1
0
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:
Virgil Dupras
2016-05-25 21:07:30 -04:00
parent 4b6f8b45e2
commit bb1f0f5be6
188 changed files with 17 additions and 15586 deletions

View File

@@ -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()

View File

@@ -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]

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)