From cd29a04aefcbe326c80ec9bb5f62f008290b9faa Mon Sep 17 00:00:00 2001 From: bcExpt1123 Date: Sun, 3 May 2026 19:42:08 -0400 Subject: [PATCH 1/2] feat: make list behavior more intuitive --- src/cronboard/app.py | 42 ++++++++++++++++++++++++++-- src/cronboard_widgets/CronServers.py | 19 +++++++++++++ tests/CronCreator_test.py | 1 - tests/CronDeleteConfirmation_test.py | 3 -- tests/CronInputSearch_test.py | 3 -- tests/CronServers_test.py | 1 + tests/app_test.py | 11 ++++++-- 7 files changed, 69 insertions(+), 11 deletions(-) diff --git a/src/cronboard/app.py b/src/cronboard/app.py index 08e1da7..21c7adc 100644 --- a/src/cronboard/app.py +++ b/src/cronboard/app.py @@ -3,9 +3,10 @@ from crontab import CronTab import tomlkit from pathlib import Path +from textual import events from textual.app import App, ComposeResult from textual.binding import Binding -from textual.widgets import Footer, Label, Tabs, Tab +from textual.widgets import Footer, Label, Tabs, Tab, Tree, Button, Input, Checkbox, MaskedInput, RadioButton, Select, Switch, TextArea from cronboard_widgets.CronTable import CronTable from textual.containers import Container from cronboard_widgets.CronTabs import CronTabs @@ -13,6 +14,8 @@ from cronboard_widgets.CronDeleteConfirmation import CronDeleteConfirmation from cronboard_widgets.CronServers import CronServers +def is_form_element(element): + return isinstance(element, (Input, Checkbox, Button, MaskedInput, RadioButton, Select, Switch, TextArea)) class CronBoard(App): """A Textual App to manage cron jobs.""" @@ -22,7 +25,7 @@ class CronBoard(App): BINDINGS = [ Binding("q,ctrl+q", "quit", "Quit", priority=True), - Binding("Tab", "focus_next", "Change Panel"), + Binding("Tab", "next_tab_and_focus", "Change Panel"), ] def compose(self) -> ComposeResult: @@ -46,6 +49,7 @@ def on_mount(self) -> None: self.local_table = CronTable(id="local-crontable") self.content_container.mount(self.local_table) self.local_table.display = True + self.set_focus(self.local_table) def load_config(self): if self.config_path.exists(): @@ -83,6 +87,40 @@ def show_tab_content(self, index: int) -> None: self.local_table.display = False self.servers.display = True + def on_key(self, event: events.Key) -> None: + if event.key != "tab": + return + + if is_form_element(self.focused): + return + + event.prevent_default() + self.action_next_tab_and_focus() + + def action_next_tab_and_focus(self) -> None: + tabs = self.tabs + tab_widgets = list(tabs.query(Tab)) + tab_ids = [tab.id for tab in tab_widgets] + current = tabs.active + index = tab_ids.index(current) + + next_index = (index + 1) % len(tab_ids) + next_tab_id = tab_ids[next_index] + + tabs.active = next_tab_id + + self.show_tab_content(next_index) + self._focus_active_panel() + + def _focus_active_panel(self) -> None: + if self.tabs.active == "local": + if self.local_table: + self.set_focus(self.local_table) + + elif self.tabs.active == "servers": + if self.servers: + self.servers.focus_tree() + def action_create_cronjob( self, cron: CronTab, remote=False, ssh_client=None, crontab_user=None ) -> None: diff --git a/src/cronboard_widgets/CronServers.py b/src/cronboard_widgets/CronServers.py index f1c2a44..89424c6 100644 --- a/src/cronboard_widgets/CronServers.py +++ b/src/cronboard_widgets/CronServers.py @@ -20,6 +20,7 @@ class CronServers(Widget): Binding("D", "delete_server", "Delete Server"), Binding("c", "connect_server", "Connect"), Binding("d", "disconnect_server", "Disconnect Server"), + Binding("J", "jump", "Jump"), ] def __init__(self) -> None: @@ -300,3 +301,21 @@ def on_delete_confirmed(confirmed: bool) -> None: message=f"Are you sure you want to delete the server '{server_info['name']}' ?", ) self.app.push_screen(confirmation_modal, on_delete_confirmed) + + def focus_tree(self): + try: + self._focus_tree() + except: + self.call_after_refresh(self._focus_tree) + + def _focus_tree(self): + tree = self.query_one("#servers-tree", Tree) + if tree: + tree.focus() + + def action_jump(self) -> None: + servers_tree = self.query_one("#servers-tree", Tree) + if servers_tree.has_focus and self.current_cron_table: + self.current_cron_table.focus() + else: + servers_tree.focus() \ No newline at end of file diff --git a/tests/CronCreator_test.py b/tests/CronCreator_test.py index 4ef3308..27c54cb 100644 --- a/tests/CronCreator_test.py +++ b/tests/CronCreator_test.py @@ -9,7 +9,6 @@ @pytest.mark.asyncio async def test_open_create_cronjob_modal(app: CronBoard): async with app.run_test() as pilot: - await pilot.press("tab") await pilot.press("c") assert isinstance(app.screen, CronCreator) diff --git a/tests/CronDeleteConfirmation_test.py b/tests/CronDeleteConfirmation_test.py index 994f20a..985a5b3 100644 --- a/tests/CronDeleteConfirmation_test.py +++ b/tests/CronDeleteConfirmation_test.py @@ -8,7 +8,6 @@ @pytest.mark.asyncio async def test_open_delete_cronjob_modal(app: CronBoard): async with app.run_test() as pilot: - await pilot.press("tab") await pilot.press("D") assert isinstance(app.screen, CronDeleteConfirmation) @@ -16,7 +15,6 @@ async def test_open_delete_cronjob_modal(app: CronBoard): @pytest.mark.asyncio async def test_delete_cronjob_cancel(app: CronBoard): async with app.run_test() as pilot: - await pilot.press("tab") await pilot.press("D") await pilot.press("tab") await pilot.press("enter") @@ -26,7 +24,6 @@ async def test_delete_cronjob_cancel(app: CronBoard): @pytest.mark.asyncio async def test_delete_cronjob_confirm(app: CronBoard): async with app.run_test() as pilot: - await pilot.press("tab") await pilot.press("D") await pilot.press("enter") assert not isinstance(app.screen, CronDeleteConfirmation) diff --git a/tests/CronInputSearch_test.py b/tests/CronInputSearch_test.py index 011f576..783bf12 100644 --- a/tests/CronInputSearch_test.py +++ b/tests/CronInputSearch_test.py @@ -4,7 +4,6 @@ async def search_input(pilot: Pilot): - await pilot.press("tab") await pilot.press("/") await pilot.press("c") await pilot.press("r") @@ -15,14 +14,12 @@ async def search_input(pilot: Pilot): @pytest.mark.asyncio async def test_open_search_modal(pilot: Pilot): - await pilot.press("tab") await pilot.press("/") assert isinstance(pilot.app.screen, CronInputSearch) @pytest.mark.asyncio async def test_close_search_modal(pilot: Pilot): - await pilot.press("tab") await pilot.press("/") await pilot.press("escape") assert not isinstance(pilot.app.screen, CronInputSearch) diff --git a/tests/CronServers_test.py b/tests/CronServers_test.py index f42fe26..d721602 100644 --- a/tests/CronServers_test.py +++ b/tests/CronServers_test.py @@ -1,3 +1,4 @@ +import pytest from cronboard_widgets.CronServers import CronServers diff --git a/tests/app_test.py b/tests/app_test.py index 182beb7..c586318 100644 --- a/tests/app_test.py +++ b/tests/app_test.py @@ -1,13 +1,20 @@ import pytest from cronboard.app import CronBoard - +from textual.widgets import Tree @pytest.mark.asyncio async def test_change_tab(app: CronBoard): async with app.run_test() as pilot: + assert app.tabs.active == "local" + await pilot.press("tab") - assert app.local_table.has_focus + await pilot.pause() + + assert app.tabs.active == "servers" + assert app.servers is not None + server_tree = app.servers.query_one("#servers-tree", Tree) + assert server_tree.has_focus @pytest.mark.asyncio async def test_refresh_data(app: CronBoard): From 13799b80d08a67a9a7cfd727aeeb7cc01d467b43 Mon Sep 17 00:00:00 2001 From: bcExpt1123 Date: Sun, 3 May 2026 19:52:01 -0400 Subject: [PATCH 2/2] fix: delete unnecessary code --- src/cronboard/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cronboard/app.py b/src/cronboard/app.py index 21c7adc..dbbb031 100644 --- a/src/cronboard/app.py +++ b/src/cronboard/app.py @@ -6,7 +6,7 @@ from textual import events from textual.app import App, ComposeResult from textual.binding import Binding -from textual.widgets import Footer, Label, Tabs, Tab, Tree, Button, Input, Checkbox, MaskedInput, RadioButton, Select, Switch, TextArea +from textual.widgets import Footer, Label, Tabs, Tab, Button, Input, Checkbox, MaskedInput, RadioButton, Select, Switch, TextArea from cronboard_widgets.CronTable import CronTable from textual.containers import Container from cronboard_widgets.CronTabs import CronTabs