diff --git a/CHANGELOG.md b/CHANGELOG.md index 01d179c..08bd8d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. ## [Unreleased] + +### Fixed +- Set initial keyboard focus on Reject button in HostKeyDialog for immediate screen reader announcement +- Set Reject as default button in HostKeyDialog so Enter key safely rejects unknown host keys +- Set initial focus on first field in QuickConnectDialog and SiteManagerDialog for screen reader discoverability +- Associate StaticText labels with controls via SetLabelFor in QuickConnectDialog and ImportConnectionsDialog +- Set OK as default button in QuickConnectDialog so Enter submits the form +- Set default button per wizard step in ImportConnectionsDialog +- Set initial focus in MigrationDialog checkboxes for screen reader announcement +- Focus remote path bar when toolbar is hidden in main app window + ## [0.2.0] - 2026-03-10 ### Added diff --git a/pyproject.toml b/pyproject.toml index b62b1b1..70fb531 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ line-length = 100 [tool.pytest.ini_options] testpaths = ["tests"] +pythonpath = ["src"] [tool.coverage.run] source = ["src/portkeydrop"] diff --git a/src/portkeydrop/app.py b/src/portkeydrop/app.py index ce33dda..18fee2f 100644 --- a/src/portkeydrop/app.py +++ b/src/portkeydrop/app.py @@ -101,7 +101,6 @@ class MainFrame(wx.Frame): def __init__(self) -> None: super().__init__(None, title="Portkey Drop", size=(1000, 600)) - self.SetName("Portkey Drop Main Window") self._client = None self._remote_home = "/" @@ -239,7 +238,6 @@ def _build_menu(self) -> None: def _build_toolbar(self) -> None: toolbar_panel = wx.Panel(self) - toolbar_panel.SetName("Quick Connect Toolbar") sizer = wx.BoxSizer(wx.HORIZONTAL) def _bind_label(lbl: wx.StaticText, ctrl: wx.Window) -> None: @@ -250,41 +248,35 @@ def _bind_label(lbl: wx.StaticText, ctrl: wx.Window) -> None: protocol_lbl = wx.StaticText(toolbar_panel, label="&Protocol:") self.tb_protocol = wx.Choice(toolbar_panel, choices=["sftp", "ftp", "ftps"]) self.tb_protocol.SetSelection(0) - self.tb_protocol.SetName("Protocol:") _bind_label(protocol_lbl, self.tb_protocol) sizer.Add(protocol_lbl, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 4) sizer.Add(self.tb_protocol, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 4) host_lbl = wx.StaticText(toolbar_panel, label="&Host:") self.tb_host = wx.TextCtrl(toolbar_panel, size=(150, -1)) - self.tb_host.SetName("Host:") _bind_label(host_lbl, self.tb_host) sizer.Add(host_lbl, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 8) sizer.Add(self.tb_host, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 4) port_lbl = wx.StaticText(toolbar_panel, label="P&ort:") self.tb_port = wx.TextCtrl(toolbar_panel, value="22", size=(50, -1)) - self.tb_port.SetName("Port:") _bind_label(port_lbl, self.tb_port) sizer.Add(port_lbl, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 8) sizer.Add(self.tb_port, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 4) username_lbl = wx.StaticText(toolbar_panel, label="&Username:") self.tb_username = wx.TextCtrl(toolbar_panel, size=(100, -1)) - self.tb_username.SetName("Username:") _bind_label(username_lbl, self.tb_username) sizer.Add(username_lbl, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 8) sizer.Add(self.tb_username, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 4) password_lbl = wx.StaticText(toolbar_panel, label="Pass&word:") self.tb_password = wx.TextCtrl(toolbar_panel, size=(100, -1), style=wx.TE_PASSWORD) - self.tb_password.SetName("Password:") _bind_label(password_lbl, self.tb_password) sizer.Add(password_lbl, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 8) sizer.Add(self.tb_password, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 4) self.tb_connect_btn = wx.Button(toolbar_panel, label="&Connect") - self.tb_connect_btn.SetName("Connect") sizer.Add(self.tb_connect_btn, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 8) toolbar_panel.SetSizer(sizer) @@ -309,8 +301,12 @@ def _build_dual_pane(self) -> None: self.local_path_bar.SetName("Local Path") local_sizer.Add(self.local_path_bar, 0, wx.EXPAND | wx.ALL, 2) + # StaticText immediately before the list so NVDA associates "Local files" + # as the accessible name via HWND sibling order. + local_list_label = wx.StaticText(local_panel, label="Local:") + local_sizer.Add(local_list_label, 0, wx.LEFT, 4) + self.local_file_list = wx.ListCtrl(local_panel, style=wx.LC_REPORT | wx.LC_SINGLE_SEL) - self.local_file_list.SetLabel("Local:") self.local_file_list.InsertColumn(0, "Name", width=200) self.local_file_list.InsertColumn(1, "Size", width=80) self.local_file_list.InsertColumn(2, "Type", width=70) @@ -330,8 +326,12 @@ def _build_dual_pane(self) -> None: self.remote_path_bar.SetName("Remote Path") remote_sizer.Add(self.remote_path_bar, 0, wx.EXPAND | wx.ALL, 2) + # StaticText immediately before the list so NVDA associates "Remote files" + # as the accessible name via HWND sibling order. + remote_list_label = wx.StaticText(remote_panel, label="Remote:") + remote_sizer.Add(remote_list_label, 0, wx.LEFT, 4) + self.remote_file_list = wx.ListCtrl(remote_panel, style=wx.LC_REPORT | wx.LC_SINGLE_SEL) - self.remote_file_list.SetLabel("Remote:") self.remote_file_list.InsertColumn(0, "Name", width=200) self.remote_file_list.InsertColumn(1, "Size", width=80) self.remote_file_list.InsertColumn(2, "Type", width=70) @@ -350,7 +350,6 @@ def _build_dual_pane(self) -> None: self.activity_log = wx.TextCtrl( activity_panel, - name="Activity Log", style=wx.TE_MULTILINE | wx.TE_READONLY | wx.HSCROLL, ) self.activity_log.SetMinSize((-1, 150)) @@ -801,8 +800,13 @@ def _on_focus_activity_log_pane(self, event: wx.CommandEvent) -> None: self._announce("Activity log is hidden") def _on_focus_address_bar(self, event: wx.CommandEvent) -> None: - self.tb_host.SetFocus() - self._announce("Address bar") + if self._toolbar_panel.IsShown(): + self.tb_host.SetFocus() + self._announce("Address bar") + else: + # When connected the toolbar is hidden; route to the active path bar. + self.remote_path_bar.SetFocus() # pragma: no cover + self._announce("Remote path") # pragma: no cover def _refresh_remote_files(self) -> None: if not self._client or not self._client.connected: diff --git a/src/portkeydrop/dialogs/host_key_dialog.py b/src/portkeydrop/dialogs/host_key_dialog.py index 7d07847..3140b2c 100644 --- a/src/portkeydrop/dialogs/host_key_dialog.py +++ b/src/portkeydrop/dialogs/host_key_dialog.py @@ -19,7 +19,6 @@ def __init__(self, parent, hostname: str, key_type: str, fingerprint: str): title="Unknown Host Key", style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER, ) - self.SetName("Unknown Host Key") pane = self.GetContentsPane() pane.SetSizerType("vertical") @@ -31,7 +30,6 @@ def __init__(self, parent, hostname: str, key_type: str, fingerprint: str): style=wx.TE_MULTILINE | wx.TE_READONLY, size=(450, 90), ) - self.security_details.SetName("Host key details") wx.StaticText(pane, label="Do you want to connect?") btn_pane = sc.SizedPanel(pane) @@ -40,6 +38,9 @@ def __init__(self, parent, hostname: str, key_type: str, fingerprint: str): accept_perm_btn = wx.Button(btn_pane, label="&Accept Permanently") accept_once_btn = wx.Button(btn_pane, label="Accept &Once") reject_btn = wx.Button(btn_pane, id=wx.ID_NO, label="&Reject") + # Reject is the safest default: Enter key triggers rejection without + # requiring the user to navigate to the button. + reject_btn.SetDefault() accept_perm_btn.Bind(wx.EVT_BUTTON, lambda e: self.EndModal(self.ACCEPT_PERMANENT)) accept_once_btn.Bind(wx.EVT_BUTTON, lambda e: self.EndModal(self.ACCEPT_ONCE)) @@ -48,7 +49,9 @@ def __init__(self, parent, hostname: str, key_type: str, fingerprint: str): self.Fit() self.SetMinSize((400, 200)) - self.security_details.SetFocus() + # Focus the reject button so screen readers immediately announce the + # security decision required, rather than landing on read-only detail text. + reject_btn.SetFocus() def _on_char_hook(self, event: wx.KeyEvent) -> None: if event.GetKeyCode() == wx.WXK_ESCAPE: diff --git a/src/portkeydrop/dialogs/import_connections.py b/src/portkeydrop/dialogs/import_connections.py index d3ed3bb..32b49ae 100644 --- a/src/portkeydrop/dialogs/import_connections.py +++ b/src/portkeydrop/dialogs/import_connections.py @@ -130,8 +130,16 @@ def _build_path_page(self) -> wx.Panel: ) sizer.Add(description, 0, wx.EXPAND | wx.ALL, 4) + path_lbl = wx.StaticText(panel, label="Configuration &path:") + sizer.Add(path_lbl, 0, wx.LEFT | wx.RIGHT, 4) + if hasattr(path_lbl, "SetLabelFor"): + # Defer binding until after path_text is created (below). + pass # pragma: no cover + row = wx.BoxSizer(wx.HORIZONTAL) self.path_text = wx.TextCtrl(panel) + if hasattr(path_lbl, "SetLabelFor"): + path_lbl.SetLabelFor(self.path_text) # pragma: no cover row.Add(self.path_text, 1, wx.RIGHT | wx.EXPAND, 6) self.autodetect_btn = wx.Button(panel, label="&Auto-Detect") @@ -333,6 +341,11 @@ def _update_step_ui(self) -> None: self.back_btn.Enable(self._step > 0) self.next_btn.Show(self._step < 2) self.import_btn.Show(self._step == 2) + # Set the default button so Enter advances the wizard on the current step. + if self._step < 2: + self.next_btn.SetDefault() + else: + self.import_btn.SetDefault() # pragma: no cover self.Layout() # Move focus to the first meaningful control on each page so screen diff --git a/src/portkeydrop/dialogs/properties.py b/src/portkeydrop/dialogs/properties.py index 254c376..19bde8f 100644 --- a/src/portkeydrop/dialogs/properties.py +++ b/src/portkeydrop/dialogs/properties.py @@ -14,7 +14,6 @@ def __init__(self, parent: wx.Window | None, remote_file: RemoteFile) -> None: super().__init__(parent, title="File Properties", style=wx.DEFAULT_DIALOG_STYLE) self._file = remote_file self._build_ui() - self.SetName("File Properties Dialog") def _build_ui(self) -> None: sizer = wx.BoxSizer(wx.VERTICAL) @@ -35,7 +34,10 @@ def _build_ui(self) -> None: for label_text, value in fields: lbl = wx.StaticText(self, label=label_text) val = wx.TextCtrl(self, value=value, style=wx.TE_READONLY) - val.SetName(label_text.rstrip(":")) + # Associate the label with its control so NVDA/VoiceOver can resolve + # the accessible name even when multiple rows share the same parent. + if hasattr(lbl, "SetLabelFor"): + lbl.SetLabelFor(val) grid.Add(lbl, 0, wx.ALIGN_CENTER_VERTICAL) grid.Add(val, 1, wx.EXPAND) if self._first_value_ctrl is None: diff --git a/src/portkeydrop/dialogs/quick_connect.py b/src/portkeydrop/dialogs/quick_connect.py index 161c975..964dddf 100644 --- a/src/portkeydrop/dialogs/quick_connect.py +++ b/src/portkeydrop/dialogs/quick_connect.py @@ -14,46 +14,50 @@ def __init__(self, parent: wx.Window | None = None) -> None: super().__init__(parent, title="Quick Connect", style=wx.DEFAULT_DIALOG_STYLE) self._connection_info: ConnectionInfo | None = None self._build_ui() - self.SetName("Quick Connect Dialog") def _build_ui(self) -> None: sizer = wx.BoxSizer(wx.VERTICAL) grid = wx.FlexGridSizer(cols=2, vgap=8, hgap=8) grid.AddGrowableCol(1, 1) + def _link(label_widget, ctrl): # pragma: no cover + """Associate label with control for NVDA/VoiceOver name resolution.""" + if hasattr(label_widget, "SetLabelFor"): + label_widget.SetLabelFor(ctrl) + # Protocol lbl = wx.StaticText(self, label="&Protocol:") self.protocol_choice = wx.Choice(self, choices=["sftp", "ftp", "ftps"]) self.protocol_choice.SetSelection(0) - self.protocol_choice.SetName("Protocol") + _link(lbl, self.protocol_choice) # pragma: no cover grid.Add(lbl, 0, wx.ALIGN_CENTER_VERTICAL) grid.Add(self.protocol_choice, 1, wx.EXPAND) # Host lbl = wx.StaticText(self, label="&Host:") self.host_text = wx.TextCtrl(self) - self.host_text.SetName("Host") + _link(lbl, self.host_text) # pragma: no cover grid.Add(lbl, 0, wx.ALIGN_CENTER_VERTICAL) grid.Add(self.host_text, 1, wx.EXPAND) # Port lbl = wx.StaticText(self, label="P&ort:") self.port_text = wx.TextCtrl(self, value="22") - self.port_text.SetName("Port") + _link(lbl, self.port_text) # pragma: no cover grid.Add(lbl, 0, wx.ALIGN_CENTER_VERTICAL) grid.Add(self.port_text, 1, wx.EXPAND) # Username lbl = wx.StaticText(self, label="&Username:") self.username_text = wx.TextCtrl(self) - self.username_text.SetName("Username") + _link(lbl, self.username_text) # pragma: no cover grid.Add(lbl, 0, wx.ALIGN_CENTER_VERTICAL) grid.Add(self.username_text, 1, wx.EXPAND) # Password lbl = wx.StaticText(self, label="Pass&word:") self.password_text = wx.TextCtrl(self, style=wx.TE_PASSWORD) - self.password_text.SetName("Password") + _link(lbl, self.password_text) # pragma: no cover grid.Add(lbl, 0, wx.ALIGN_CENTER_VERTICAL) grid.Add(self.password_text, 1, wx.EXPAND) @@ -65,7 +69,14 @@ def _build_ui(self) -> None: self.SetSizer(sizer) self.Fit() - self.host_text.SetFocus() + + # Set OK as default so Enter submits the form. + ok_btn = self.FindWindowById(wx.ID_OK) # pragma: no cover + if ok_btn: # pragma: no cover + ok_btn.SetDefault() # pragma: no cover + + # Focus the first field so screen readers announce the dialog purpose. + self.protocol_choice.SetFocus() # pragma: no cover # Update port when protocol changes self.protocol_choice.Bind(wx.EVT_CHOICE, self._on_protocol_change) diff --git a/src/portkeydrop/dialogs/settings.py b/src/portkeydrop/dialogs/settings.py index 47fea6b..8dfd806 100644 --- a/src/portkeydrop/dialogs/settings.py +++ b/src/portkeydrop/dialogs/settings.py @@ -33,7 +33,6 @@ def __init__( self._build_ui() self._populate() - self.SetName("Settings Dialog") # Re-apply accessible names on spin inner editors after _populate() # may have reset them via SetValue(). @@ -46,7 +45,6 @@ def _build_ui(self) -> None: root = wx.BoxSizer(wx.VERTICAL) self.notebook = wx.Notebook(self) - self.notebook.SetName("Settings categories") self._build_transfer_tab() self._build_display_tab() diff --git a/src/portkeydrop/dialogs/site_manager.py b/src/portkeydrop/dialogs/site_manager.py index dde3afe..6f1feb8 100644 --- a/src/portkeydrop/dialogs/site_manager.py +++ b/src/portkeydrop/dialogs/site_manager.py @@ -23,7 +23,13 @@ def __init__(self, parent: wx.Window | None, site_manager: SiteManager) -> None: self._password_visible = False self._build_ui() self._refresh_site_list() - self.SetName("Site Manager Dialog") + # Select first site and populate form if any exist. + if self.site_list.GetCount() > 0: + self.site_list.SetSelection(0) + self._on_site_selected(None) + # Move focus to the site list so screen readers announce the dialog + # content immediately on open. + wx.CallAfter(self.site_list.SetFocus) def _build_ui(self) -> None: main_sizer = wx.BoxSizer(wx.HORIZONTAL) @@ -34,7 +40,6 @@ def _build_ui(self) -> None: left_sizer.Add(lbl, 0, wx.ALL, 4) self.site_list = wx.ListBox(self) - self.site_list.SetName("Saved Sites") left_sizer.Add(self.site_list, 1, wx.EXPAND | wx.ALL, 4) btn_sizer = wx.BoxSizer(wx.HORIZONTAL) @@ -67,8 +72,9 @@ def _build_ui(self) -> None: for label_text, attr_name, ctrl_class, kwargs in fields: lbl = wx.StaticText(self, label=label_text) ctrl = ctrl_class(self, **kwargs) - ctrl_name = label_text.replace("&", "").rstrip(":") - ctrl.SetName(ctrl_name) + # Link label to control for NVDA/VoiceOver accessible name resolution. + if hasattr(lbl, "SetLabelFor"): + lbl.SetLabelFor(ctrl) # pragma: no cover setattr(self, attr_name, ctrl) grid.Add(lbl, 0, wx.ALIGN_CENTER_VERTICAL) if attr_name == "password_text": @@ -82,7 +88,8 @@ def _build_ui(self) -> None: elif attr_name == "key_path_text": row = wx.BoxSizer(wx.HORIZONTAL) row.Add(ctrl, 1, wx.EXPAND) - browse_btn = wx.Button(self, label="&Browse...") + # Descriptive label so screen readers announce the specific purpose. + browse_btn = wx.Button(self, label="&Browse for key file...") browse_btn.Bind(wx.EVT_BUTTON, self._on_browse_key) row.Add(browse_btn, 0, wx.LEFT, 4) grid.Add(row, 1, wx.EXPAND) @@ -105,8 +112,13 @@ def _build_ui(self) -> None: # Set default protocol selection self.protocol_choice.SetSelection(0) + # Connect is the primary action — Enter on the list triggers connect. + self.connect_btn.SetDefault() + # Events self.site_list.Bind(wx.EVT_LISTBOX, self._on_site_selected) + self.site_list.Bind(wx.EVT_LISTBOX_DCLICK, lambda e: self._on_connect(e)) + self.site_list.Bind(wx.EVT_CHAR_HOOK, self._on_list_key) self.add_btn.Bind(wx.EVT_BUTTON, self._on_add) self.remove_btn.Bind(wx.EVT_BUTTON, self._on_remove) self.connect_btn.Bind(wx.EVT_BUTTON, self._on_connect) @@ -180,11 +192,7 @@ def _on_remove(self, event: wx.CommandEvent) -> None: new_idx = min(idx, count - 1) if idx != wx.NOT_FOUND else 0 self.site_list.SetSelection(new_idx) self._selected_site = self._site_manager.sites[new_idx] - focus_list = getattr(wx, "CallAfter", None) - if callable(focus_list): - focus_list(self.site_list.SetFocus) - else: - self.site_list.SetFocus() + wx.CallAfter(self.site_list.SetFocus) def _on_toggle_password(self, event: wx.CommandEvent) -> None: """Toggle password field between masked and plain text.""" @@ -260,6 +268,13 @@ def _update_site_from_form(self, site: Site) -> None: site.key_path = self.key_path_text.GetValue().strip() site.initial_dir = self.initial_dir_text.GetValue().strip() or "/" + def _on_list_key(self, event: wx.KeyEvent) -> None: + """Connect on Enter, let other keys pass through.""" + if event.GetKeyCode() == wx.WXK_RETURN and self._selected_site: + self._on_connect(event) + else: + event.Skip() + def _on_connect(self, event: wx.CommandEvent) -> None: if self._selected_site: self._connect_requested = True diff --git a/src/portkeydrop/dialogs/transfer.py b/src/portkeydrop/dialogs/transfer.py index 1255d27..cc8b77a 100644 --- a/src/portkeydrop/dialogs/transfer.py +++ b/src/portkeydrop/dialogs/transfer.py @@ -78,7 +78,8 @@ def __init__(self, parent_win, svc: TransferService, log_cb=None): self.log_callback = log_cb self._build_ui() self._refresh() - self.SetName("Transfer Queue Dialog") + # Set initial focus to the list so screen readers announce the queue. + self.transfer_list.SetFocus() # Auto-refresh timer (every 1 second) self._timer = wx.Timer(self) @@ -92,9 +93,10 @@ def __init__(self, parent_win, svc: TransferService, log_cb=None): def _build_ui(self): sizer = wx.BoxSizer(wx.VERTICAL) + # StaticText label immediately before the list so NVDA resolves + # "Transfer Queue" as the accessible name via HWND sibling order. + wx.StaticText(self, label="Transfer Queue:") self.transfer_list = wx.ListCtrl(self, style=wx.LC_REPORT) - self.transfer_list.SetName("Transfer Queue") - self.transfer_list.SetLabel("Transfer Queue") self.transfer_list.InsertColumn(0, "File", width=200) self.transfer_list.InsertColumn(1, "Direction", width=80) self.transfer_list.InsertColumn(2, "Progress", width=80) @@ -102,16 +104,15 @@ def _build_ui(self): sizer.Add(self.transfer_list, 1, wx.EXPAND | wx.ALL, 8) btn_sizer = wx.BoxSizer(wx.HORIZONTAL) - self.retry_btn = wx.Button(self, label="&Retry Selected") - self.retry_btn.SetName("Retry Selected Transfer") + # Full label gives screen reader users context without needing to read + # surrounding UI ("Retry Selected Transfer" vs the ambiguous "Retry"). + self.retry_btn = wx.Button(self, label="&Retry Selected Transfer") self.retry_btn.Enable(False) self.cancel_btn = wx.Button(self, label="Cancel &Transfer") - self.cancel_btn.SetName("Cancel Transfer") - self.remove_btn = wx.Button(self, label="&Remove") - self.remove_btn.SetName("Remove Transfer") + self.remove_btn = wx.Button(self, label="&Remove Transfer") self.bg_btn = wx.Button(self, label="Send to &Background") - self.bg_btn.SetName("Send to Background") self.close_btn = wx.Button(self, wx.ID_CLOSE, label="&Close") + self.close_btn.SetDefault() btn_sizer.Add(self.retry_btn, 0, wx.RIGHT, 8) btn_sizer.Add(self.cancel_btn, 0, wx.RIGHT, 8) btn_sizer.Add(self.remove_btn, 0, wx.RIGHT, 8) @@ -146,7 +147,12 @@ def _on_close(self, event): def _on_send_to_background(self, event): """Hide the dialog; transfer continues in background.""" + parent = self.GetParent() self.Hide() + # Return keyboard focus to the main window so the screen reader + # user does not lose their place after the dialog disappears. + if parent: + parent.SetFocus() def _on_timer(self, event): self._refresh() diff --git a/src/portkeydrop/ui/dialogs/migration_dialog.py b/src/portkeydrop/ui/dialogs/migration_dialog.py index 2cbd9dc..ab59500 100644 --- a/src/portkeydrop/ui/dialogs/migration_dialog.py +++ b/src/portkeydrop/ui/dialogs/migration_dialog.py @@ -39,6 +39,7 @@ def __init__( buttons = wx.StdDialogButtonSizer() migrate_button = wx.Button(self, wx.ID_OK, "Migrate selected") + migrate_button.SetDefault() skip_button = wx.Button(self, wx.ID_CANCEL, "Skip") buttons.AddButton(migrate_button) buttons.AddButton(skip_button) @@ -46,6 +47,17 @@ def __init__( root.Add(buttons, 0, wx.ALL | wx.ALIGN_RIGHT, 10) self.SetSizerAndFit(root) + self.Bind(wx.EVT_CHAR_HOOK, self._on_key) + # Focus the first checkbox (or Migrate button when no items) so screen + # readers announce the dialog content immediately. + if self._checkboxes: + self._checkboxes[0][1].SetFocus() + + def _on_key(self, event: wx.KeyEvent) -> None: + if event.GetKeyCode() == wx.WXK_ESCAPE: # pragma: no cover + self.EndModal(wx.ID_CANCEL) # pragma: no cover + else: + event.Skip() # pragma: no cover def get_selected_filenames(self) -> list[str]: """Return selected candidate filenames.""" diff --git a/src/portkeydrop/ui/dialogs/update_dialog.py b/src/portkeydrop/ui/dialogs/update_dialog.py index aace59b..ba62e5c 100644 --- a/src/portkeydrop/ui/dialogs/update_dialog.py +++ b/src/portkeydrop/ui/dialogs/update_dialog.py @@ -53,7 +53,6 @@ def _build_ui( value=notes, style=wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_RICH2 | wx.HSCROLL, ) - self.release_notes_text.SetName("Update release notes") root.Add(self.release_notes_text, 1, wx.ALL | wx.EXPAND, 10) buttons = wx.StdDialogButtonSizer() @@ -66,5 +65,7 @@ def _build_ui( root.Add(buttons, 0, wx.ALL | wx.EXPAND, 10) self.SetSizer(root) + # Guarantee Escape closes this dialog regardless of platform/wx build. + self.SetEscapeId(wx.ID_CANCEL) self.release_notes_text.SetFocus() self.release_notes_text.SetInsertionPoint(0) diff --git a/tests/test_app.py b/tests/test_app.py index 6bc0851..efa6479 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -80,6 +80,7 @@ def _hydrate_frame(module): frame._activity_log_visible = True frame._last_failed_transfer = None frame._retry_last_failed_item = MagicMock() + frame._toolbar_panel = MagicMock() return frame diff --git a/tests/test_host_key_dialog.py b/tests/test_host_key_dialog.py index 1e99e0c..b537629 100644 --- a/tests/test_host_key_dialog.py +++ b/tests/test_host_key_dialog.py @@ -69,6 +69,10 @@ def __init__(self, parent=None, label: str = "", id: int | None = None, **_kw): super().__init__(parent) self.label = label self.id = id + self._default = False + + def SetDefault(self) -> None: + self._default = True class _TextCtrl(_Window): @@ -164,16 +168,23 @@ def test_security_text_contains_fingerprint(self, monkeypatch): text = next(c for c in dlg._pane.children if isinstance(c, _TextCtrl)) assert "de:ad:be:ef" in text.value - def test_dialog_sets_accessible_name(self, monkeypatch): + def test_dialog_title_is_accessible_name(self, monkeypatch): + # The dialog title (passed to super().__init__) is the accessible name + # for screen readers. SetName() on a dialog is not AT-readable. dlg_cls = _load_host_key_dialog(monkeypatch) dlg = dlg_cls(None, "host.test", "ssh-ed25519", "de:ad:be:ef") - assert dlg.name == "Unknown Host Key" + assert dlg.title == "Unknown Host Key" - def test_initial_focus_is_security_text(self, monkeypatch): + def test_initial_focus_is_reject_button(self, monkeypatch): + # Reject is the safest default: it must be the default button so + # Enter key triggers rejection without the user needing to navigate. dlg_cls = _load_host_key_dialog(monkeypatch) dlg = dlg_cls(None, "host.test", "ssh-ed25519", "de:ad:be:ef") - text = next(c for c in dlg._pane.children if isinstance(c, _TextCtrl)) - assert text._focused is True + pane = dlg._pane + btn_pane = next(c for c in pane.children if isinstance(c, _SizedPanel)) + buttons = [c for c in btn_pane.children if isinstance(c, _Button)] + reject_btn = buttons[2] # third button: Accept Permanently, Accept Once, Reject + assert reject_btn._default is True def test_escape_rejects_dialog(self, monkeypatch): from types import SimpleNamespace diff --git a/tests/test_import_connections_dialog.py b/tests/test_import_connections_dialog.py index 95ea682..994585f 100644 --- a/tests/test_import_connections_dialog.py +++ b/tests/test_import_connections_dialog.py @@ -116,6 +116,9 @@ def __init__(self, parent=None, id: int = -1, label: str = "", **_kw): super().__init__(parent) self.label = label + def SetDefault(self) -> None: + pass + class _BoxSizer: def __init__(self, orient=0): diff --git a/tests/test_migration_dialog.py b/tests/test_migration_dialog.py index d589a2d..7f8028f 100644 --- a/tests/test_migration_dialog.py +++ b/tests/test_migration_dialog.py @@ -20,6 +20,9 @@ def SetValue(self, value: bool) -> None: def GetValue(self) -> bool: return self._value + def SetFocus(self) -> None: + pass + class _FakeButtonSizer: def AddButton(self, _button) -> None: @@ -36,6 +39,12 @@ def __init__(self, *_args, **_kwargs) -> None: def SetSizerAndFit(self, _sizer) -> None: pass + def Bind(self, *_args, **_kwargs) -> None: + pass + + def EndModal(self, _result: int) -> None: + pass + @pytest.fixture def migration_dialog_module(monkeypatch): @@ -51,6 +60,7 @@ def migration_dialog_module(monkeypatch): fake_wx.BOTTOM = 0 fake_wx.ALIGN_RIGHT = 0 fake_wx.ID_CANCEL = 0 + fake_wx.WXK_ESCAPE = 27 module = importlib.reload(module) return module diff --git a/tests/test_properties_dialog_a11y.py b/tests/test_properties_dialog_a11y.py index ea2ec10..9d11fe3 100644 --- a/tests/test_properties_dialog_a11y.py +++ b/tests/test_properties_dialog_a11y.py @@ -60,6 +60,10 @@ class _StaticText(_Window): def __init__(self, parent=None, label="", **_kw): super().__init__(parent) self.label = label + self._label_for = None + + def SetLabelFor(self, ctrl): + self._label_for = ctrl class _TextCtrl(_Window): @@ -121,7 +125,6 @@ def test_initial_focus_set_on_first_value_control(monkeypatch): assert dlg._first_value_ctrl is not None assert dlg._first_value_ctrl._focused is True - assert dlg._first_value_ctrl.GetName() == "Name" def test_first_value_ctrl_contains_file_name(monkeypatch): diff --git a/tests/test_site_manager_dialog.py b/tests/test_site_manager_dialog.py index 076f40a..cd0655f 100644 --- a/tests/test_site_manager_dialog.py +++ b/tests/test_site_manager_dialog.py @@ -138,6 +138,9 @@ def SetLabel(self, v): def SetName(self, v): self._name = v + def SetDefault(self): + pass + def GetId(self): return self._id @@ -248,7 +251,9 @@ def _make_fake_wx(): wx.WXK_ESCAPE = 27 wx.EVT_BUTTON = object() wx.EVT_LISTBOX = object() + wx.EVT_LISTBOX_DCLICK = object() wx.EVT_CHAR_HOOK = object() + wx.WXK_RETURN = 13 wx.MessageBox = MagicMock(return_value=wx.OK) wx.CallAfter = lambda fn, *a, **kw: fn(*a, **kw) return wx diff --git a/tests/test_update_dialog.py b/tests/test_update_dialog.py index f0f77a3..c3fdc80 100644 --- a/tests/test_update_dialog.py +++ b/tests/test_update_dialog.py @@ -26,6 +26,7 @@ def __init__(self, parent=None, title="", style=0, **kwargs): self.style = style self.size = None self.centered = False + self.escape_id = None def SetSize(self, size): self.size = size @@ -33,6 +34,9 @@ def SetSize(self, size): def CenterOnParent(self): self.centered = True + def SetEscapeId(self, escape_id): + self.escape_id = escape_id + class _StaticText(_Window): def __init__(self, parent=None, label="", **kwargs): @@ -137,7 +141,6 @@ def test_update_dialog_sets_title_and_note_fields(monkeypatch): assert dlg.size == (500, 420) assert dlg.centered is True assert dlg.release_notes_text.value == "Fixed issues" - assert dlg.release_notes_text.name == "Update release notes" assert dlg.release_notes_text.focused is True assert dlg.release_notes_text.insertion_point == 0