mirror of
https://github.com/arsenetar/dupeguru.git
synced 2026-01-22 14:41:39 +00:00
Convert hscommon, qtlib and cocoalib to submodules
... rather than subtrees. That also represents a small qtlib updates which needed a code adjustment.
This commit is contained in:
@@ -1,74 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2011/09/09
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
def noop(*args, **kwargs):
|
||||
pass
|
||||
|
||||
class NoopGUI:
|
||||
def __getattr__(self, func_name):
|
||||
return noop
|
||||
|
||||
class GUIObject:
|
||||
"""Cross-toolkit "model" representation of a GUI layer object.
|
||||
|
||||
A ``GUIObject`` is a cross-toolkit "model" representation of a GUI layer object, for example, a
|
||||
table. It acts as a cross-toolkit interface to what we call here a :attr:`view`. That
|
||||
view is a toolkit-specific controller to the actual view (an ``NSTableView``, a ``QTableView``,
|
||||
etc.). In our GUIObject, we need a reference to that toolkit-specific controller because some
|
||||
actions have effects on it (for example, prompting it to refresh its data). The ``GUIObject``
|
||||
is typically instantiated before its :attr:`view`, that is why we set it to ``None`` on init.
|
||||
However, the GUI layer is supposed to set the view as soon as its toolkit-specific controller is
|
||||
instantiated.
|
||||
|
||||
When you subclass ``GUIObject``, you will likely want to update its view on instantiation. That
|
||||
is why we call ``self.view.refresh()`` in :meth:`_view_updated`. If you need another type of
|
||||
action on view instantiation, just override the method.
|
||||
"""
|
||||
def __init__(self):
|
||||
self._view = None
|
||||
|
||||
def _view_updated(self):
|
||||
"""(Virtual) Called after :attr:`view` has been set.
|
||||
|
||||
Doing nothing by default, this method is called after :attr:`view` has been set (it isn't
|
||||
called when it's unset, however). Use this for initialization code that requires a view
|
||||
(which is often the whole of the initialization code).
|
||||
"""
|
||||
|
||||
def has_view(self):
|
||||
return (self._view is not None) and (not isinstance(self._view, NoopGUI))
|
||||
|
||||
@property
|
||||
def view(self):
|
||||
"""A reference to our toolkit-specific view controller.
|
||||
|
||||
*view answering to GUIObject sublass's view protocol*. *get/set*
|
||||
|
||||
This view starts as ``None`` and has to be set "manually". There's two times at which we set
|
||||
the view property: On initialization, where we set the view that we'll use for our lifetime,
|
||||
and just before the view is deallocated. We need to unset our view at that time to avoid
|
||||
calls to a deallocated instance (which means a crash).
|
||||
|
||||
To unset our view, we simple assign it to ``None``.
|
||||
"""
|
||||
return self._view
|
||||
|
||||
@view.setter
|
||||
def view(self, value):
|
||||
if self._view is None:
|
||||
# Initial view assignment
|
||||
if value is None:
|
||||
return
|
||||
self._view = value
|
||||
self._view_updated()
|
||||
else:
|
||||
assert value is None
|
||||
# Instead of None, we put a NoopGUI() there to avoid rogue view callback raising an
|
||||
# exception.
|
||||
self._view = NoopGUI()
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2010-07-25
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
import copy
|
||||
|
||||
from .base import GUIObject
|
||||
|
||||
class Column:
|
||||
"""Holds column attributes such as its name, width, visibility, etc.
|
||||
|
||||
These attributes are then used to correctly configure the column on the "view" side.
|
||||
"""
|
||||
def __init__(self, name, display='', visible=True, optional=False):
|
||||
#: "programmatical" (not for display) name. Used as a reference in a couple of place, such
|
||||
#: as :meth:`Columns.column_by_name`.
|
||||
self.name = name
|
||||
#: Immutable index of the column. Doesn't change even when columns are re-ordered. Used in
|
||||
#: :meth:`Columns.column_by_index`.
|
||||
self.logical_index = 0
|
||||
#: Index of the column in the ordered set of columns.
|
||||
self.ordered_index = 0
|
||||
#: Width of the column.
|
||||
self.width = 0
|
||||
#: Default width of the column. This value usually depends on the platform and is set on
|
||||
#: columns initialisation. It will be used if column restoration doesn't contain any
|
||||
#: "remembered" widths.
|
||||
self.default_width = 0
|
||||
#: Display name (title) of the column.
|
||||
self.display = display
|
||||
#: Whether the column is visible.
|
||||
self.visible = visible
|
||||
#: Whether the column is visible by default. It will be used if column restoration doesn't
|
||||
#: contain any "remembered" widths.
|
||||
self.default_visible = visible
|
||||
#: Whether the column can have :attr:`visible` set to false.
|
||||
self.optional = optional
|
||||
|
||||
class ColumnsView:
|
||||
"""Expected interface for :class:`Columns`'s view.
|
||||
|
||||
*Not actually used in the code. For documentation purposes only.*
|
||||
|
||||
Our view, the columns controller of a table or outline, is expected to properly respond to
|
||||
callbacks.
|
||||
"""
|
||||
def restore_columns(self):
|
||||
"""Update all columns according to the model.
|
||||
|
||||
When this is called, our view has to update the columns title, order and visibility of all
|
||||
columns.
|
||||
"""
|
||||
|
||||
def set_column_visible(self, colname, visible):
|
||||
"""Update visibility of column ``colname``.
|
||||
|
||||
Called when the user toggles the visibility of a column, we must update the column
|
||||
``colname``'s visibility status to ``visible``.
|
||||
"""
|
||||
|
||||
class PrefAccessInterface:
|
||||
"""Expected interface for :class:`Columns`'s prefaccess.
|
||||
|
||||
*Not actually used in the code. For documentation purposes only.*
|
||||
"""
|
||||
def get_default(self, key, fallback_value):
|
||||
"""Retrieve the value for ``key`` in the currently running app's preference store.
|
||||
|
||||
If the key doesn't exist, return ``fallback_value``.
|
||||
"""
|
||||
|
||||
def set_default(self, key, value):
|
||||
"""Set the value ``value`` for ``key`` in the currently running app's preference store.
|
||||
"""
|
||||
|
||||
class Columns(GUIObject):
|
||||
"""Cross-toolkit GUI-enabled column set for tables or outlines.
|
||||
|
||||
Manages a column set's order, visibility and width. We also manage the persistence of these
|
||||
attributes so that we can restore them on the next run.
|
||||
|
||||
Subclasses :class:`.GUIObject`. Expected view: :class:`ColumnsView`.
|
||||
|
||||
:param table: The table the columns belong to. It's from there that we retrieve our column
|
||||
configuration and it must have a ``COLUMNS`` attribute which is a list of
|
||||
:class:`Column`. We also call :meth:`~.GUITable.save_edits` on it from time to
|
||||
time. Technically, this argument can also be a tree, but there's probably some
|
||||
sorting in the code to do to support this option cleanly.
|
||||
:param prefaccess: An object giving access to user preferences for the currently running app.
|
||||
We use this to make column attributes persistent. Must follow
|
||||
:class:`PrefAccessInterface`.
|
||||
:param str savename: The name under which column preferences will be saved. This name is in fact
|
||||
a prefix. Preferences are saved under more than one name, but they will all
|
||||
have that same prefix.
|
||||
"""
|
||||
def __init__(self, table, prefaccess=None, savename=None):
|
||||
GUIObject.__init__(self)
|
||||
self.table = table
|
||||
self.prefaccess = prefaccess
|
||||
self.savename = savename
|
||||
# We use copy here for test isolation. If we don't, changing a column affects all tests.
|
||||
self.column_list = list(map(copy.copy, table.COLUMNS))
|
||||
for i, column in enumerate(self.column_list):
|
||||
column.logical_index = i
|
||||
column.ordered_index = i
|
||||
self.coldata = {col.name: col for col in self.column_list}
|
||||
|
||||
#--- Private
|
||||
def _get_colname_attr(self, colname, attrname, default):
|
||||
try:
|
||||
return getattr(self.coldata[colname], attrname)
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
def _set_colname_attr(self, colname, attrname, value):
|
||||
try:
|
||||
col = self.coldata[colname]
|
||||
setattr(col, attrname, value)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def _optional_columns(self):
|
||||
return [c for c in self.column_list if c.optional]
|
||||
|
||||
#--- Override
|
||||
def _view_updated(self):
|
||||
self.restore_columns()
|
||||
|
||||
#--- Public
|
||||
def column_by_index(self, index):
|
||||
"""Return the :class:`Column` having the :attr:`~Column.logical_index` ``index``.
|
||||
"""
|
||||
return self.column_list[index]
|
||||
|
||||
def column_by_name(self, name):
|
||||
"""Return the :class:`Column` having the :attr:`~Column.name` ``name``.
|
||||
"""
|
||||
return self.coldata[name]
|
||||
|
||||
def columns_count(self):
|
||||
"""Returns the number of columns in our set.
|
||||
"""
|
||||
return len(self.column_list)
|
||||
|
||||
def column_display(self, colname):
|
||||
"""Returns display name for column named ``colname``, or ``''`` if there's none.
|
||||
"""
|
||||
return self._get_colname_attr(colname, 'display', '')
|
||||
|
||||
def column_is_visible(self, colname):
|
||||
"""Returns visibility for column named ``colname``, or ``True`` if there's none.
|
||||
"""
|
||||
return self._get_colname_attr(colname, 'visible', True)
|
||||
|
||||
def column_width(self, colname):
|
||||
"""Returns width for column named ``colname``, or ``0`` if there's none.
|
||||
"""
|
||||
return self._get_colname_attr(colname, 'width', 0)
|
||||
|
||||
def columns_to_right(self, colname):
|
||||
"""Returns the list of all columns to the right of ``colname``.
|
||||
|
||||
"right" meaning "having a higher :attr:`Column.ordered_index`" in our left-to-right
|
||||
civilization.
|
||||
"""
|
||||
column = self.coldata[colname]
|
||||
index = column.ordered_index
|
||||
return [col.name for col in self.column_list if (col.visible and col.ordered_index > index)]
|
||||
|
||||
def menu_items(self):
|
||||
"""Returns a list of items convenient for quick visibility menu generation.
|
||||
|
||||
Returns a list of ``(display_name, is_marked)`` items for each optional column in the
|
||||
current view (``is_marked`` means that it's visible).
|
||||
|
||||
You can use this to generate a menu to let the user toggle the visibility of an optional
|
||||
column. That is why we only show optional column, because the visibility of mandatory
|
||||
columns can't be toggled.
|
||||
"""
|
||||
return [(c.display, c.visible) for c in self._optional_columns()]
|
||||
|
||||
def move_column(self, colname, index):
|
||||
"""Moves column ``colname`` to ``index``.
|
||||
|
||||
The column will be placed just in front of the column currently having that index, or to the
|
||||
end of the list if there's none.
|
||||
"""
|
||||
colnames = self.colnames
|
||||
colnames.remove(colname)
|
||||
colnames.insert(index, colname)
|
||||
self.set_column_order(colnames)
|
||||
|
||||
def reset_to_defaults(self):
|
||||
"""Reset all columns' width and visibility to their default values.
|
||||
"""
|
||||
self.set_column_order([col.name for col in self.column_list])
|
||||
for col in self._optional_columns():
|
||||
col.visible = col.default_visible
|
||||
col.width = col.default_width
|
||||
self.view.restore_columns()
|
||||
|
||||
def resize_column(self, colname, newwidth):
|
||||
"""Set column ``colname``'s width to ``newwidth``.
|
||||
"""
|
||||
self._set_colname_attr(colname, 'width', newwidth)
|
||||
|
||||
def restore_columns(self):
|
||||
"""Restore's column persistent attributes from the last :meth:`save_columns`.
|
||||
"""
|
||||
if not (self.prefaccess and self.savename and self.coldata):
|
||||
if (not self.savename) and (self.coldata):
|
||||
# This is a table that will not have its coldata saved/restored. we should
|
||||
# "restore" its default column attributes.
|
||||
self.view.restore_columns()
|
||||
return
|
||||
for col in self.column_list:
|
||||
pref_name = '{}.Columns.{}'.format(self.savename, col.name)
|
||||
coldata = self.prefaccess.get_default(pref_name, fallback_value={})
|
||||
if 'index' in coldata:
|
||||
col.ordered_index = coldata['index']
|
||||
if 'width' in coldata:
|
||||
col.width = coldata['width']
|
||||
if col.optional and 'visible' in coldata:
|
||||
col.visible = coldata['visible']
|
||||
self.view.restore_columns()
|
||||
|
||||
def save_columns(self):
|
||||
"""Save column attributes in persistent storage for restoration in :meth:`restore_columns`.
|
||||
"""
|
||||
if not (self.prefaccess and self.savename and self.coldata):
|
||||
return
|
||||
for col in self.column_list:
|
||||
pref_name = '{}.Columns.{}'.format(self.savename, col.name)
|
||||
coldata = {'index': col.ordered_index, 'width': col.width}
|
||||
if col.optional:
|
||||
coldata['visible'] = col.visible
|
||||
self.prefaccess.set_default(pref_name, coldata)
|
||||
|
||||
def set_column_order(self, colnames):
|
||||
"""Change the columns order so it matches the order in ``colnames``.
|
||||
|
||||
:param colnames: A list of column names in the desired order.
|
||||
"""
|
||||
colnames = (name for name in colnames if name in self.coldata)
|
||||
for i, colname in enumerate(colnames):
|
||||
col = self.coldata[colname]
|
||||
col.ordered_index = i
|
||||
|
||||
def set_column_visible(self, colname, visible):
|
||||
"""Set the visibility of column ``colname``.
|
||||
"""
|
||||
self.table.save_edits() # the table on the GUI side will stop editing when the columns change
|
||||
self._set_colname_attr(colname, 'visible', visible)
|
||||
self.view.set_column_visible(colname, visible)
|
||||
|
||||
def set_default_width(self, colname, width):
|
||||
"""Set the default width or column ``colname``.
|
||||
"""
|
||||
self._set_colname_attr(colname, 'default_width', width)
|
||||
|
||||
def toggle_menu_item(self, index):
|
||||
"""Toggles the visibility of an optional column.
|
||||
|
||||
You know, that optional column menu you've generated in :meth:`menu_items`? Well, ``index``
|
||||
is the index of them menu item in *that* menu that the user has clicked on to toggle it.
|
||||
|
||||
Returns whether the column in question ends up being visible or not.
|
||||
"""
|
||||
col = self._optional_columns()[index]
|
||||
self.set_column_visible(col.name, not col.visible)
|
||||
return col.visible
|
||||
|
||||
#--- Properties
|
||||
@property
|
||||
def ordered_columns(self):
|
||||
"""List of :class:`Column` in visible order.
|
||||
"""
|
||||
return [col for col in sorted(self.column_list, key=lambda col: col.ordered_index)]
|
||||
|
||||
@property
|
||||
def colnames(self):
|
||||
"""List of column names in visible order.
|
||||
"""
|
||||
return [col.name for col in self.ordered_columns]
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
# Created On: 2013/07/01
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
from ..jobprogress.performer import ThreadedJobPerformer
|
||||
from .base import GUIObject
|
||||
from .text_field import TextField
|
||||
|
||||
class ProgressWindowView:
|
||||
"""Expected interface for :class:`ProgressWindow`'s view.
|
||||
|
||||
*Not actually used in the code. For documentation purposes only.*
|
||||
|
||||
Our view, some kind window with a progress bar, two labels and a cancel button, is expected
|
||||
to properly respond to its callbacks.
|
||||
|
||||
It's also expected to call :meth:`ProgressWindow.cancel` when the cancel button is clicked.
|
||||
"""
|
||||
def show(self):
|
||||
"""Show the dialog.
|
||||
"""
|
||||
|
||||
def close(self):
|
||||
"""Close the dialog.
|
||||
"""
|
||||
|
||||
def set_progress(self, progress):
|
||||
"""Set the progress of the progress bar to ``progress``.
|
||||
|
||||
Not all jobs are equally responsive on their job progress report and it is recommended that
|
||||
you put your progressbar in "indeterminate" mode as long as you haven't received the first
|
||||
``set_progress()`` call to avoid letting the user think that the app is frozen.
|
||||
|
||||
:param int progress: a value between ``0`` and ``100``.
|
||||
"""
|
||||
|
||||
class ProgressWindow(GUIObject, ThreadedJobPerformer):
|
||||
"""Cross-toolkit GUI-enabled progress window.
|
||||
|
||||
This class allows you to run a long running, job enabled function in a separate thread and
|
||||
allow the user to follow its progress with a progress dialog.
|
||||
|
||||
To use it, you start your long-running job with :meth:`run` and then have your UI layer
|
||||
regularly call :meth:`pulse` to refresh the job status in the UI. It is advised that you call
|
||||
:meth:`pulse` in the main thread because GUI toolkit usually only support calling UI-related
|
||||
functions from the main thread.
|
||||
|
||||
We subclass :class:`.GUIObject` and :class:`.ThreadedJobPerformer`.
|
||||
Expected view: :class:`ProgressWindowView`.
|
||||
|
||||
:param finishfunc: A function ``f(jobid)`` that is called when a job is completed. ``jobid`` is
|
||||
an arbitrary id passed to :meth:`run`.
|
||||
"""
|
||||
def __init__(self, finish_func):
|
||||
# finish_func(jobid) is the function that is called when a job is completed.
|
||||
GUIObject.__init__(self)
|
||||
ThreadedJobPerformer.__init__(self)
|
||||
self._finish_func = finish_func
|
||||
#: :class:`.TextField`. It contains that title you gave the job on :meth:`run`.
|
||||
self.jobdesc_textfield = TextField()
|
||||
#: :class:`.TextField`. It contains the job textual update that the function might yield
|
||||
#: during its course.
|
||||
self.progressdesc_textfield = TextField()
|
||||
self.jobid = None
|
||||
|
||||
def cancel(self):
|
||||
"""Call for a user-initiated job cancellation.
|
||||
"""
|
||||
# The UI is sometimes a bit buggy and calls cancel() on self.view.close(). We just want to
|
||||
# make sure that this doesn't lead us to think that the user acually cancelled the task, so
|
||||
# we verify that the job is still running.
|
||||
if self._job_running:
|
||||
self.job_cancelled = True
|
||||
|
||||
def pulse(self):
|
||||
"""Update progress reports in the GUI.
|
||||
|
||||
Call this regularly from the GUI main run loop. The values might change before
|
||||
:meth:`ProgressWindowView.set_progress` happens.
|
||||
|
||||
If the job is finished, ``pulse()`` will take care of closing the window and re-raising any
|
||||
exception that might have been raised during the job (in the main thread this time). If
|
||||
there was no exception, ``finish_func(jobid)`` is called to let you take appropriate action.
|
||||
"""
|
||||
last_progress = self.last_progress
|
||||
last_desc = self.last_desc
|
||||
if not self._job_running or last_progress is None:
|
||||
self.view.close()
|
||||
self.reraise_if_error()
|
||||
if not self.job_cancelled:
|
||||
self._finish_func(self.jobid)
|
||||
return
|
||||
if self.job_cancelled:
|
||||
return
|
||||
if last_desc:
|
||||
self.progressdesc_textfield.text = last_desc
|
||||
self.view.set_progress(last_progress)
|
||||
|
||||
def run(self, jobid, title, target, args=()):
|
||||
"""Starts a threaded job.
|
||||
|
||||
The ``target`` function will be sent, as its first argument, a :class:`.Job` instance which
|
||||
it can use to report on its progress.
|
||||
|
||||
:param jobid: Arbitrary identifier which will be passed to ``finish_func()`` at the end.
|
||||
:param title: A title for the task you're starting.
|
||||
:param target: The function that does your famous long running job.
|
||||
:param args: additional arguments that you want to send to ``target``.
|
||||
"""
|
||||
# target is a function with its first argument being a Job. It can then be followed by other
|
||||
# arguments which are passed as `args`.
|
||||
self.jobid = jobid
|
||||
self.progressdesc_textfield.text = ''
|
||||
j = self.create_job()
|
||||
args = tuple([j] + list(args))
|
||||
self.run_threaded(target, args)
|
||||
self.jobdesc_textfield.text = title
|
||||
self.view.show()
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
# Created By: Virgil Dupras
|
||||
# Created On: 2011-09-06
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
from collections import Sequence, MutableSequence
|
||||
|
||||
from .base import GUIObject
|
||||
|
||||
class Selectable(Sequence):
|
||||
"""Mix-in for a ``Sequence`` that manages its selection status.
|
||||
|
||||
When mixed in with a ``Sequence``, we enable it to manage its selection status. The selection
|
||||
is held as a list of ``int`` indexes. Multiple selection is supported.
|
||||
"""
|
||||
def __init__(self):
|
||||
self._selected_indexes = []
|
||||
|
||||
#--- Private
|
||||
def _check_selection_range(self):
|
||||
if not self:
|
||||
self._selected_indexes = []
|
||||
if not self._selected_indexes:
|
||||
return
|
||||
self._selected_indexes = [index for index in self._selected_indexes if index < len(self)]
|
||||
if not self._selected_indexes:
|
||||
self._selected_indexes = [len(self) - 1]
|
||||
|
||||
#--- Virtual
|
||||
def _update_selection(self):
|
||||
"""(Virtual) Updates the model's selection appropriately.
|
||||
|
||||
Called after selection has been updated. Takes the table's selection and does appropriates
|
||||
updates on the view and/or model. Common sense would dictate that when the selection doesn't
|
||||
change, we don't update anything (and thus don't call ``_update_selection()`` at all), but
|
||||
there are cases where it's false. For example, if our list updates its items but doesn't
|
||||
change its selection, we probably want to update the model's selection.
|
||||
|
||||
By default, does nothing.
|
||||
|
||||
Important note: This is only called on :meth:`select`, not on changes to
|
||||
:attr:`selected_indexes`.
|
||||
"""
|
||||
# A redesign of how this whole thing works is probably in order, but not now, there's too
|
||||
# much breakage at once involved.
|
||||
|
||||
#--- Public
|
||||
def select(self, indexes):
|
||||
"""Update selection to ``indexes``.
|
||||
|
||||
:meth:`_update_selection` is called afterwards.
|
||||
|
||||
:param list indexes: List of ``int`` that is to become the new selection.
|
||||
"""
|
||||
if isinstance(indexes, int):
|
||||
indexes = [indexes]
|
||||
self.selected_indexes = indexes
|
||||
self._update_selection()
|
||||
|
||||
#--- Properties
|
||||
@property
|
||||
def selected_index(self):
|
||||
"""Points to the first selected index.
|
||||
|
||||
*int*. *get/set*.
|
||||
|
||||
Thin wrapper around :attr:`selected_indexes`. ``None`` if selection is empty. Using this
|
||||
property only makes sense if your selectable sequence supports single selection only.
|
||||
"""
|
||||
return self._selected_indexes[0] if self._selected_indexes else None
|
||||
|
||||
@selected_index.setter
|
||||
def selected_index(self, value):
|
||||
self.selected_indexes = [value]
|
||||
|
||||
@property
|
||||
def selected_indexes(self):
|
||||
"""List of selected indexes.
|
||||
|
||||
*list of int*. *get/set*.
|
||||
|
||||
When setting the value, automatically removes out-of-bounds indexes. The list is kept
|
||||
sorted.
|
||||
"""
|
||||
return self._selected_indexes
|
||||
|
||||
@selected_indexes.setter
|
||||
def selected_indexes(self, value):
|
||||
self._selected_indexes = value
|
||||
self._selected_indexes.sort()
|
||||
self._check_selection_range()
|
||||
|
||||
|
||||
class SelectableList(MutableSequence, Selectable):
|
||||
"""A list that can manage selection of its items.
|
||||
|
||||
Subclasses :class:`Selectable`. Behaves like a ``list``.
|
||||
"""
|
||||
def __init__(self, items=None):
|
||||
Selectable.__init__(self)
|
||||
if items:
|
||||
self._items = list(items)
|
||||
else:
|
||||
self._items = []
|
||||
|
||||
def __delitem__(self, key):
|
||||
self._items.__delitem__(key)
|
||||
self._check_selection_range()
|
||||
self._on_change()
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._items.__getitem__(key)
|
||||
|
||||
def __len__(self):
|
||||
return len(self._items)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self._items.__setitem__(key, value)
|
||||
self._on_change()
|
||||
|
||||
#--- Override
|
||||
def append(self, item):
|
||||
self._items.append(item)
|
||||
self._on_change()
|
||||
|
||||
def insert(self, index, item):
|
||||
self._items.insert(index, item)
|
||||
self._on_change()
|
||||
|
||||
def remove(self, row):
|
||||
self._items.remove(row)
|
||||
self._check_selection_range()
|
||||
self._on_change()
|
||||
|
||||
#--- Virtual
|
||||
def _on_change(self):
|
||||
"""(Virtual) Called whenever the contents of the list changes.
|
||||
|
||||
By default, does nothing.
|
||||
"""
|
||||
|
||||
#--- Public
|
||||
def search_by_prefix(self, prefix):
|
||||
# XXX Why the heck is this method here?
|
||||
prefix = prefix.lower()
|
||||
for index, s in enumerate(self):
|
||||
if s.lower().startswith(prefix):
|
||||
return index
|
||||
return -1
|
||||
|
||||
|
||||
class GUISelectableListView:
|
||||
"""Expected interface for :class:`GUISelectableList`'s view.
|
||||
|
||||
*Not actually used in the code. For documentation purposes only.*
|
||||
|
||||
Our view, some kind of list view or combobox, is expected to sync with the list's contents by
|
||||
appropriately behave to all callbacks in this interface.
|
||||
"""
|
||||
def refresh(self):
|
||||
"""Refreshes the contents of the list widget.
|
||||
|
||||
Ensures that the contents of the list widget is synced with the model.
|
||||
"""
|
||||
|
||||
def update_selection(self):
|
||||
"""Update selection status.
|
||||
|
||||
Ensures that the list widget's selection is in sync with the model.
|
||||
"""
|
||||
|
||||
class GUISelectableList(SelectableList, GUIObject):
|
||||
"""Cross-toolkit GUI-enabled list view.
|
||||
|
||||
Represents a UI element presenting the user with a selectable list of items.
|
||||
|
||||
Subclasses :class:`SelectableList` and :class:`.GUIObject`. Expected view:
|
||||
:class:`GUISelectableListView`.
|
||||
|
||||
:param iterable items: If specified, items to fill the list with initially.
|
||||
"""
|
||||
def __init__(self, items=None):
|
||||
SelectableList.__init__(self, items)
|
||||
GUIObject.__init__(self)
|
||||
|
||||
def _view_updated(self):
|
||||
"""Refreshes the view contents with :meth:`GUISelectableListView.refresh`.
|
||||
|
||||
Overrides :meth:`~hscommon.gui.base.GUIObject._view_updated`.
|
||||
"""
|
||||
self.view.refresh()
|
||||
|
||||
def _update_selection(self):
|
||||
"""Refreshes the view selection with :meth:`GUISelectableListView.update_selection`.
|
||||
|
||||
Overrides :meth:`Selectable._update_selection`.
|
||||
"""
|
||||
self.view.update_selection()
|
||||
|
||||
def _on_change(self):
|
||||
"""Refreshes the view contents with :meth:`GUISelectableListView.refresh`.
|
||||
|
||||
Overrides :meth:`SelectableList._on_change`.
|
||||
"""
|
||||
self.view.refresh()
|
||||
@@ -1,543 +0,0 @@
|
||||
# Created By: Eric Mc Sween
|
||||
# Created On: 2008-05-29
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
from collections import MutableSequence, namedtuple
|
||||
|
||||
from .base import GUIObject
|
||||
from .selectable_list import Selectable
|
||||
|
||||
# We used to directly subclass list, but it caused problems at some point with deepcopy
|
||||
class Table(MutableSequence, Selectable):
|
||||
"""Sortable and selectable sequence of :class:`Row`.
|
||||
|
||||
In fact, the Table is very similar to :class:`.SelectableList` in
|
||||
practice and differs mostly in principle. Their difference lies in the nature of their items
|
||||
they manage. With the Table, rows usually have many properties, presented in columns, and they
|
||||
have to subclass :class:`Row`.
|
||||
|
||||
Usually used with :class:`~hscommon.gui.column.Column`.
|
||||
|
||||
Subclasses :class:`.Selectable`.
|
||||
"""
|
||||
def __init__(self):
|
||||
Selectable.__init__(self)
|
||||
self._rows = []
|
||||
self._header = None
|
||||
self._footer = None
|
||||
|
||||
def __delitem__(self, key):
|
||||
self._rows.__delitem__(key)
|
||||
if self._header is not None and ((not self) or (self[0] is not self._header)):
|
||||
self._header = None
|
||||
if self._footer is not None and ((not self) or (self[-1] is not self._footer)):
|
||||
self._footer = None
|
||||
self._check_selection_range()
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._rows.__getitem__(key)
|
||||
|
||||
def __len__(self):
|
||||
return len(self._rows)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self._rows.__setitem__(key, value)
|
||||
|
||||
def append(self, item):
|
||||
"""Appends ``item`` at the end of the table.
|
||||
|
||||
If there's a footer, the item is inserted before it.
|
||||
"""
|
||||
if self._footer is not None:
|
||||
self._rows.insert(-1, item)
|
||||
else:
|
||||
self._rows.append(item)
|
||||
|
||||
def insert(self, index, item):
|
||||
"""Inserts ``item`` at ``index`` in the table.
|
||||
|
||||
If there's a header, will make sure we don't insert before it, and if there's a footer, will
|
||||
make sure that we don't insert after it.
|
||||
"""
|
||||
if (self._header is not None) and (index == 0):
|
||||
index = 1
|
||||
if (self._footer is not None) and (index >= len(self)):
|
||||
index = len(self) - 1
|
||||
self._rows.insert(index, item)
|
||||
|
||||
def remove(self, row):
|
||||
"""Removes ``row`` from table.
|
||||
|
||||
If ``row`` is a header or footer, that header or footer will be set to ``None``.
|
||||
"""
|
||||
if row is self._header:
|
||||
self._header = None
|
||||
if row is self._footer:
|
||||
self._footer = None
|
||||
self._rows.remove(row)
|
||||
self._check_selection_range()
|
||||
|
||||
def sort_by(self, column_name, desc=False):
|
||||
"""Sort table by ``column_name``.
|
||||
|
||||
Sort key for each row is computed from :meth:`Row.sort_key_for_column`.
|
||||
|
||||
If ``desc`` is ``True``, sort order is reversed.
|
||||
|
||||
If present, header and footer will always be first and last, respectively.
|
||||
"""
|
||||
if self._header is not None:
|
||||
self._rows.pop(0)
|
||||
if self._footer is not None:
|
||||
self._rows.pop()
|
||||
key = lambda row: row.sort_key_for_column(column_name)
|
||||
self._rows.sort(key=key, reverse=desc)
|
||||
if self._header is not None:
|
||||
self._rows.insert(0, self._header)
|
||||
if self._footer is not None:
|
||||
self._rows.append(self._footer)
|
||||
|
||||
#--- Properties
|
||||
@property
|
||||
def footer(self):
|
||||
"""If set, a row that always stay at the bottom of the table.
|
||||
|
||||
:class:`Row`. *get/set*.
|
||||
|
||||
When set to something else than ``None``, ``header`` and ``footer`` represent rows that will
|
||||
always be kept in first and/or last position, regardless of sorting. ``len()`` and indexing
|
||||
will include them, which means that if there's a header, ``table[0]`` returns it and if
|
||||
there's a footer, ``table[-1]`` returns it. To make things short, all list-like functions
|
||||
work with header and footer "on". But things get fuzzy for ``append()`` and ``insert()``
|
||||
because these will ensure that no "normal" row gets inserted before the header or after the
|
||||
footer.
|
||||
|
||||
Adding and removing footer here and there might seem (and is) hackish, but it's much simpler
|
||||
than the alternative (when, of course, you need such a feature), which is to override magic
|
||||
methods and adjust the results. When we do that, there the slice stuff that we have to
|
||||
implement and it gets quite complex. Moreover, the most frequent operation on a table is
|
||||
``__getitem__``, and making checks to know whether the key is a header or footer at each
|
||||
call would make that operation, which is the most used, slower.
|
||||
"""
|
||||
return self._footer
|
||||
|
||||
@footer.setter
|
||||
def footer(self, value):
|
||||
if self._footer is not None:
|
||||
self._rows.pop()
|
||||
if value is not None:
|
||||
self._rows.append(value)
|
||||
self._footer = value
|
||||
|
||||
@property
|
||||
def header(self):
|
||||
"""If set, a row that always stay at the bottom of the table.
|
||||
|
||||
See :attr:`footer` for details.
|
||||
"""
|
||||
return self._header
|
||||
|
||||
@header.setter
|
||||
def header(self, value):
|
||||
if self._header is not None:
|
||||
self._rows.pop(0)
|
||||
if value is not None:
|
||||
self._rows.insert(0, value)
|
||||
self._header = value
|
||||
|
||||
@property
|
||||
def row_count(self):
|
||||
"""Number or rows in the table (without counting header and footer).
|
||||
|
||||
*int*. *read-only*.
|
||||
"""
|
||||
result = len(self)
|
||||
if self._footer is not None:
|
||||
result -= 1
|
||||
if self._header is not None:
|
||||
result -= 1
|
||||
return result
|
||||
|
||||
@property
|
||||
def rows(self):
|
||||
"""List of rows in the table, excluding header and footer.
|
||||
|
||||
List of :class:`Row`. *read-only*.
|
||||
"""
|
||||
start = None
|
||||
end = None
|
||||
if self._footer is not None:
|
||||
end = -1
|
||||
if self._header is not None:
|
||||
start = 1
|
||||
return self[start:end]
|
||||
|
||||
@property
|
||||
def selected_row(self):
|
||||
"""Selected row according to :attr:`.selected_index`.
|
||||
|
||||
:class:`Row`. *get/set*.
|
||||
|
||||
When setting this attribute, we look up the index of the row and set the selected index from
|
||||
there. If the row isn't in the list, selection isn't changed.
|
||||
"""
|
||||
return self[self.selected_index] if self.selected_index is not None else None
|
||||
|
||||
@selected_row.setter
|
||||
def selected_row(self, value):
|
||||
try:
|
||||
self.selected_index = self.index(value)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
@property
|
||||
def selected_rows(self):
|
||||
"""List of selected rows based on :attr:`.selected_indexes`.
|
||||
|
||||
List of :class:`Row`. *read-only*.
|
||||
"""
|
||||
return [self[index] for index in self.selected_indexes]
|
||||
|
||||
|
||||
class GUITableView:
|
||||
"""Expected interface for :class:`GUITable`'s view.
|
||||
|
||||
*Not actually used in the code. For documentation purposes only.*
|
||||
|
||||
Our view, some kind of table view, is expected to sync with the table's contents by
|
||||
appropriately behave to all callbacks in this interface.
|
||||
|
||||
When in edit mode, the content types by the user is expected to be sent as soon as possible
|
||||
to the :class:`Row`.
|
||||
|
||||
Whenever the user changes the selection, we expect the view to call :meth:`Table.select`.
|
||||
"""
|
||||
def refresh(self):
|
||||
"""Refreshes the contents of the table widget.
|
||||
|
||||
Ensures that the contents of the table widget is synced with the model. This includes
|
||||
selection.
|
||||
"""
|
||||
|
||||
def start_editing(self):
|
||||
"""Start editing the currently selected row.
|
||||
|
||||
Begin whatever inline editing support that the view supports.
|
||||
"""
|
||||
|
||||
def stop_editing(self):
|
||||
"""Stop editing if there's an inline editing in effect.
|
||||
|
||||
There's no "aborting" implied in this call, so it's appropriate to send whatever the user
|
||||
has typed and might not have been sent down to the :class:`Row` yet. After you've done that,
|
||||
stop the editing mechanism.
|
||||
"""
|
||||
|
||||
|
||||
SortDescriptor = namedtuple('SortDescriptor', 'column desc')
|
||||
class GUITable(Table, GUIObject):
|
||||
"""Cross-toolkit GUI-enabled table view.
|
||||
|
||||
Represents a UI element presenting the user with a sortable, selectable, possibly editable,
|
||||
table view.
|
||||
|
||||
Behaves like the :class:`Table` which it subclasses, but is more focused on being the presenter
|
||||
of some model data to its :attr:`.GUIObject.view`. There's a :meth:`refresh`
|
||||
mechanism which ensures fresh data while preserving sorting order and selection. There's also an
|
||||
editing mechanism which tracks whether (and which) row is being edited (or added) and
|
||||
save/cancel edits when appropriate.
|
||||
|
||||
Subclasses :class:`Table` and :class:`.GUIObject`. Expected view:
|
||||
:class:`GUITableView`.
|
||||
"""
|
||||
def __init__(self):
|
||||
GUIObject.__init__(self)
|
||||
Table.__init__(self)
|
||||
#: The row being currently edited by the user. ``None`` if no edit is taking place.
|
||||
self.edited = None
|
||||
self._sort_descriptor = None
|
||||
|
||||
#--- Virtual
|
||||
def _do_add(self):
|
||||
"""(Virtual) Creates a new row, adds it in the table.
|
||||
|
||||
Returns ``(row, insert_index)``.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def _do_delete(self):
|
||||
"""(Virtual) Delete the selected rows.
|
||||
"""
|
||||
pass
|
||||
|
||||
def _fill(self):
|
||||
"""(Virtual/Required) Fills the table with all the rows that this table is supposed to have.
|
||||
|
||||
Called by :meth:`refresh`. Does nothing by default.
|
||||
"""
|
||||
pass
|
||||
|
||||
def _is_edited_new(self):
|
||||
"""(Virtual) Returns whether the currently edited row should be considered "new".
|
||||
|
||||
This is used in :meth:`cancel_edits` to know whether the cancellation of the edit means a
|
||||
revert of the row's value or the removal of the row.
|
||||
|
||||
By default, always false.
|
||||
"""
|
||||
return False
|
||||
|
||||
def _restore_selection(self, previous_selection):
|
||||
"""(Virtual) Restores row selection after a contents-changing operation.
|
||||
|
||||
Before each contents changing operation, we store our previously selected indexes because in
|
||||
many cases, such as in :meth:`refresh`, our selection will be lost. After the operation is
|
||||
over, we call this method with our previously selected indexes (in ``previous_selection``).
|
||||
|
||||
The default behavior is (if we indeed have an empty :attr:`.selected_indexes`) to re-select
|
||||
``previous_selection``. If it was empty, we select the last row of the table.
|
||||
|
||||
This behavior can, of course, be overriden.
|
||||
"""
|
||||
if not self.selected_indexes:
|
||||
if previous_selection:
|
||||
self.select(previous_selection)
|
||||
else:
|
||||
self.select([len(self) - 1])
|
||||
|
||||
#--- Public
|
||||
def add(self):
|
||||
"""Add a new row in edit mode.
|
||||
|
||||
Requires :meth:`do_add` to be implemented. The newly added row will be selected and in edit
|
||||
mode.
|
||||
"""
|
||||
self.view.stop_editing()
|
||||
if self.edited is not None:
|
||||
self.save_edits()
|
||||
row, insert_index = self._do_add()
|
||||
self.insert(insert_index, row)
|
||||
self.select([insert_index])
|
||||
self.edited = row
|
||||
self.view.refresh()
|
||||
self.view.start_editing()
|
||||
|
||||
def can_edit_cell(self, column_name, row_index):
|
||||
"""Returns whether the cell at ``row_index`` and ``column_name`` can be edited.
|
||||
|
||||
A row is, by default, editable as soon as it has an attr with the same name as `column`.
|
||||
If :meth:`Row.can_edit` returns False, the row is not editable at all. You can set
|
||||
editability of rows at the attribute level with can_edit_* properties.
|
||||
|
||||
Mostly just a shortcut to :meth:`Row.can_edit_cell`.
|
||||
"""
|
||||
row = self[row_index]
|
||||
return row.can_edit_cell(column_name)
|
||||
|
||||
def cancel_edits(self):
|
||||
"""Cancels the current edit operation.
|
||||
|
||||
If there's an :attr:`edited` row, it will be re-initialized (with :meth:`Row.load`).
|
||||
"""
|
||||
if self.edited is None:
|
||||
return
|
||||
self.view.stop_editing()
|
||||
if self._is_edited_new():
|
||||
previous_selection = self.selected_indexes
|
||||
self.remove(self.edited)
|
||||
self._restore_selection(previous_selection)
|
||||
self._update_selection()
|
||||
else:
|
||||
self.edited.load()
|
||||
self.edited = None
|
||||
self.view.refresh()
|
||||
|
||||
def delete(self):
|
||||
"""Delete the currently selected rows.
|
||||
|
||||
Requires :meth:`_do_delete` for this to have any effect on the model. Cancels editing if
|
||||
relevant.
|
||||
"""
|
||||
self.view.stop_editing()
|
||||
if self.edited is not None:
|
||||
self.cancel_edits()
|
||||
return
|
||||
if self:
|
||||
self._do_delete()
|
||||
|
||||
def refresh(self, refresh_view=True):
|
||||
"""Empty the table and re-create its rows.
|
||||
|
||||
:meth:`_fill` is called after we emptied the table to create our rows. Previous sort order
|
||||
will be preserved, regardless of the order in which the rows were filled. If there was any
|
||||
edit operation taking place, it's cancelled.
|
||||
|
||||
:param bool refresh_view: Whether we tell our view to refresh after our refill operation.
|
||||
Most of the time, it's what we want, but there's some cases where
|
||||
we don't.
|
||||
"""
|
||||
self.cancel_edits()
|
||||
previous_selection = self.selected_indexes
|
||||
del self[:]
|
||||
self._fill()
|
||||
sd = self._sort_descriptor
|
||||
if sd is not None:
|
||||
Table.sort_by(self, column_name=sd.column, desc=sd.desc)
|
||||
self._restore_selection(previous_selection)
|
||||
if refresh_view:
|
||||
self.view.refresh()
|
||||
|
||||
def save_edits(self):
|
||||
"""Commit user edits to the model.
|
||||
|
||||
This is done by calling :meth:`Row.save`.
|
||||
"""
|
||||
if self.edited is None:
|
||||
return
|
||||
row = self.edited
|
||||
self.edited = None
|
||||
row.save()
|
||||
|
||||
def sort_by(self, column_name, desc=False):
|
||||
"""Sort table by ``column_name``.
|
||||
|
||||
Overrides :meth:`Table.sort_by`. After having performed sorting, calls
|
||||
:meth:`~.Selectable._update_selection` to give you the chance,
|
||||
if appropriate, to update your selected indexes according to, maybe, the selection that you
|
||||
have in your model.
|
||||
|
||||
Then, we refresh our view.
|
||||
"""
|
||||
Table.sort_by(self, column_name=column_name, desc=desc)
|
||||
self._sort_descriptor = SortDescriptor(column_name, desc)
|
||||
self._update_selection()
|
||||
self.view.refresh()
|
||||
|
||||
|
||||
class Row:
|
||||
"""Represents a row in a :class:`Table`.
|
||||
|
||||
It holds multiple values to be represented through columns. It's its role to prepare data
|
||||
fetched from model instances into ready-to-present-in-a-table fashion. You will do this in
|
||||
:meth:`load`.
|
||||
|
||||
When you do this, you'll put the result into arbitrary attributes, which will later be fetched
|
||||
by your table for presentation to the user.
|
||||
|
||||
You can organize your attributes in whatever way you want, but there's a convention you can
|
||||
follow if you want to minimize subclassing and use default behavior:
|
||||
|
||||
1. Attribute name = column name. If your attribute is ``foobar``, whenever we refer to
|
||||
``column_name``, you refer to that attribute with the column name ``foobar``.
|
||||
2. Public attributes are for *formatted* value, that is, user readable strings.
|
||||
3. Underscore prefix is the unformatted (computable) value. For example, you could have
|
||||
``_foobar`` at ``42`` and ``foobar`` at ``"42 seconds"`` (what you present to the user).
|
||||
4. Unformatted values are used for sorting.
|
||||
5. If your column name is a python keyword, add an underscore suffix (``from_``).
|
||||
|
||||
Of course, this is only default behavior. This can be overriden.
|
||||
"""
|
||||
def __init__(self, table):
|
||||
super(Row, self).__init__()
|
||||
self.table = table
|
||||
|
||||
def _edit(self):
|
||||
if self.table.edited is self:
|
||||
return
|
||||
assert self.table.edited is None
|
||||
self.table.edited = self
|
||||
|
||||
#--- Virtual
|
||||
def can_edit(self):
|
||||
"""(Virtual) Whether the whole row can be edited.
|
||||
|
||||
By default, always returns ``True``. This is for the *whole* row. For individual cells, it's
|
||||
:meth:`can_edit_cell`.
|
||||
"""
|
||||
return True
|
||||
|
||||
def load(self):
|
||||
"""(Virtual/Required) Loads up values from the model to be presented in the table.
|
||||
|
||||
Usually, our model instances contain values that are not quite ready for display. If you
|
||||
have number formatting, display calculations and other whatnots to perform, you do it here
|
||||
and then you put the result in an arbitrary attribute of the row.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def save(self):
|
||||
"""(Virtual/Required) Saves user edits into your model.
|
||||
|
||||
If your table is editable, this is called when the user commits his changes. Usually, these
|
||||
are typed up stuff, or selected indexes. You have to do proper parsing and reference
|
||||
linking, and save that stuff into your model.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def sort_key_for_column(self, column_name):
|
||||
"""(Virtual) Return the value that is to be used to sort by column ``column_name``.
|
||||
|
||||
By default, looks for an attribute with the same name as ``column_name``, but with an
|
||||
underscore prefix ("unformatted value"). If there's none, tries without the underscore. If
|
||||
there's none, raises ``AttributeError``.
|
||||
"""
|
||||
try:
|
||||
return getattr(self, '_' + column_name)
|
||||
except AttributeError:
|
||||
return getattr(self, column_name)
|
||||
|
||||
#--- Public
|
||||
def can_edit_cell(self, column_name):
|
||||
"""Returns whether cell for column ``column_name`` can be edited.
|
||||
|
||||
By the default, the check is done in many steps:
|
||||
|
||||
1. We check whether the whole row can be edited with :meth:`can_edit`. If it can't, the cell
|
||||
can't either.
|
||||
2. If the column doesn't exist as an attribute, we can't edit.
|
||||
3. If we have an attribute ``can_edit_<column_name>``, return that.
|
||||
4. Check if our attribute is a property. If it's not, it's not editable.
|
||||
5. If our attribute is in fact a property, check whether the property is "settable" (has a
|
||||
``fset`` method). The cell is editable only if the property is "settable".
|
||||
"""
|
||||
if not self.can_edit():
|
||||
return False
|
||||
# '_' is in case column is a python keyword
|
||||
if not hasattr(self, column_name):
|
||||
if hasattr(self, column_name + '_'):
|
||||
column_name = column_name + '_'
|
||||
else:
|
||||
return False
|
||||
if hasattr(self, 'can_edit_' + column_name):
|
||||
return getattr(self, 'can_edit_' + column_name)
|
||||
# If the row has a settable property, we can edit the cell
|
||||
rowclass = self.__class__
|
||||
prop = getattr(rowclass, column_name, None)
|
||||
if prop is None:
|
||||
return False
|
||||
return bool(getattr(prop, 'fset', None))
|
||||
|
||||
def get_cell_value(self, attrname):
|
||||
"""Get cell value for ``attrname``.
|
||||
|
||||
By default, does a simple ``getattr()``, but it is used to allow subclasses to have
|
||||
alternative value storage mechanisms.
|
||||
"""
|
||||
if attrname == 'from':
|
||||
attrname = 'from_'
|
||||
return getattr(self, attrname)
|
||||
|
||||
def set_cell_value(self, attrname, value):
|
||||
"""Set cell value to ``value`` for ``attrname``.
|
||||
|
||||
By default, does a simple ``setattr()``, but it is used to allow subclasses to have
|
||||
alternative value storage mechanisms.
|
||||
"""
|
||||
if attrname == 'from':
|
||||
attrname = 'from_'
|
||||
setattr(self, attrname, value)
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
# Created On: 2012/01/23
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
from .base import GUIObject
|
||||
from ..util import nonone
|
||||
|
||||
class TextFieldView:
|
||||
"""Expected interface for :class:`TextField`'s view.
|
||||
|
||||
*Not actually used in the code. For documentation purposes only.*
|
||||
|
||||
Our view is expected to sync with :attr:`TextField.text` "both ways", that is, update the
|
||||
model's text when the user types something, but also update the text field when :meth:`refresh`
|
||||
is called.
|
||||
"""
|
||||
def refresh(self):
|
||||
"""Refreshes the contents of the input widget.
|
||||
|
||||
Ensures that the contents of the input widget is actually :attr:`TextField.text`.
|
||||
"""
|
||||
|
||||
class TextField(GUIObject):
|
||||
"""Cross-toolkit text field.
|
||||
|
||||
Represents a UI element allowing the user to input a text value. Its main attribute is
|
||||
:attr:`text` which acts as the store of the said value.
|
||||
|
||||
When our model value isn't a string, we have a built-in parsing/formatting mechanism allowing
|
||||
us to directly retrieve/set our non-string value through :attr:`value`.
|
||||
|
||||
Subclasses :class:`.GUIObject`. Expected view: :class:`TextFieldView`.
|
||||
"""
|
||||
def __init__(self):
|
||||
GUIObject.__init__(self)
|
||||
self._text = ''
|
||||
self._value = None
|
||||
|
||||
#--- Virtual
|
||||
def _parse(self, text):
|
||||
"""(Virtual) Parses ``text`` to put into :attr:`value`.
|
||||
|
||||
Returns the parsed version of ``text``. Called whenever :attr:`text` changes.
|
||||
"""
|
||||
return text
|
||||
|
||||
def _format(self, value):
|
||||
"""(Virtual) Formats ``value`` to put into :attr:`text`.
|
||||
|
||||
Returns the formatted version of ``value``. Called whenever :attr:`value` changes.
|
||||
"""
|
||||
return value
|
||||
|
||||
def _update(self, newvalue):
|
||||
"""(Virtual) Called whenever we have a new value.
|
||||
|
||||
Whenever our text/value store changes to a new value (different from the old one), this
|
||||
method is called. By default, it does nothing but you can override it if you want.
|
||||
"""
|
||||
|
||||
#--- Override
|
||||
def _view_updated(self):
|
||||
self.view.refresh()
|
||||
|
||||
#--- Public
|
||||
def refresh(self):
|
||||
"""Triggers a view :meth:`~TextFieldView.refresh`.
|
||||
"""
|
||||
self.view.refresh()
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
"""The text that is currently displayed in the widget.
|
||||
|
||||
*str*. *get/set*.
|
||||
|
||||
This property can be set. When it is, :meth:`refresh` is called and the view is synced with
|
||||
our value. Always in sync with :attr:`value`.
|
||||
"""
|
||||
return self._text
|
||||
|
||||
@text.setter
|
||||
def text(self, newtext):
|
||||
self.value = self._parse(nonone(newtext, ''))
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
"""The "parsed" representation of :attr:`text`.
|
||||
|
||||
*arbitrary type*. *get/set*.
|
||||
|
||||
By default, it's a mirror of :attr:`text`, but a subclass can override :meth:`_parse` and
|
||||
:meth:`_format` to have anything else. Always in sync with :attr:`text`.
|
||||
"""
|
||||
return self._value
|
||||
|
||||
@value.setter
|
||||
def value(self, newvalue):
|
||||
if newvalue == self._value:
|
||||
return
|
||||
self._value = newvalue
|
||||
self._text = self._format(newvalue)
|
||||
self._update(self._value)
|
||||
self.refresh()
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||
#
|
||||
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||
# which should be included with this package. The terms are also available at
|
||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
from collections import MutableSequence
|
||||
|
||||
from .base import GUIObject
|
||||
|
||||
class Node(MutableSequence):
|
||||
"""Pretty bland node implementation to be used in a :class:`Tree`.
|
||||
|
||||
It has a :attr:`parent`, behaves like a list, its content being its children. Link integrity
|
||||
is somewhat enforced (adding a child to a node will set the child's :attr:`parent`, but that's
|
||||
pretty much as far as we go, integrity-wise. Nodes don't tend to move around much in a GUI
|
||||
tree). We don't even check for infinite node loops. Don't play around these grounds too much.
|
||||
|
||||
Nodes are designed to be subclassed and given meaningful attributes (those you'll want to
|
||||
display in your tree view), but they all have a :attr:`name`, which is given on initialization.
|
||||
"""
|
||||
def __init__(self, name):
|
||||
self._name = name
|
||||
self._parent = None
|
||||
self._path = None
|
||||
self._children = []
|
||||
|
||||
def __repr__(self):
|
||||
return '<Node %r>' % self.name
|
||||
|
||||
#--- MutableSequence overrides
|
||||
def __delitem__(self, key):
|
||||
self._children.__delitem__(key)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._children.__getitem__(key)
|
||||
|
||||
def __len__(self):
|
||||
return len(self._children)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self._children.__setitem__(key, value)
|
||||
|
||||
def append(self, node):
|
||||
self._children.append(node)
|
||||
node._parent = self
|
||||
node._path = None
|
||||
|
||||
def insert(self, index, node):
|
||||
self._children.insert(index, node)
|
||||
node._parent = self
|
||||
node._path = None
|
||||
|
||||
#--- Public
|
||||
def clear(self):
|
||||
"""Clears the node of all its children.
|
||||
"""
|
||||
del self[:]
|
||||
|
||||
def find(self, predicate, include_self=True):
|
||||
"""Return the first child to match ``predicate``.
|
||||
|
||||
See :meth:`findall`.
|
||||
"""
|
||||
try:
|
||||
return next(self.findall(predicate, include_self=include_self))
|
||||
except StopIteration:
|
||||
return None
|
||||
|
||||
def findall(self, predicate, include_self=True):
|
||||
"""Yield all children matching ``predicate``.
|
||||
|
||||
:param predicate: ``f(node) --> bool``
|
||||
:param include_self: Whether we can return ``self`` or we return only children.
|
||||
"""
|
||||
if include_self and predicate(self):
|
||||
yield self
|
||||
for child in self:
|
||||
for found in child.findall(predicate, include_self=True):
|
||||
yield found
|
||||
|
||||
def get_node(self, index_path):
|
||||
"""Returns the node at ``index_path``.
|
||||
|
||||
:param index_path: a list of int indexes leading to our node. See :attr:`path`.
|
||||
"""
|
||||
result = self
|
||||
if index_path:
|
||||
for index in index_path:
|
||||
result = result[index]
|
||||
return result
|
||||
|
||||
def get_path(self, target_node):
|
||||
"""Returns the :attr:`path` of ``target_node``.
|
||||
|
||||
If ``target_node`` is ``None``, returns ``None``.
|
||||
"""
|
||||
if target_node is None:
|
||||
return None
|
||||
return target_node.path
|
||||
|
||||
@property
|
||||
def children_count(self):
|
||||
"""Same as ``len(self)``.
|
||||
"""
|
||||
return len(self)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Name for the node, supplied on init.
|
||||
"""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
"""Parent of the node.
|
||||
|
||||
If ``None``, we have a root node.
|
||||
"""
|
||||
return self._parent
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
"""A list of node indexes leading from the root node to ``self``.
|
||||
|
||||
The path of a node is always related to its :attr:`root`. It's the sequences of index that
|
||||
we have to take to get to our node, starting from the root. For example, if
|
||||
``node.path == [1, 2, 3, 4]``, it means that ``node.root[1][2][3][4] is node``.
|
||||
"""
|
||||
if self._path is None:
|
||||
if self._parent is None:
|
||||
self._path = []
|
||||
else:
|
||||
self._path = self._parent.path + [self._parent.index(self)]
|
||||
return self._path
|
||||
|
||||
@property
|
||||
def root(self):
|
||||
"""Root node of current node.
|
||||
|
||||
To get it, we recursively follow our :attr:`parent` chain until we have ``None``.
|
||||
"""
|
||||
if self._parent is None:
|
||||
return self
|
||||
else:
|
||||
return self._parent.root
|
||||
|
||||
|
||||
class Tree(Node, GUIObject):
|
||||
"""Cross-toolkit GUI-enabled tree view.
|
||||
|
||||
This class is a bit too thin to be used as a tree view controller out of the box and HS apps
|
||||
that subclasses it each add quite a bit of logic to it to make it workable. Making this more
|
||||
usable out of the box is a work in progress.
|
||||
|
||||
This class is here (in addition to being a :class:`Node`) mostly to handle selection.
|
||||
|
||||
Subclasses :class:`Node` (it is the root node of all its children) and :class:`.GUIObject`.
|
||||
"""
|
||||
def __init__(self):
|
||||
Node.__init__(self, '')
|
||||
GUIObject.__init__(self)
|
||||
#: Where we store selected nodes (as a list of :class:`Node`)
|
||||
self._selected_nodes = []
|
||||
|
||||
#--- Virtual
|
||||
def _select_nodes(self, nodes):
|
||||
"""(Virtual) Customize node selection behavior.
|
||||
|
||||
By default, simply set :attr:`_selected_nodes`.
|
||||
"""
|
||||
self._selected_nodes = nodes
|
||||
|
||||
#--- Override
|
||||
def _view_updated(self):
|
||||
self.view.refresh()
|
||||
|
||||
def clear(self):
|
||||
self._selected_nodes = []
|
||||
Node.clear(self)
|
||||
|
||||
#--- Public
|
||||
@property
|
||||
def selected_node(self):
|
||||
"""Currently selected node.
|
||||
|
||||
*:class:`Node`*. *get/set*.
|
||||
|
||||
First of :attr:`selected_nodes`. ``None`` if empty.
|
||||
"""
|
||||
return self._selected_nodes[0] if self._selected_nodes else None
|
||||
|
||||
@selected_node.setter
|
||||
def selected_node(self, node):
|
||||
if node is not None:
|
||||
self._select_nodes([node])
|
||||
else:
|
||||
self._select_nodes([])
|
||||
|
||||
@property
|
||||
def selected_nodes(self):
|
||||
"""List of selected nodes in the tree.
|
||||
|
||||
*List of :class:`Node`*. *get/set*.
|
||||
|
||||
We use nodes instead of indexes to store selection because it's simpler when it's time to
|
||||
manage selection of multiple node levels.
|
||||
"""
|
||||
return self._selected_nodes
|
||||
|
||||
@selected_nodes.setter
|
||||
def selected_nodes(self, nodes):
|
||||
self._select_nodes(nodes)
|
||||
|
||||
@property
|
||||
def selected_path(self):
|
||||
"""Currently selected path.
|
||||
|
||||
*:attr:`Node.path`*. *get/set*.
|
||||
|
||||
First of :attr:`selected_paths`. ``None`` if empty.
|
||||
"""
|
||||
return self.get_path(self.selected_node)
|
||||
|
||||
@selected_path.setter
|
||||
def selected_path(self, index_path):
|
||||
if index_path is not None:
|
||||
self.selected_paths = [index_path]
|
||||
else:
|
||||
self._select_nodes([])
|
||||
|
||||
@property
|
||||
def selected_paths(self):
|
||||
"""List of selected paths in the tree.
|
||||
|
||||
*List of :attr:`Node.path`*. *get/set*
|
||||
|
||||
Computed from :attr:`selected_nodes`.
|
||||
"""
|
||||
return list(map(self.get_path, self._selected_nodes))
|
||||
|
||||
@selected_paths.setter
|
||||
def selected_paths(self, index_paths):
|
||||
nodes = []
|
||||
for path in index_paths:
|
||||
try:
|
||||
nodes.append(self.get_node(path))
|
||||
except IndexError:
|
||||
pass
|
||||
self._select_nodes(nodes)
|
||||
|
||||
Reference in New Issue
Block a user