Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ line-length = 100

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]

[tool.coverage.run]
source = ["src/portkeydrop"]
Expand Down
30 changes: 17 additions & 13 deletions src/portkeydrop/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "/"
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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))
Expand Down Expand Up @@ -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:
Expand Down
9 changes: 6 additions & 3 deletions src/portkeydrop/dialogs/host_key_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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)
Expand All @@ -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))
Expand All @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions src/portkeydrop/dialogs/import_connections.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/portkeydrop/dialogs/properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down
25 changes: 18 additions & 7 deletions src/portkeydrop/dialogs/quick_connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
Expand Down
2 changes: 0 additions & 2 deletions src/portkeydrop/dialogs/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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().
Expand All @@ -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()
Expand Down
35 changes: 25 additions & 10 deletions src/portkeydrop/dialogs/site_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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":
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading