From 5c461b9635c6a20d148e050843bf371a533560dd Mon Sep 17 00:00:00 2001 From: David Foster Date: Sat, 13 Jan 2024 15:52:02 -0500 Subject: [PATCH] IP: Support editing Root URLs and Groups after creation Needs: * Detect any cycle created in an edited Source property of a Group * Test new Unicode icon for Edit button on Windows (and Linux) * Automated tests * Release notes * Link to issue #98 in commit message While testing, don't forget to check: * Fix titles to update properly when name changes (ex: for direct children of ResourceGroupNode) * Focus on Name field by default when open Edit dialog * Menuitem & its accelerator --- src/crystal/browser/__init__.py | 134 +++++++++++++++++++++------- src/crystal/browser/entitytree.py | 12 ++- src/crystal/browser/new_group.py | 21 +++-- src/crystal/browser/new_root_url.py | 30 +++++-- src/crystal/model.py | 61 ++++++++++--- src/crystal/tests/util/windows.py | 8 +- src/crystal/util/wx_dialog.py | 21 +++++ 7 files changed, 222 insertions(+), 65 deletions(-) diff --git a/src/crystal/browser/__init__.py b/src/crystal/browser/__init__.py index 90c893ed..e6d9e80a 100644 --- a/src/crystal/browser/__init__.py +++ b/src/crystal/browser/__init__.py @@ -31,6 +31,7 @@ from crystal.util.xthreading import ( bg_call_later, fg_affinity, fg_call_later, fg_call_and_wait, set_is_quitting ) +from functools import partial import os import time from typing import ContextManager, Iterator, Optional @@ -195,17 +196,25 @@ def _create_actions(self) -> None: self._new_root_url_action = Action(wx.ID_ANY, 'New &Root URL...', wx.AcceleratorEntry(wx.ACCEL_CTRL, ord('R')), - self._on_add_url, + self._on_new_root_url, 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, + self._on_new_group, enabled=(not self._readonly), button_bitmap=dict(DEFAULT_FOLDER_ICON_SET())[wx.TreeItemIcon_Normal], button_label='New &Group...') + self._edit_action = Action(wx.ID_ANY, + '&Edit...', + accel=wx.AcceleratorEntry(wx.ACCEL_NORMAL, wx.WXK_RETURN), + action_func=self._on_edit_entity, + enabled=False, + # FIXME: Verify that ✏️ displays okay on Windows 8+ + # FIXME: Verify that '' is the correct value for Windows + button_label=decorate_label('✏️', '&Edit...', '')) self._forget_action = Action(wx.ID_ANY, '&Forget', wx.AcceleratorEntry(wx.ACCEL_CTRL, wx.WXK_BACK), @@ -221,11 +230,13 @@ def _create_actions(self) -> None: self._update_members_action = Action(wx.ID_ANY, 'Update &Members', accel=None, - action_func=self._on_update_group_membership, + action_func=self._on_update_group_members, enabled=False, button_label=decorate_label('🔎', 'Update &Members', ' ')) self._view_action = Action(wx.ID_ANY, '&View', + # TODO: Consider adding Space as a alternate accelerator + #wx.AcceleratorEntry(wx.ACCEL_NORMAL, wx.WXK_SPACE), wx.AcceleratorEntry(wx.ACCEL_CTRL|wx.ACCEL_SHIFT, ord('O')), self._on_view_entity, enabled=False, @@ -271,6 +282,7 @@ def _create_menu_bar(self, raw_frame: wx.Frame) -> wx.MenuBar: entity_menu = wx.Menu() self._new_root_url_action.append_menuitem_to(entity_menu) self._new_group_action.append_menuitem_to(entity_menu) + self._edit_action.append_menuitem_to(entity_menu) self._forget_action.append_menuitem_to(entity_menu) entity_menu.AppendSeparator() self._download_action.append_menuitem_to(entity_menu) @@ -329,25 +341,24 @@ def _create_entity_tree(self, parent: wx.Window, progress_listener: OpenProjectP def _create_button_bar(self, parent: wx.Window): readonly = self._readonly # cache - add_url_button = self._new_root_url_action.create_button(parent, name='cr-add-url-button') - - add_group_button = self._new_group_action.create_button(parent, name='cr-add-group-button') - - remove_entity_button = self._forget_action.create_button(parent, name='cr-forget-button') - + new_root_url_button = self._new_root_url_action.create_button(parent, name='cr-add-url-button') + new_group_button = self._new_group_action.create_button(parent, name='cr-add-group-button') + edit_entity_button = self._edit_action.create_button(parent, name='cr-edit-button') + forget_entity_button = self._forget_action.create_button(parent, name='cr-forget-button') download_button = self._download_action.create_button(parent, name='cr-download-button') - update_members_button = self._update_members_action.create_button( parent, name='cr-update-members-button') view_button = self._view_action.create_button(parent, name='cr-view-button') content_sizer = wx.BoxSizer(wx.HORIZONTAL) - content_sizer.Add(add_url_button) + content_sizer.Add(new_root_url_button) + content_sizer.AddSpacer(_WINDOW_INNER_PADDING) + content_sizer.Add(new_group_button) content_sizer.AddSpacer(_WINDOW_INNER_PADDING) - content_sizer.Add(add_group_button) + content_sizer.Add(edit_entity_button) content_sizer.AddSpacer(_WINDOW_INNER_PADDING) - content_sizer.Add(remove_entity_button) + content_sizer.Add(forget_entity_button) content_sizer.AddSpacer(_WINDOW_INNER_PADDING * 2) content_sizer.AddStretchSpacer() content_sizer.Add(download_button) @@ -432,23 +443,23 @@ def _on_close_frame(self, event: wx.CloseEvent) -> None: # === Entity Pane: Events === - def _on_add_url(self, event: wx.CommandEvent) -> None: - def root_url_exists(url: str) -> bool: - r = self.project.get_resource(url) - if r is None: - return False - rr = self.project.get_root_resource(r) - return rr is not None - + def _on_new_root_url(self, event: wx.CommandEvent) -> None: NewRootUrlDialog( - self._frame, self._on_add_url_dialog_ok, - url_exists_func=root_url_exists, + self._frame, self._on_new_root_url_dialog_ok, + url_exists_func=self._root_url_exists, initial_url=self._suggested_url_or_url_pattern_for_selection or '', initial_name=self._suggested_name_for_selection or '', ) + def _root_url_exists(self, url: str) -> bool: + r = self.project.get_resource(url) + if r is None: + return False + rr = self.project.get_root_resource(r) + return rr is not None + @fg_affinity - def _on_add_url_dialog_ok(self, name: str, url: str) -> None: + def _on_new_root_url_dialog_ok(self, name: str, url: str) -> None: if url == '': raise ValueError('Invalid blank URL') try: @@ -456,10 +467,20 @@ def _on_add_url_dialog_ok(self, name: str, url: str) -> None: except RootResource.AlreadyExists: raise ValueError('Invalid duplicate URL') - def _on_add_group(self, event: wx.CommandEvent) -> None: + @fg_affinity + def _on_edit_root_url_dialog_ok(self, rr: RootResource, name: str, url: str) -> None: + if url != rr.url: + raise ValueError() + rr.name = name + + # TODO: This update should happen in response to an event + # fired by the entity itself. + self.entity_tree.root.update_title_of_descendants() # update names in titles + + def _on_new_group(self, event: wx.CommandEvent) -> None: try: NewGroupDialog( - self._frame, self._on_add_group_dialog_ok, + self._frame, self._on_new_group_dialog_ok, self.project, initial_url_pattern=self._suggested_url_or_url_pattern_for_selection or '', initial_source=self._suggested_source_for_selection, @@ -467,19 +488,61 @@ def _on_add_group(self, event: wx.CommandEvent) -> None: except CancelLoadUrls: pass - def _on_add_group_dialog_ok(self, name: str, url_pattern: str, source): + @fg_affinity + def _on_new_group_dialog_ok(self, name: str, url_pattern: str, source: ResourceGroupSource) -> None: # TODO: Validate user input: - # * Is name or url_pattern empty? - # * Is name or url_pattern already taken? - rg = ResourceGroup(self.project, name, url_pattern, source) + # * Is url_pattern empty? + # * Is url_pattern already taken? + ResourceGroup(self.project, name, url_pattern, source) + + @fg_affinity + def _on_edit_group_dialog_ok(self, rg: ResourceGroup, name: str, url_pattern: str, source: ResourceGroupSource) -> None: + if url_pattern != rg.url_pattern: + raise ValueError() + (rg.name, rg.source) = (name, source) + + # TODO: This update should happen in response to an event + # fired by the entity itself. + self.entity_tree.root.update_title_of_descendants() # update names in titles - def _on_forget_entity(self, event): + def _on_edit_entity(self, event) -> None: selected_entity_pair = self.entity_tree.selected_entity_pair selected_or_related_entity = selected_entity_pair[0] or selected_entity_pair[1] + assert selected_or_related_entity is not None + + if isinstance(selected_or_related_entity, RootResource): + rr = selected_or_related_entity + NewRootUrlDialog( + self._frame, partial(self._on_edit_root_url_dialog_ok, rr), + url_exists_func=self._root_url_exists, + initial_url=rr.url, + initial_name=rr.name, + is_edit=True, + ) + elif isinstance(selected_or_related_entity, ResourceGroup): + rg = selected_or_related_entity + try: + NewGroupDialog( + self._frame, partial(self._on_edit_group_dialog_ok, rg), + self.project, + initial_url_pattern=rg.url_pattern, + initial_source=rg.source, + initial_name=rg.name, + is_edit=True) + except CancelLoadUrls: + pass + else: + raise AssertionError() + + def _on_forget_entity(self, event) -> None: + selected_entity_pair = self.entity_tree.selected_entity_pair + selected_or_related_entity = selected_entity_pair[0] or selected_entity_pair[1] + assert selected_or_related_entity is not None selected_or_related_entity.delete() - # TODO: This update() should happen in response to a delete - # event fired by the entity itself. + + # TODO: This update() should happen in response to an event + # fired by the entity itself. self.entity_tree.update() def _on_download_entity(self, event) -> None: @@ -545,7 +608,7 @@ def fg_task() -> None: fg_call_and_wait(fg_task) bg_call_later(bg_task) - def _on_update_group_membership(self, event): + def _on_update_group_members(self, event): selected_entity = self.entity_tree.selected_entity selected_entity.update_members() @@ -611,6 +674,9 @@ def _on_selected_entity_changed(self, event: wx.TreeEvent) -> None: selected_or_related_entity = selected_entity_pair[0] or selected_entity_pair[1] readonly = self._readonly # cache + self._edit_action.enabled = ( + (not readonly) and + isinstance(selected_or_related_entity, (ResourceGroup, RootResource))) self._forget_action.enabled = ( (not readonly) and isinstance(selected_or_related_entity, (ResourceGroup, RootResource))) diff --git a/src/crystal/browser/entitytree.py b/src/crystal/browser/entitytree.py index 81b8efd3..9a4e9729 100644 --- a/src/crystal/browser/entitytree.py +++ b/src/crystal/browser/entitytree.py @@ -832,8 +832,10 @@ def entity(self) -> RootResource: # === Comparison === def __eq__(self, other): - return isinstance(other, RootResourceNode) and ( - self.root_resource == other.root_resource) + return ( + isinstance(other, RootResourceNode) and + self.root_resource == other.root_resource + ) def __hash__(self): return hash(self.root_resource) @@ -1090,8 +1092,10 @@ def on_more_expanded(self, more_node: MorePlaceholderNode) -> None: # === Comparison === def __eq__(self, other) -> bool: - return isinstance(other, ResourceGroupNode) and ( - self.resource_group == other.resource_group) + return ( + isinstance(other, ResourceGroupNode) and + self.resource_group == other.resource_group + ) def __hash__(self) -> int: return hash(self.resource_group) diff --git a/src/crystal/browser/new_group.py b/src/crystal/browser/new_group.py index 5f9dc9d7..8983c421 100644 --- a/src/crystal/browser/new_group.py +++ b/src/crystal/browser/new_group.py @@ -5,7 +5,7 @@ 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_dialog import CreateButtonSizer, position_dialog_initially from crystal.util.wx_static_box_sizer import wrap_static_box_sizer_child from crystal.util.xos import is_linux import sys @@ -31,6 +31,7 @@ def __init__(self, initial_url_pattern: str='', initial_source: ResourceGroupSource=None, initial_name: str='', + is_edit: bool=False, ) -> None: """ Arguments: @@ -54,7 +55,8 @@ def __init__(self, raise dialog = self.dialog = wx.Dialog( - parent, title='New Group', + parent, + title=('New Group' if not is_edit else 'Edit Group'), name='cr-new-group-dialog', style=wx.DEFAULT_DIALOG_STYLE|wx.RESIZE_BORDER) dialog_sizer = wx.BoxSizer(wx.VERTICAL) @@ -96,17 +98,22 @@ def __init__(self, content_sizer = wx.BoxSizer(wx.VERTICAL) content_sizer.Add( - self._create_fields(dialog, initial_url_pattern, initial_source, initial_name), + self._create_fields(dialog, initial_url_pattern, initial_source, initial_name, is_edit), flag=wx.EXPAND) content_sizer.Add(preview_box, proportion=1, flag=wx.EXPAND|preview_box_flags, border=preview_box_border) + ok_button_id = (wx.ID_NEW if not is_edit else wx.ID_SAVE) dialog_sizer.Add(content_sizer, proportion=1, flag=wx.EXPAND|wx.ALL, border=_WINDOW_INNER_PADDING) - dialog_sizer.Add(dialog.CreateButtonSizer(wx.OK|wx.CANCEL), flag=wx.EXPAND|wx.BOTTOM, + dialog_sizer.Add(CreateButtonSizer(dialog, ok_button_id, wx.ID_CANCEL), flag=wx.EXPAND|wx.BOTTOM, border=_WINDOW_INNER_PADDING) if not fields_hide_hint_when_focused(): - self.pattern_field.SetFocus() # initialize focus + # Initialize focus + if not is_edit: + self.pattern_field.SetFocus() + else: + self.name_field.SetFocus() self._update_preview_urls() position_dialog_initially(dialog) @@ -121,6 +128,7 @@ def _create_fields(self, initial_url_pattern: str, initial_source: ResourceGroupSource, initial_name: str, + is_edit: bool, ) -> wx.Sizer: fields_sizer = wx.FlexGridSizer(cols=2, vgap=_FORM_ROW_SPACING, hgap=_FORM_LABEL_INPUT_SPACING) @@ -134,6 +142,7 @@ def _create_fields(self, bind(self.pattern_field, wx.EVT_TEXT, self._on_pattern_field_changed) self.pattern_field.Hint = 'https://example.com/post/*' self.pattern_field.SetSelection(-1, -1) # select all upon focus + self.pattern_field.Enabled = not is_edit pattern_field_sizer.Add(self.pattern_field, flag=wx.EXPAND) pattern_field_sizer.Add(wx.StaticText(parent, label='# = digit, @ = alpha, * = any nonslash, ** = any'), flag=wx.EXPAND) @@ -227,7 +236,7 @@ def _on_pattern_field_changed(self, event) -> None: def _on_button(self, event: wx.CommandEvent) -> None: btn_id = event.GetEventObject().GetId() - if btn_id == wx.ID_OK: + if btn_id in (wx.ID_NEW, wx.ID_SAVE): self._on_ok(event) elif btn_id == wx.ID_CANCEL: self._on_cancel(event) diff --git a/src/crystal/browser/new_root_url.py b/src/crystal/browser/new_root_url.py index e39ff6a5..aa9cb928 100644 --- a/src/crystal/browser/new_root_url.py +++ b/src/crystal/browser/new_root_url.py @@ -1,6 +1,8 @@ from crystal.url_input import UrlCleaner from crystal.util.wx_bind import bind -from crystal.util.wx_dialog import position_dialog_initially, ShowModal +from crystal.util.wx_dialog import ( + CreateButtonSizer, position_dialog_initially, ShowModal, +) from crystal.util.xos import is_wx_gtk from crystal.util.xthreading import fg_affinity, fg_call_later import os @@ -28,6 +30,7 @@ def __init__(self, url_exists_func: Callable[[str], bool], initial_url: str='', initial_name: str='', + is_edit: bool=False, ) -> None: """ Arguments: @@ -37,6 +40,7 @@ def __init__(self, """ self._on_finish = on_finish self._url_exists_func = url_exists_func + self._is_edit = is_edit self._url_field_focused = False self._last_cleaned_url = None # type: Optional[str] @@ -45,7 +49,9 @@ def __init__(self, self._is_destroying_or_destroyed = False dialog = self.dialog = wx.Dialog( - parent, title='New Root URL', name='cr-new-root-url-dialog', + parent, + title=('New Root URL' if not is_edit else 'Edit Root URL'), + name='cr-new-root-url-dialog', style=wx.DEFAULT_DIALOG_STYLE|wx.RESIZE_BORDER) dialog_sizer = wx.BoxSizer(wx.VERTICAL) dialog.SetSizer(dialog_sizer) @@ -53,17 +59,22 @@ def __init__(self, bind(dialog, wx.EVT_CLOSE, self._on_close) bind(dialog, wx.EVT_WINDOW_DESTROY, self._on_destroyed) - dialog_sizer.Add(self._create_fields(dialog, initial_url, initial_name), flag=wx.EXPAND|wx.ALL, + ok_button_id = (wx.ID_NEW if not is_edit else wx.ID_SAVE) + dialog_sizer.Add(self._create_fields(dialog, initial_url, initial_name, is_edit), flag=wx.EXPAND|wx.ALL, border=_WINDOW_INNER_PADDING) - dialog_sizer.Add(dialog.CreateButtonSizer(wx.OK|wx.CANCEL), flag=wx.EXPAND|wx.BOTTOM, + dialog_sizer.Add(CreateButtonSizer(dialog, ok_button_id, wx.ID_CANCEL), flag=wx.EXPAND|wx.BOTTOM, border=_WINDOW_INNER_PADDING) - self.ok_button = dialog.FindWindow(id=wx.ID_OK) + self.ok_button = dialog.FindWindow(id=ok_button_id) self.cancel_button = dialog.FindWindow(id=wx.ID_CANCEL) self._update_ok_enabled() if not fields_hide_hint_when_focused(): - self.url_field.SetFocus() # initialize focus + # Initialize focus + if not is_edit: + self.url_field.SetFocus() + else: + self.name_field.SetFocus() position_dialog_initially(dialog) dialog.Show(True) @@ -76,7 +87,7 @@ def __init__(self, if os.environ.get('CRYSTAL_RUNNING_TESTS', 'False') == 'True': NewRootUrlDialog._last_opened = self - def _create_fields(self, parent: wx.Window, initial_url: str, initial_name: str) -> wx.Sizer: + def _create_fields(self, parent: wx.Window, initial_url: str, initial_name: str, is_edit: bool) -> wx.Sizer: fields_sizer = wx.FlexGridSizer(rows=2, cols=2, vgap=_FORM_ROW_SPACING, hgap=_FORM_LABEL_INPUT_SPACING) fields_sizer.AddGrowableCol(1) @@ -94,6 +105,7 @@ def _create_fields(self, parent: wx.Window, initial_url: str, initial_name: str) name='cr-new-root-url-dialog__url-field') self.url_field.Hint = 'https://example.com/' self.url_field.SetSelection(-1, -1) # select all upon focus + self.url_field.Enabled = not is_edit bind(self.url_field, wx.EVT_TEXT, self._update_ok_enabled) bind(self.url_field, wx.EVT_SET_FOCUS, self._on_url_field_focus) bind(self.url_field, wx.EVT_KILL_FOCUS, self._on_url_field_blur) @@ -222,7 +234,7 @@ def _on_url_field_blur(self, event: Optional[wx.FocusEvent]=None) -> None: def _on_button(self, event: wx.CommandEvent) -> None: btn_id = event.GetEventObject().GetId() - if btn_id == wx.ID_OK: + if btn_id in (wx.ID_NEW, wx.ID_SAVE): self._on_ok(event) elif btn_id == wx.ID_CANCEL: self._on_cancel(event) @@ -247,7 +259,7 @@ def _on_ok(self, event: Optional[wx.CommandEvent]=None) -> None: name = self.name_field.Value url = self.url_field.Value - if self._url_exists_func(url): + if not self._is_edit and self._url_exists_func(url): dialog = wx.MessageDialog(None, message='That root URL already exists in the project.', caption='Root URL Exists', diff --git a/src/crystal/model.py b/src/crystal/model.py index 5d2d78b6..39b3549f 100644 --- a/src/crystal/model.py +++ b/src/crystal/model.py @@ -981,10 +981,8 @@ def _set_min_fetch_date(self, min_fetch_date: Optional[datetime.datetime]) -> No for lis in self.listeners: if hasattr(lis, 'min_fetch_date_did_change'): lis.min_fetch_date_did_change() # type: ignore[attr-defined] - min_fetch_date = property( - _get_min_fetch_date, - _set_min_fetch_date, - doc=""" + min_fetch_date = property(_get_min_fetch_date, _set_min_fetch_date, doc= + """ If non-None then any resource fetched <= this datetime will be considered stale and subject to being redownloaded. @@ -2093,8 +2091,8 @@ class RootResource: Persisted and auto-saved. """ project: Project - name: str - resource: Resource + _name: str + _resource: Resource _id: int # or None if deleted # === Init === @@ -2123,8 +2121,8 @@ def __new__(cls, project: Project, name: str, resource: Resource, _id: Optional[ else: self = object.__new__(cls) self.project = project - self.name = name - self.resource = resource + self._name = name + self._resource = resource if project._loading: assert _id is not None @@ -2165,11 +2163,33 @@ def delete(self) -> None: # === Properties === + def _get_name(self) -> str: + """Name of this root resource. Possibly ''.""" + return self._name + def _set_name(self, name: str) -> None: + if self._name == name: + return + + if self.project.readonly: + raise ProjectReadOnlyError() + c = self.project._db.cursor() + c.execute('update root_resource set name=? where id=?', ( + name, + self._id,)) + self.project._db.commit() + + self._name = name + name = cast(str, property(_get_name, _set_name)) + @property def display_name(self) -> str: - """Name of this root resource that is used in the UI.""" + """Name of this root resource that is used in the UI. Never ''.""" return self.name or self.url + @property + def resource(self) -> Resource: + return self._resource + @property def url(self) -> str: return self.resource.url @@ -3128,7 +3148,7 @@ def __init__(self, raise ValueError('Cannot create group with empty pattern') self.project = project - self.name = name + self._name = name self.url_pattern = url_pattern self._url_pattern_re = ResourceGroup.create_re_for_url_pattern(url_pattern) self._source = None # type: Union[ResourceGroupSource, EllipsisType] @@ -3189,6 +3209,24 @@ def delete(self) -> None: # === Properties === + def _get_name(self) -> str: + """Name of this resource group. Possibly ''.""" + return self._name + def _set_name(self, name: str) -> None: + if self._name == name: + return + + if self.project.readonly: + raise ProjectReadOnlyError() + c = self.project._db.cursor() + c.execute('update resource_group set name=? where id=?', ( + name, + self._id,)) + self.project._db.commit() + + self._name = name + name = cast(str, property(_get_name, _set_name)) + @property def display_name(self) -> str: """Name of this group that is used in the UI.""" @@ -3206,6 +3244,9 @@ def _get_source(self) -> ResourceGroupSource: raise ValueError('Expected ResourceGroup.init_source() to have been already called') return self._source def _set_source(self, value: ResourceGroupSource) -> None: + if value == self._source: + return + if value is None: source_type = None source_id = None diff --git a/src/crystal/tests/util/windows.py b/src/crystal/tests/util/windows.py index 50a9df44..c404f970 100644 --- a/src/crystal/tests/util/windows.py +++ b/src/crystal/tests/util/windows.py @@ -335,7 +335,9 @@ async def wait_for() -> NewRootUrlDialog: assert isinstance(self.url_cleaner_spinner, wx.ActivityIndicator) self.name_field = self._dialog.FindWindow(name='cr-new-root-url-dialog__name-field') assert isinstance(self.name_field, wx.TextCtrl) - self.ok_button = self._dialog.FindWindow(id=wx.ID_OK) + self.ok_button = self._dialog.FindWindow(id=wx.ID_NEW) + if self.ok_button is None: + self.ok_button = self._dialog.FindWindow(id=wx.ID_SAVE) assert isinstance(self.ok_button, wx.Button) self.cancel_button = self._dialog.FindWindow(id=wx.ID_CANCEL) assert isinstance(self.cancel_button, wx.Button) @@ -398,7 +400,9 @@ async def wait_for() -> NewGroupDialog: assert isinstance(self.preview_members_list, wx.ListBox) self.cancel_button = add_group_dialog.FindWindow(id=wx.ID_CANCEL) assert isinstance(self.cancel_button, wx.Button) - self.ok_button = add_group_dialog.FindWindow(id=wx.ID_OK) + self.ok_button = add_group_dialog.FindWindow(id=wx.ID_NEW) + if self.ok_button is None: + self.ok_button = add_group_dialog.FindWindow(id=wx.ID_SAVE) assert isinstance(self.ok_button, wx.Button) return self diff --git a/src/crystal/util/wx_dialog.py b/src/crystal/util/wx_dialog.py index 4be14fa5..7756f4b4 100644 --- a/src/crystal/util/wx_dialog.py +++ b/src/crystal/util/wx_dialog.py @@ -67,3 +67,24 @@ def set_dialog_or_frame_icon_if_appropriate(tlw: wx.TopLevelWindow) -> None: # 2. KDE: Define app icon in the top-left corner and in the dock if is_windows() or is_kde_or_non_gnome(): tlw.SetIcons(wx.IconBundle(resources.open_binary('appicon.ico'))) + + +def CreateButtonSizer( + parent: wx.Dialog, + affirmative_id, + cancel_id=wx.ID_CANCEL, + ) -> wx.Sizer: + """ + Has a similar effect as wx.Dialog.CreateButtonSizer() but supports any + value for the `affirmative_id`. + """ + sizer = wx.StdDialogButtonSizer() + + affirmative_button = wx.Button(parent, affirmative_id) + affirmative_button.SetDefault() + sizer.SetAffirmativeButton(affirmative_button) + + sizer.SetCancelButton(wx.Button(parent, cancel_id)) + + sizer.Realize() + return sizer