Skip to content

feat: implement unified toggle widget shortcut system across interfaces #29

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
59 changes: 59 additions & 0 deletions mininterface/_lib/shortcuts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
def convert_to_tkinter_shortcut(shortcut: str) -> str:
"""Convert a textual shortcut format to tkinter format.

Args:
shortcut: Shortcut in textual format (e.g., "ctrl+t", "f4")

Returns:
Shortcut in tkinter format (e.g., "<Control-t>", "<F4>")
"""
# Handle function keys
if shortcut.startswith("f") and shortcut[1:].isdigit():
return f"<{shortcut.upper()}>"

# Handle modifier keys
mods = {
"ctrl": "Control",
"alt": "Alt",
"shift": "Shift",
"cmd": "Command", # For macOS
"meta": "Meta",
}

parts = shortcut.lower().split("+")
keys = [mods.get(p, p) for p in parts]
modifiers = keys[:-1]
key = keys[-1]

return f"<{'-'.join(modifiers + [key])}>"


def convert_to_textual_shortcut(shortcut: str) -> str:
"""Convert a tkinter shortcut format to textual format.

Args:
shortcut: Shortcut in tkinter format (e.g., "<Control-t>", "<F4>")

Returns:
Shortcut in textual format (e.g., "ctrl+t", "f4")
"""
shortcut = shortcut.strip("<>")

# Handle function keys
if shortcut.startswith("F") and shortcut[1:].isdigit():
return shortcut.lower()

# Handle modifier keys
mods = {
"Control": "ctrl",
"Alt": "alt",
"Shift": "shift",
"Command": "cmd", # For macOS
"Meta": "meta",
}

parts = shortcut.split("-")
# Convert each part to its proper form
keys = [mods.get(part.title(), part.lower()) for part in parts]

return "+".join(keys)
6 changes: 4 additions & 2 deletions mininterface/_tk_interface/date_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
Calendar = None

from ..tag.datetime_tag import DatetimeTag
from .._lib.shortcuts import convert_to_tkinter_shortcut
if TYPE_CHECKING:
from mininterface._tk_interface.adaptor import TkAdaptor

Expand Down Expand Up @@ -76,8 +77,9 @@ def create_spinbox(self, variable: tk.Variable):
# Bind key release event to update calendar when user changes the input field
spinbox.bind("<KeyRelease>", self.on_spinbox_change)

# Toggle calendar widget with ctrl+shift+c
spinbox.bind("<Control-Shift-C>", self.toggle_calendar)
# Toggle calendar widget with the shortcut from settings
tk_shortcut = convert_to_tkinter_shortcut(self.tk_app.options.toggle_widget)
spinbox.bind(tk_shortcut, self.toggle_calendar)

# Select all in the spinbox with ctrl+a
spinbox.bind("<Control-a>", self.select_all)
Expand Down
12 changes: 7 additions & 5 deletions mininterface/_tk_interface/secret_entry.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,31 @@
import tkinter as tk
from tkinter import Button, Entry
from ..tag.secret_tag import SecretTag
from .._lib.shortcuts import convert_to_tkinter_shortcut


class SecretEntryWrapper:
def __init__(self, master, tag: SecretTag, variable: tk.Variable, grid_info):
def __init__(self, master, tag: SecretTag, variable: tk.Variable, grid_info, adaptor):
self.tag = tag
self.entry = Entry(master, text=variable, show="•")
# Add more hints here as needed
self.entry._shortcut_hints = [
"Ctrl+T: Toggle visibility of password field"
f"{adaptor.options.toggle_widget}: Toggle visibility of password field"
]
row = grid_info['row']
col = grid_info['column']
self.entry.grid(row=row, column=col, sticky="we")

# Add Ctrl+T binding to the entry widget
self.entry.bind('<Control-t>', self._on_toggle)
# Add binding using the shortcut from settings
tk_shortcut = convert_to_tkinter_shortcut(adaptor.options.toggle_widget)
self.entry.bind(tk_shortcut, self._on_toggle)

if tag.show_toggle:
self.button = Button(master, text='👁', command=self.toggle_show)
self.button.grid(row=row, column=col + 1)

def _on_toggle(self, event=None):
"""Handle Ctrl+T key event"""
"""Handle toggle key event"""
self.toggle_show()
return "break" # Prevent event propagation

Expand Down
2 changes: 1 addition & 1 deletion mininterface/_tk_interface/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ def handle_return(event):
grid_info = widget.grid_info()
widget.grid_forget()
# Create wrapper and store it in the widget list
wrapper = SecretEntryWrapper(master, tag, variable, grid_info)
wrapper = SecretEntryWrapper(master, tag, variable, grid_info, adaptor)
widget = wrapper.entry
# Add shortcut to the central shortcuts set
adaptor.shortcuts.add("Ctrl+T: Toggle visibility of password field")
Expand Down
28 changes: 12 additions & 16 deletions mininterface/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,18 @@
@dataclass
class UiSettings:
toggle_widget: str = "f4"
""" Shortcuts to toggle ex. calendar or file picker. """

# NOTE should be used in tkinter
# But we have to convert textual shortcut to tkinter shortcut with something like this
# mods = {
# "ctrl": "Control",
# "alt": "Alt",
# "shift": "Shift",
# }

# parts = shortcut.lower().split("+")
# keys = [mods.get(p, p) for p in parts]
# modifiers = keys[:-1]
# key = keys[-1]

# return f"<{'-'.join(modifiers + [key])}>"
""" Shortcut to toggle widgets like secret fields, file picker, and datetime dialog.

The shortcut should be in Textual format (e.g., "ctrl+t", "f4").
This format will be automatically converted to the appropriate format
for each interface (e.g., "<Control-t>" for Tkinter).

Examples:
- "ctrl+t" for Control+T
- "alt+f" for Alt+F
- "f4" for F4 key
- "cmd+s" for Command+S (macOS)
"""

mnemonic: Optional[bool] = True
""" Allow users to access fields with the `Alt+char` shortcut.
Expand Down
87 changes: 87 additions & 0 deletions tests/test_shortcuts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
from mininterface._lib.shortcuts import convert_to_tkinter_shortcut, convert_to_textual_shortcut
from shared import TestAbstract


class TestShortcuts(TestAbstract):
def test_basic_modifiers(self):
"""Test basic modifier key conversions."""
# Textual to Tkinter
self.assertEqual(convert_to_tkinter_shortcut("ctrl+t"), "<Control-t>")
self.assertEqual(convert_to_tkinter_shortcut("alt+f"), "<Alt-f>")
self.assertEqual(convert_to_tkinter_shortcut("shift+s"), "<Shift-s>")

# Tkinter to Textual
self.assertEqual(convert_to_textual_shortcut("<Control-t>"), "ctrl+t")
self.assertEqual(convert_to_textual_shortcut("<Alt-f>"), "alt+f")
self.assertEqual(convert_to_textual_shortcut("<Shift-s>"), "shift+s")

def test_function_keys(self):
"""Test function key conversions."""
# Textual to Tkinter
self.assertEqual(convert_to_tkinter_shortcut("f4"), "<F4>")
self.assertEqual(convert_to_tkinter_shortcut("f12"), "<F12>")

# Tkinter to Textual
self.assertEqual(convert_to_textual_shortcut("<F4>"), "f4")
self.assertEqual(convert_to_textual_shortcut("<F12>"), "f12")

def test_multiple_modifiers(self):
"""Test multiple modifier key combinations."""
# Textual to Tkinter
self.assertEqual(convert_to_tkinter_shortcut("ctrl+alt+t"), "<Control-Alt-t>")
self.assertEqual(convert_to_tkinter_shortcut("ctrl+shift+s"), "<Control-Shift-s>")

# Tkinter to Textual
self.assertEqual(convert_to_textual_shortcut("<Control-Alt-t>"), "ctrl+alt+t")
self.assertEqual(convert_to_textual_shortcut("<Control-Shift-s>"), "ctrl+shift+s")

def test_macos_keys(self):
"""Test macOS specific key conversions."""
# Textual to Tkinter
self.assertEqual(convert_to_tkinter_shortcut("cmd+s"), "<Command-s>")
self.assertEqual(convert_to_tkinter_shortcut("meta+t"), "<Meta-t>")

# Tkinter to Textual
self.assertEqual(convert_to_textual_shortcut("<Command-s>"), "cmd+s")
self.assertEqual(convert_to_textual_shortcut("<Meta-t>"), "meta+t")

def test_case_insensitivity(self):
"""Test case insensitivity in conversions."""
# Textual to Tkinter
self.assertEqual(convert_to_tkinter_shortcut("CTRL+T"), "<Control-t>")
self.assertEqual(convert_to_tkinter_shortcut("Ctrl+t"), "<Control-t>")
self.assertEqual(convert_to_tkinter_shortcut("cTrL+t"), "<Control-t>")

# Tkinter to Textual
self.assertEqual(convert_to_textual_shortcut("<CONTROL-t>"), "ctrl+t")
self.assertEqual(convert_to_textual_shortcut("<Control-t>"), "ctrl+t")
self.assertEqual(convert_to_textual_shortcut("<CoNtRoL-t>"), "ctrl+t")

def test_roundtrip_conversion(self):
"""Test that converting back and forth gives the same result."""
test_shortcuts = [
"ctrl+t",
"alt+f",
"shift+s",
"f4",
"ctrl+alt+t",
"cmd+s",
"meta+t"
]

for shortcut in test_shortcuts:
tk_shortcut = convert_to_tkinter_shortcut(shortcut)
back_to_textual = convert_to_textual_shortcut(tk_shortcut)
self.assertEqual(back_to_textual, shortcut)

def test_invalid_input(self):
"""Test handling of invalid inputs."""
with self.assertRaises(AttributeError):
convert_to_tkinter_shortcut(None)

with self.assertRaises(AttributeError):
convert_to_textual_shortcut(None)

# Empty string should be handled gracefully
self.assertEqual(convert_to_tkinter_shortcut(""), "<>")
self.assertEqual(convert_to_textual_shortcut("<>"), "")