diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 6d603c43..25452510 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -28,6 +28,8 @@ Release Notes ⋮ * Improve suggested source when creating a new group. * Main Window * Prevent resizing the window to be too small. + * Use ⚓️ and 📁 icons consistently in the UI to refer to + Root URLs and Groups respectively. * Serving improvements * XML files like Atom feeds and RSS feeds are now served correctly, diff --git a/src/crystal/browser/__init__.py b/src/crystal/browser/__init__.py index ab1d5622..68508ce5 100644 --- a/src/crystal/browser/__init__.py +++ b/src/crystal/browser/__init__.py @@ -3,7 +3,8 @@ from crystal import __version__ as crystal_version from crystal.browser.addgroup import AddGroupDialog from crystal.browser.addrooturl import AddRootUrlDialog -from crystal.browser.entitytree import EntityTree +from crystal.browser.entitytree import EntityTree, ResourceGroupNode, RootResourceNode +from crystal.browser.icons import TREE_NODE_ICONS from crystal.browser.preferences import PreferencesDialog from crystal.browser.tasktree import TaskTree from crystal.model import ( @@ -19,7 +20,9 @@ from crystal.ui.actions import Action from crystal.ui.BetterMessageDialog import BetterMessageDialog from crystal.ui.log_drawer import LogDrawer +from crystal.ui.tree import DEFAULT_FOLDER_ICON_SET from crystal.util.finderinfo import get_hide_file_extension +from crystal.util.unicode_labels import decorate_label from crystal.util.wx_bind import bind from crystal.util.wx_dialog import set_dialog_or_frame_icon_if_appropriate from crystal.util.xos import ( @@ -186,39 +189,47 @@ def _create_actions(self) -> None: # NOTE: Action is bound to self._on_preferences later manually action_func=None, enabled=True, - button_label='&Preferences...') + button_label=decorate_label('⚙️', '&Preferences...', '')) # Entity self._new_root_url_action = Action(wx.ID_ANY, 'New &Root URL...', wx.AcceleratorEntry(wx.ACCEL_CTRL, ord('R')), self._on_add_url, - enabled=(not self._readonly)) + enabled=(not self._readonly), + button_bitmap=TREE_NODE_ICONS()['entitytree_root_resource'], + button_label='New &Root URL...') self._new_group_action = Action(wx.ID_ANY, 'New &Group...', wx.AcceleratorEntry(wx.ACCEL_CTRL, ord('G')), self._on_add_group, - enabled=(not self._readonly)) + enabled=(not self._readonly), + button_bitmap=dict(DEFAULT_FOLDER_ICON_SET())[wx.TreeItemIcon_Normal], + button_label='New &Group...') self._forget_action = Action(wx.ID_ANY, '&Forget', wx.AcceleratorEntry(wx.ACCEL_CTRL, wx.WXK_BACK), self._on_forget_entity, - enabled=False) + enabled=False, + button_label=decorate_label('✖️', '&Forget', '')) self._download_action = Action(wx.ID_ANY, '&Download', wx.AcceleratorEntry(wx.ACCEL_CTRL, wx.WXK_RETURN), self._on_download_entity, - enabled=False) + enabled=False, + button_label=decorate_label('⬇', '&Download', '')) self._update_membership_action = Action(wx.ID_ANY, 'Update &Membership', accel=None, action_func=self._on_update_group_membership, - enabled=False) + enabled=False, + button_label=decorate_label('🔎', 'Update &Membership', ' ')) self._view_action = Action(wx.ID_ANY, '&View', wx.AcceleratorEntry(wx.ACCEL_CTRL|wx.ACCEL_SHIFT, ord('O')), self._on_view_entity, - enabled=False) + enabled=False, + button_label=decorate_label('👀', '&View', ' ')) # HACK: Gather all actions indirectly by inspecting fields self._actions = [a for a in self.__dict__.values() if isinstance(a, Action)] diff --git a/src/crystal/browser/addgroup.py b/src/crystal/browser/addgroup.py index 0e083576..16b2286f 100644 --- a/src/crystal/browser/addgroup.py +++ b/src/crystal/browser/addgroup.py @@ -1,7 +1,9 @@ # TODO: Consider extracting functions shared between dialogs to own module from crystal.browser.addrooturl import AddRootUrlDialog, fields_hide_hint_when_focused +from crystal.browser.entitytree import ResourceGroupNode, RootResourceNode from crystal.model import Project, ResourceGroup, ResourceGroupSource from crystal.progress import CancelLoadUrls +from crystal.util.unicode_labels import decorate_label from crystal.util.wx_bind import bind from crystal.util.wx_dialog import position_dialog_initially from crystal.util.wx_static_box_sizer import wrap_static_box_sizer_child @@ -146,9 +148,19 @@ def _create_fields(self, name='cr-add-group-dialog__source-field') self.source_choice_box.Append('none', None) for rr in self._project.root_resources: - self.source_choice_box.Append(rr.display_name, rr) + self.source_choice_box.Append( + decorate_label( + RootResourceNode.ICON, + RootResourceNode.calculate_title_of(rr), + RootResourceNode.ICON_TRUNCATION_FIX), + rr) for rg in self._project.resource_groups: - self.source_choice_box.Append(rg.display_name, rg) + self.source_choice_box.Append( + decorate_label( + ResourceGroupNode.ICON, + ResourceGroupNode.calculate_title_of(rg), + ResourceGroupNode.ICON_TRUNCATION_FIX), + rg) self.source_choice_box.SetSelection(0) if initial_source is not None: for i in range(self.source_choice_box.GetCount()): diff --git a/src/crystal/browser/entitytree.py b/src/crystal/browser/entitytree.py index 993b97a5..81b8efd3 100644 --- a/src/crystal/browser/entitytree.py +++ b/src/crystal/browser/entitytree.py @@ -792,6 +792,9 @@ def on_selection_deleted(self, class RootResourceNode(_ResourceNode): + ICON = '⚓️' + ICON_TRUNCATION_FIX = '' + def __init__(self, root_resource: RootResource, *, source: DeferrableResourceGroupSource, @@ -811,10 +814,14 @@ def _entity_tooltip(self) -> str: return 'root URL' def calculate_title(self) -> str: - project = self.root_resource.project - display_url = project.get_display_url(self.root_resource.url) - if self.root_resource.name != '': - return '%s - %s' % (display_url, self.root_resource.name) + return self.calculate_title_of(self.root_resource) + + @staticmethod + def calculate_title_of(root_resource: RootResource) -> str: + project = root_resource.project + display_url = project.get_display_url(root_resource.url) + if root_resource.name != '': + return '%s - %s' % (display_url, root_resource.name) else: return '%s' % (display_url,) @@ -950,6 +957,9 @@ def __hash__(self): class ResourceGroupNode(Node): + ICON = '📁' + ICON_TRUNCATION_FIX = ' ' + _MAX_VISIBLE_CHILDREN = 100 _MORE_CHILDREN_TO_SHOW = 20 @@ -973,10 +983,14 @@ def icon_tooltip(self) -> Optional[str]: return 'Group' def calculate_title(self) -> str: - project = self.resource_group.project - display_url = project.get_display_url(self.resource_group.url_pattern) - if self.resource_group.name != '': - return '%s - %s' % (display_url, self.resource_group.name) + return self.calculate_title_of(self.resource_group) + + @staticmethod + def calculate_title_of(resource_group: ResourceGroup) -> str: + project = resource_group.project + display_url = project.get_display_url(resource_group.url_pattern) + if resource_group.name != '': + return '%s - %s' % (display_url, resource_group.name) else: return '%s' % (display_url,) diff --git a/src/crystal/browser/icons.py b/src/crystal/browser/icons.py index 0015e301..574ed417 100644 --- a/src/crystal/browser/icons.py +++ b/src/crystal/browser/icons.py @@ -80,4 +80,28 @@ def _add_badge_to_background(background: wx.Bitmap, badge: wx.Bitmap) -> wx.Bitm return background_plus_badge +def add_transparent_left_border(original: wx.Bitmap, thickness: int) -> wx.Bitmap: + if thickness == 0: + return original + + bordered = wx.Bitmap() + success = bordered.Create( + original.Width + thickness, + original.Height, + original.Depth) + if not success: + raise Exception('Failed to create a wx.Bitmap') + + dc = wx.MemoryDC(bordered) + dc.Clear() # fill bitmap with white + dc.DrawBitmap( + original, + x=thickness, + y=0, + useMask=True) + dc.SelectObject(wx.NullBitmap) # commit changes to bordered + bordered.SetMaskColour(wx.Colour(255, 255, 255)) # replace white with transparent + return bordered + + # ------------------------------------------------------------------------------ diff --git a/src/crystal/tests/util/windows.py b/src/crystal/tests/util/windows.py index 5085a996..77ff2958 100644 --- a/src/crystal/tests/util/windows.py +++ b/src/crystal/tests/util/windows.py @@ -17,6 +17,7 @@ ) from crystal.util.xos import is_mac_os import os.path +import re import sys import tempfile import traceback @@ -364,6 +365,7 @@ async def cancel(self) -> None: class AddGroupDialog: _NONE_SOURCE_TITLE = 'none' + _SOURCE_TITLE_RE = re.compile(r'^(?:[^a-zA-Z0-9]+ )?(.*?)(?: - (.*?))? *$') name_field: wx.TextCtrl pattern_field: wx.TextCtrl @@ -412,14 +414,41 @@ def _get_source(self) -> Optional[str]: selection_ci = self.source_field.GetSelection() if selection_ci == wx.NOT_FOUND: return None - source_name = self.source_field.GetString(selection_ci) - if source_name == self._NONE_SOURCE_TITLE: + source_title = self.source_field.GetString(selection_ci) + if source_title == self._NONE_SOURCE_TITLE: return None - return source_name + m = self._SOURCE_TITLE_RE.fullmatch(source_title) + assert m is not None + source_name = m.group(2) + if source_name is not None: + return source_name + else: + # If the referenced source has no name, allow referring to it by its display URL + cur_source_display_url = m.group(1) + assert cur_source_display_url is not None + return cur_source_display_url + def _set_source(self, source_name: Optional[str]) -> None: if source_name is None: - source_name = self._NONE_SOURCE_TITLE - selection_ci = self.source_field.GetStrings().index(source_name) + selection_ci = 0 + else: + for (selection_ci, source_title) in enumerate(self.source_field.GetStrings()): + if selection_ci == 0: + continue + m = self._SOURCE_TITLE_RE.fullmatch(source_title) + assert m is not None + cur_source_name = m.group(2) + if cur_source_name is not None: + if cur_source_name == source_name: + break + else: + # If the referenced source has no name, allow referring to it by its display URL + cur_source_display_url = m.group(1) + assert cur_source_display_url is not None + if cur_source_display_url == source_name: + break + else: + raise ValueError(f'Source not found: {source_name}') self.source_field.SetSelection(selection_ci) source = property(_get_source, _set_source) diff --git a/src/crystal/ui/actions.py b/src/crystal/ui/actions.py index ac162a46..e86530ee 100644 --- a/src/crystal/ui/actions.py +++ b/src/crystal/ui/actions.py @@ -1,5 +1,6 @@ +from crystal.browser.icons import add_transparent_left_border from crystal.util.wx_bind import bind -from crystal.util.xos import is_mac_os +from crystal.util.xos import is_mac_os, is_windows from typing import Callable, List, Optional import wx @@ -21,12 +22,14 @@ def __init__(self, accel: Optional[wx.AcceleratorEntry]=None, action_func: Optional[Callable[[wx.CommandEvent], None]]=None, enabled: bool=True, + button_bitmap: Optional[wx.Bitmap]=None, button_label: str=''): self._menuitem_id = menuitem_id self._label = label self._accel = accel self._action_func = action_func self._enabled = enabled + self._button_bitmap = button_bitmap self._button_label = button_label self._menuitems = [] # type: List[wx.MenuItem] @@ -78,6 +81,16 @@ def create_button(self, *args, **kwargs) -> wx.Button: button_label = wx.Control.RemoveMnemonics(button_label) # reinterpret button = wx.Button(*args, label=button_label, **kwargs) + if self._button_bitmap is not None: + button.SetBitmap(add_transparent_left_border( + self._button_bitmap, + # Alter left margin for bitmap to be reasonable on Windows. + # Initially it is 0. + 8+6 if is_windows() else 0 + )) + if is_windows(): + # Decrease bitmap-to-text spacing on Windows to be reasonable + button.SetBitmapMargins((-6, 0)) # (NOTE: Do NOT set accelerator for the button because it does not # work on consistently on macOS. Any action added as a button # should also be added as a menuitem too, and we CAN set an diff --git a/src/crystal/ui/tree.py b/src/crystal/ui/tree.py index 81bd2757..bbe37e96 100644 --- a/src/crystal/ui/tree.py +++ b/src/crystal/ui/tree.py @@ -28,7 +28,7 @@ _DEFAULT_TREE_ICON_SIZE = (16,16) _DEFAULT_FOLDER_ICON_SET_CACHED = None -def _DEFAULT_FOLDER_ICON_SET() -> IconSet: +def DEFAULT_FOLDER_ICON_SET() -> IconSet: global _DEFAULT_FOLDER_ICON_SET_CACHED # necessary to write to a module global if not _DEFAULT_FOLDER_ICON_SET_CACHED: _DEFAULT_FOLDER_ICON_SET_CACHED = ( @@ -258,7 +258,7 @@ def _set_icon_set(self, effective_value = ( value if value is not None else ( - _DEFAULT_FOLDER_ICON_SET() + DEFAULT_FOLDER_ICON_SET() if self.expandable else _DEFAULT_FILE_ICON_SET() ) diff --git a/src/crystal/util/unicode_labels.py b/src/crystal/util/unicode_labels.py new file mode 100644 index 00000000..a8dd375b --- /dev/null +++ b/src/crystal/util/unicode_labels.py @@ -0,0 +1,15 @@ +from crystal.util.xos import is_windows, windows_major_version + + +def decorate_label(icon: str, message: str, truncation_fix: str) -> str: + if truncation_fix not in ('', ' ', ' '): + raise ValueError() + if is_windows(): + if windows_major_version() == 7: + # Windows 7: Cannot display Unicode icon at all + return f'{message}' + else: # windows_major_version() >= 8 + # Windows 8, 10: Can display some Unicode icons. + # Some icons require trailing spaces to avoid truncating the message + return f'{icon} {message}{truncation_fix}' + return f'{icon} {message}'