diff --git a/help/en/developer/hscommon/gui/column.rst b/help/en/developer/hscommon/gui/column.rst new file mode 100644 index 00000000..5780a19d --- /dev/null +++ b/help/en/developer/hscommon/gui/column.rst @@ -0,0 +1,25 @@ +hscommon.gui.column +============================ + +.. automodule:: hscommon.gui.column + + .. autosummary:: + + Columns + Column + ColumnsView + PrefAccessInterface + + .. autoclass:: Columns + :members: + :private-members: + + .. autoclass:: Column + :members: + :private-members: + + .. autoclass:: ColumnsView + :members: + + .. autoclass:: PrefAccessInterface + :members: diff --git a/help/en/developer/hscommon/gui/progress_window.rst b/help/en/developer/hscommon/gui/progress_window.rst new file mode 100644 index 00000000..2453b705 --- /dev/null +++ b/help/en/developer/hscommon/gui/progress_window.rst @@ -0,0 +1,18 @@ +hscommon.gui.progress_window +============================ + +.. automodule:: hscommon.gui.progress_window + + .. autosummary:: + + ProgressWindow + ProgressWindowView + + .. autoclass:: ProgressWindow + :members: + :private-members: + + .. autoclass:: ProgressWindowView + :members: + :private-members: + diff --git a/help/en/developer/hscommon/gui/tree.rst b/help/en/developer/hscommon/gui/tree.rst new file mode 100644 index 00000000..1c1e02b2 --- /dev/null +++ b/help/en/developer/hscommon/gui/tree.rst @@ -0,0 +1,18 @@ +hscommon.gui.tree +================= + +.. automodule:: hscommon.gui.tree + + .. autosummary:: + + Tree + Node + + .. autoclass:: Tree + :members: + :private-members: + + .. autoclass:: Node + :members: + :private-members: + diff --git a/help/en/developer/hscommon/index.rst b/help/en/developer/hscommon/index.rst index 99173255..38f7afca 100644 --- a/help/en/developer/hscommon/index.rst +++ b/help/en/developer/hscommon/index.rst @@ -14,3 +14,6 @@ hscommon gui/text_field gui/selectable_list gui/table + gui/tree + gui/column + gui/progress_window diff --git a/hscommon/gui/column.py b/hscommon/gui/column.py index 2204e9bf..5b25d58f 100644 --- a/hscommon/gui/column.py +++ b/hscommon/gui/column.py @@ -11,19 +11,92 @@ 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 @@ -59,40 +132,71 @@ class Columns(GUIObject): #--- 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 (display_name, marked) items for each optional column in the current - # view (marked means that it's visible). + """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 @@ -100,9 +204,13 @@ class Columns(GUIObject): 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 @@ -121,6 +229,8 @@ class Columns(GUIObject): 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: @@ -131,20 +241,35 @@ class Columns(GUIObject): 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 @@ -152,9 +277,13 @@ class Columns(GUIObject): #--- 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] diff --git a/hscommon/gui/progress_window.py b/hscommon/gui/progress_window.py index d27bede4..b57c92d1 100644 --- a/hscommon/gui/progress_window.py +++ b/hscommon/gui/progress_window.py @@ -10,17 +10,68 @@ 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 ``ThreadedJobPerformer`` (from the ``jobprogress`` library). + 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`. + + .. _job enabled: https://pypi.python.org/pypi/jobprogress + """ 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. @@ -28,8 +79,15 @@ class ProgressWindow(GUIObject, ThreadedJobPerformer): self.job_cancelled = True def pulse(self): - # Call this regularly from the GUI main run loop. - # the values might change before setValue happens + """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: @@ -45,6 +103,16 @@ class ProgressWindow(GUIObject, ThreadedJobPerformer): 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 ``Job`` instance (from the + ``jobprogress`` library) 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 diff --git a/hscommon/gui/tree.py b/hscommon/gui/tree.py index ec84d67b..273584a7 100644 --- a/hscommon/gui/tree.py +++ b/hscommon/gui/tree.py @@ -9,6 +9,16 @@ 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 @@ -43,15 +53,26 @@ class Node(MutableSequence): #--- 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: @@ -59,6 +80,10 @@ class Node(MutableSequence): 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: @@ -66,24 +91,42 @@ class Node(MutableSequence): 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 = [] @@ -93,6 +136,10 @@ class Node(MutableSequence): @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: @@ -100,28 +147,47 @@ class Node(MutableSequence): 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): - # all selection changes go through this method, so you can override this if you want to - # customize the tree's behavior. + """(Virtual) Customize node selection behavior. + + By default, simply set :attr:`_selected_nodes`. + """ self._selected_nodes = nodes #--- Override def _view_updated(self): self.view.refresh() - #--- Public 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 @@ -133,6 +199,13 @@ class Tree(Node, GUIObject): @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 @@ -141,6 +214,12 @@ class Tree(Node, GUIObject): @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 @@ -152,6 +231,12 @@ class Tree(Node, GUIObject): @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