From 787fd031aba65f8eda12b5a0b1f938617e60a594 Mon Sep 17 00:00:00 2001 From: Siriwat Uamngamsup Date: Wed, 15 Jul 2026 15:10:57 +0700 Subject: [PATCH] Add resizable panes via leader keys and draggable dividers Panes were fixed-size (sidebar 35 cols, query/results 50/50). Add the ability to resize both boundaries: - Leader: space = grows, space - shrinks the focused pane's boundary (explorer -> sidebar width; query/results -> the split). - Mouse: thin draggable dividers between sidebar<->main and query<->results, with a hover highlight. Chosen sizes persist across restarts (interface state, per the keybindings/aesthetics exception). Sizes are clamped and re-clamped to the terminal, and the dividers hide when a pane is maximized or the explorer is hidden. Fullscreen restores the saved sizes on exit. Per the project's keybinding philosophy we avoid ctrl+ chords: the direct resize actions (grow_sidebar/shrink_sidebar/grow_split/shrink_split) ship unbound and remain rebindable via the keymap editor, so there is no clash with the OS (e.g. macOS reserves ctrl+arrow for Spaces/Mission Control). Adds tests/ui/keybindings/test_pane_resize.py covering keyboard resize, leader-key end-to-end, mouse drag, clamps, persistence, fullscreen-hide, and blank splitter rendering. Co-Authored-By: Claude Opus 4.8 (1M context) --- sqlit/core/keymap.py | 9 + sqlit/domains/shell/app/main.css | 22 +- sqlit/domains/shell/app/main.py | 5 + sqlit/domains/shell/app/startup_flow.py | 19 + sqlit/domains/shell/state/machine.py | 1 + sqlit/domains/shell/state/main_screen.py | 6 + sqlit/domains/shell/ui/mixins/ui_leader.py | 6 + .../domains/shell/ui/mixins/ui_navigation.py | 149 +++++++ sqlit/shared/ui/widgets_pane_splitter.py | 80 ++++ tests/ui/keybindings/test_pane_resize.py | 394 ++++++++++++++++++ 10 files changed, 690 insertions(+), 1 deletion(-) create mode 100644 sqlit/shared/ui/widgets_pane_splitter.py create mode 100644 tests/ui/keybindings/test_pane_resize.py diff --git a/sqlit/core/keymap.py b/sqlit/core/keymap.py index 7f320e49..d8dcd6c1 100644 --- a/sqlit/core/keymap.py +++ b/sqlit/core/keymap.py @@ -15,6 +15,9 @@ "circumflex_accent": "^", "dollar_sign": "$", "percent_sign": "%", + "equals_sign": "=", + "minus": "-", + "plus": "+", "space": "", "escape": "", "enter": "", @@ -191,6 +194,8 @@ def _build_leader_commands(self) -> list[LeaderCommandDef]: # View LeaderCommandDef("e", "toggle_explorer", "Toggle Explorer", "View"), LeaderCommandDef("f", "toggle_fullscreen", "Toggle Maximize", "View"), + LeaderCommandDef("equals_sign", "grow_active_pane", "Grow Pane", "View"), + LeaderCommandDef("minus", "shrink_active_pane", "Shrink Pane", "View"), # Connection LeaderCommandDef("c", "show_connection_picker", "Connect", "Connection"), LeaderCommandDef("x", "disconnect", "Disconnect", "Connection", guard="has_connection"), @@ -471,6 +476,10 @@ def _build_action_keys(self) -> list[ActionKeyDef]: ActionKeyDef("e", "focus_explorer", "navigation"), ActionKeyDef("q", "focus_query", "navigation"), ActionKeyDef("r", "focus_results", "navigation"), + # Pane resize is driven by the leader menu (space =/-) and mouse drag + # by default. The grow_sidebar/shrink_sidebar/grow_split/shrink_split + # actions stay rebindable via the keymap editor for users who want + # direct keys (no default binding avoids the macOS ctrl+arrow clash). # Query (autocomplete) ActionKeyDef("ctrl+j", "autocomplete_next", "autocomplete"), ActionKeyDef("down", "autocomplete_next", "autocomplete", primary=False), diff --git a/sqlit/domains/shell/app/main.css b/sqlit/domains/shell/app/main.css index e416394e..cb3a7ced 100644 --- a/sqlit/domains/shell/app/main.css +++ b/sqlit/domains/shell/app/main.css @@ -72,6 +72,22 @@ display: none; } + Screen.explorer-fullscreen #sidebar-splitter, + Screen.explorer-fullscreen #query-results-splitter { + display: none; + } + + Screen.results-fullscreen #sidebar-splitter, + Screen.results-fullscreen #query-results-splitter, + Screen.query-fullscreen #sidebar-splitter, + Screen.query-fullscreen #query-results-splitter { + display: none; + } + + Screen.explorer-hidden #sidebar-splitter { + display: none; + } + #main-container { width: 100%; height: 100%; @@ -83,6 +99,8 @@ #sidebar { width: 35; + min-width: 15; + max-width: 80; border: round $border; padding: 1; margin: 0; @@ -98,6 +116,7 @@ #query-area { height: 50%; + min-height: 5; border: round $border; padding: 1; margin: 0; @@ -110,7 +129,8 @@ } #results-area { - height: 50%; + height: 1fr; + min-height: 5; padding: 1; border: round $border; margin: 0; diff --git a/sqlit/domains/shell/app/main.py b/sqlit/domains/shell/app/main.py index e98c4c2c..fb17f9c4 100644 --- a/sqlit/domains/shell/app/main.py +++ b/sqlit/domains/shell/app/main.py @@ -61,6 +61,7 @@ SqlitDataTable, TreeFilterInput, ) +from sqlit.shared.ui.widgets_pane_splitter import PaneSplitter from sqlit.shared.ui.widgets_stacked_results import StackedResultsContainer if TYPE_CHECKING: @@ -1145,6 +1146,8 @@ def compose(self) -> ComposeResult: tree.guide_depth = 2 yield tree + yield PaneSplitter(id="sidebar-splitter", orientation="vertical") + with Vertical(id="main-panel"): with Container(id="query-area"): yield QueryTextArea( @@ -1156,6 +1159,8 @@ def compose(self) -> ComposeResult: ) yield Lazy(AutocompleteDropdown(id="autocomplete-dropdown")) + yield PaneSplitter(id="query-results-splitter", orientation="horizontal") + with Container(id="results-area"): yield ResultsFilterInput(id="results-filter") yield Lazy(SqlitDataTable(id="results-table", zebra_stripes=True, show_header=False)) diff --git a/sqlit/domains/shell/app/startup_flow.py b/sqlit/domains/shell/app/startup_flow.py index 27c147dd..786cf690 100644 --- a/sqlit/domains/shell/app/startup_flow.py +++ b/sqlit/domains/shell/app/startup_flow.py @@ -74,6 +74,7 @@ def run_on_mount(app: AppProtocol) -> None: mode = parse_alert_mode(settings.get("query_alert_mode")) if mode is not None: app.services.runtime.query_alert_mode = int(mode) + _apply_persisted_pane_sizes(app, settings) app._startup_stamp("settings_applied") apply_mock_settings(app, settings) @@ -133,6 +134,24 @@ def _connect_startup() -> None: log_startup_timing(app) +def _apply_persisted_pane_sizes(app: AppProtocol, settings: dict) -> None: + """Restore user-chosen pane sizes saved from a previous session.""" + def _as_int(value: object) -> int | None: + try: + return int(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return None + + app._sidebar_width = _as_int(settings.get("sidebar_width")) + app._query_height = _as_int(settings.get("query_area_height")) + applier = getattr(app, "apply_persisted_pane_sizes", None) + if callable(applier): + try: + applier() + except Exception: + pass + + def _warn_on_missing_actions(app: AppProtocol, is_headless: bool) -> None: from sqlit.core.action_validation import validate_actions diff --git a/sqlit/domains/shell/state/machine.py b/sqlit/domains/shell/state/machine.py index dc146c35..98a9c8eb 100644 --- a/sqlit/domains/shell/state/machine.py +++ b/sqlit/domains/shell/state/machine.py @@ -204,6 +204,7 @@ def lk(action: str, menu: str, fallback: str) -> str: s.binding(k("focus_explorer", "e"), "Focus Explorer pane") s.binding(k("focus_query", "q"), "Focus Query pane") s.binding(k("focus_results", "r"), "Focus Results pane") + s.binding(f"{leader_key}{lk('grow_active_pane', 'leader', '=')}/{lk('shrink_active_pane', 'leader', '-')}", "Grow/shrink focused pane (or drag divider)") s.binding(leader_key, "Open command menu") s.binding(k("show_help", "?"), "Show this help") sections.append(s) diff --git a/sqlit/domains/shell/state/main_screen.py b/sqlit/domains/shell/state/main_screen.py index 608e3e52..427abcd5 100644 --- a/sqlit/domains/shell/state/main_screen.py +++ b/sqlit/domains/shell/state/main_screen.py @@ -19,6 +19,12 @@ def _setup_actions(self) -> None: self.allows("show_connection_picker") self.allows("disconnect", guard=lambda app: app.has_connection) self.allows("toggle_fullscreen", help="Toggle fullscreen") + self.allows("grow_active_pane", help="Grow pane") + self.allows("shrink_active_pane", help="Shrink pane") + self.allows("grow_sidebar", help="Widen sidebar") + self.allows("shrink_sidebar", help="Narrow sidebar") + self.allows("grow_split", help="Grow query pane") + self.allows("shrink_split", help="Grow results pane") self.allows("change_theme") self.allows("toggle_process_worker", help="Toggle process worker") self.allows("leader_key", key="", label="Commands") diff --git a/sqlit/domains/shell/ui/mixins/ui_leader.py b/sqlit/domains/shell/ui/mixins/ui_leader.py index 16f024ac..031b581f 100644 --- a/sqlit/domains/shell/ui/mixins/ui_leader.py +++ b/sqlit/domains/shell/ui/mixins/ui_leader.py @@ -94,6 +94,12 @@ def action_leader_toggle_explorer(self: UINavigationMixinHost) -> None: def action_leader_toggle_fullscreen(self: UINavigationMixinHost) -> None: self._execute_leader_command("toggle_fullscreen") + def action_leader_grow_active_pane(self: UINavigationMixinHost) -> None: + self._execute_leader_command("grow_active_pane") + + def action_leader_shrink_active_pane(self: UINavigationMixinHost) -> None: + self._execute_leader_command("shrink_active_pane") + def action_leader_show_connection_picker(self: UINavigationMixinHost) -> None: self._execute_leader_command("show_connection_picker") diff --git a/sqlit/domains/shell/ui/mixins/ui_navigation.py b/sqlit/domains/shell/ui/mixins/ui_navigation.py index e0833228..8354d816 100644 --- a/sqlit/domains/shell/ui/mixins/ui_navigation.py +++ b/sqlit/domains/shell/ui/mixins/ui_navigation.py @@ -21,6 +21,14 @@ class UINavigationMixin(UIStatusMixin, UILeaderMixin): _notification_timer: Timer | None = None _leader_timer: Timer | None = None _last_active_pane: str | None = None + _sidebar_width: int | None = None + _query_height: int | None = None + + # Pane resize bounds (cells) + _SIDEBAR_MIN = 15 + _SIDEBAR_MAX = 80 + _QUERY_MIN = 5 + _RESIZE_STEP = 4 def _set_fullscreen_mode(self: UINavigationMixinHost, mode: str) -> None: """Set fullscreen mode: none|explorer|query|results.""" @@ -29,6 +37,16 @@ def _set_fullscreen_mode(self: UINavigationMixinHost, mode: str) -> None: self.screen.remove_class("query-fullscreen") self.screen.remove_class("explorer-fullscreen") + if mode == "none": + # Restore any user-chosen pane sizes; inline styles were cleared + # when entering fullscreen so the fullscreen CSS rules could win. + self.apply_persisted_pane_sizes() + else: + # Inline .styles beat stylesheet rules, so a resized pane would + # ignore the fullscreen layout. Drop the inline sizes while + # maximized; they are re-applied on the way back to "none". + self._clear_inline_pane_sizes() + if mode == "results": self.screen.add_class("results-fullscreen") elif mode == "query": @@ -36,6 +54,17 @@ def _set_fullscreen_mode(self: UINavigationMixinHost, mode: str) -> None: elif mode == "explorer": self.screen.add_class("explorer-fullscreen") + def _clear_inline_pane_sizes(self: UINavigationMixinHost) -> None: + """Remove inline width/height so fullscreen CSS rules apply cleanly.""" + try: + self.sidebar.styles.clear_rule("width") + except Exception: + pass + try: + self.query_area.styles.clear_rule("height") + except Exception: + pass + def action_focus_explorer(self: UINavigationMixinHost) -> None: """Focus the Explorer pane.""" self._clear_count_buffer() # Clear any pending count prefix @@ -164,6 +193,126 @@ def action_toggle_fullscreen(self: UINavigationMixinHost) -> None: self._update_section_labels() self._update_footer_bindings() + # ======================================================================== + # Pane resizing + # ======================================================================== + + def _pane_cells(self: UINavigationMixinHost, widget: Any, dim: str, fallback: int) -> int: + """Return the current size of a pane dimension in cells. + + Reads the inline style value when it is already expressed in cells, + otherwise falls back to the rendered size (handles the initial CSS + rules that use ``%``/``fr`` units). + """ + from textual.css.scalar import Unit + + scalar = getattr(widget.styles, dim, None) + if scalar is not None and getattr(scalar, "unit", None) == Unit.CELLS: + try: + return int(scalar.value) + except (TypeError, ValueError): + pass + size = getattr(widget, "size", None) + measured = getattr(size, dim, None) if size is not None else None + try: + measured = int(measured) + except (TypeError, ValueError): + measured = 0 + return measured if measured > 0 else fallback + + def _resize_step_amount(self: UINavigationMixinHost, base: int) -> int: + """Multiply the base step by any pending vim count prefix.""" + count = self._get_and_clear_count() or 1 + return base * count + + def _resize_sidebar(self: UINavigationMixinHost, delta: int, *, persist: bool = True) -> None: + """Grow/shrink the explorer sidebar width by ``delta`` cells.""" + if self._fullscreen_mode != "none" or self.screen.has_class("explorer-hidden"): + return + current = self._pane_cells(self.sidebar, "width", 35) + new = max(self._SIDEBAR_MIN, min(self._SIDEBAR_MAX, current + delta)) + if new == current: + return + self.sidebar.styles.width = new + self._sidebar_width = new + if persist: + self._persist_pane_sizes() + + def _resize_split(self: UINavigationMixinHost, delta: int, *, persist: bool = True) -> None: + """Move the query/results split by ``delta`` cells (query pane height).""" + if self._fullscreen_mode != "none": + return + panel = getattr(self.main_panel, "size", None) + panel_height = int(getattr(panel, "height", 0) or 0) + upper = max(self._QUERY_MIN, panel_height - self._QUERY_MIN) + current = self._pane_cells(self.query_area, "height", 10) + new = max(self._QUERY_MIN, min(upper, current + delta)) + if new == current: + return + self.query_area.styles.height = new + self._query_height = new + if persist: + self._persist_pane_sizes() + + def _resize_active_pane(self: UINavigationMixinHost, delta: int) -> None: + """Resize whichever boundary belongs to the focused pane.""" + if self.object_tree.has_focus: + self._resize_sidebar(delta) + else: + self._resize_split(delta) + + def action_grow_active_pane(self: UINavigationMixinHost) -> None: + """Grow the focused pane (leader command).""" + self._resize_active_pane(self._RESIZE_STEP) + + def action_shrink_active_pane(self: UINavigationMixinHost) -> None: + """Shrink the focused pane (leader command).""" + self._resize_active_pane(-self._RESIZE_STEP) + + def action_grow_sidebar(self: UINavigationMixinHost) -> None: + """Widen the explorer sidebar.""" + self._resize_sidebar(self._resize_step_amount(self._RESIZE_STEP)) + + def action_shrink_sidebar(self: UINavigationMixinHost) -> None: + """Narrow the explorer sidebar.""" + self._resize_sidebar(self._resize_step_amount(-self._RESIZE_STEP)) + + def action_grow_split(self: UINavigationMixinHost) -> None: + """Give the query pane more height (results shrinks).""" + self._resize_split(self._resize_step_amount(self._RESIZE_STEP)) + + def action_shrink_split(self: UINavigationMixinHost) -> None: + """Give the results pane more height (query shrinks).""" + self._resize_split(self._resize_step_amount(-self._RESIZE_STEP)) + + def _persist_pane_sizes(self: UINavigationMixinHost) -> None: + """Save the current pane sizes so they survive a restart.""" + try: + store = self.services.settings_store + if self._sidebar_width is not None: + store.set("sidebar_width", self._sidebar_width) + if self._query_height is not None: + store.set("query_area_height", self._query_height) + except Exception: + pass + + def apply_persisted_pane_sizes(self: UINavigationMixinHost) -> None: + """Re-apply stored pane sizes on startup, clamped to current bounds.""" + width = self._sidebar_width + if width is not None: + clamped = max(self._SIDEBAR_MIN, min(self._SIDEBAR_MAX, int(width))) + self._sidebar_width = clamped + try: + self.sidebar.styles.width = clamped + except Exception: + pass + height = self._query_height + if height is not None: + try: + self.query_area.styles.height = max(self._QUERY_MIN, int(height)) + except Exception: + pass + def action_quit(self: UINavigationMixinHost) -> None: """Quit the application.""" close_worker = getattr(self, "_close_process_worker_client", None) diff --git a/sqlit/shared/ui/widgets_pane_splitter.py b/sqlit/shared/ui/widgets_pane_splitter.py new file mode 100644 index 00000000..d90824dc --- /dev/null +++ b/sqlit/shared/ui/widgets_pane_splitter.py @@ -0,0 +1,80 @@ +"""Draggable pane splitter widget for resizing sidebar and query/results panes.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from rich.text import Text +from textual.widget import Widget + +if TYPE_CHECKING: + from textual import events + from textual.app import RenderResult + + +class PaneSplitter(Widget): + """A thin draggable divider that resizes an adjacent pane on mouse drag. + + A ``vertical`` splitter resizes the explorer sidebar width; a ``horizontal`` + splitter resizes the query/results split height. The host app supplies the + ``_resize_sidebar`` / ``_resize_split`` / ``_persist_pane_sizes`` methods. + """ + + DEFAULT_CSS = """ + PaneSplitter { + background: $panel-lighten-1; + } + + PaneSplitter:hover { + background: $accent; + } + + PaneSplitter.-vertical { + width: 1; + height: 1fr; + } + + PaneSplitter.-horizontal { + height: 1; + width: 1fr; + } + """ + + def __init__(self, *, orientation: str, id: str | None = None) -> None: + super().__init__(id=id) + self.orientation = orientation + self._dragging = False + self.add_class("-vertical" if orientation == "vertical" else "-horizontal") + + def render(self) -> RenderResult: + # Blank: the divider is a solid bar drawn by its CSS background. Without + # this override, Textual's default Widget.render() shows the widget's + # CSS identifier as placeholder text. + return Text("") + + def on_mouse_down(self, event: events.MouseDown) -> None: + self.capture_mouse() + self._dragging = True + event.stop() + + def on_mouse_move(self, event: events.MouseMove) -> None: + if not self._dragging: + return + if self.orientation == "vertical": + resize = getattr(self.app, "_resize_sidebar", None) + if callable(resize): + resize(int(event.delta_x), persist=False) + else: + resize = getattr(self.app, "_resize_split", None) + if callable(resize): + resize(int(event.delta_y), persist=False) + event.stop() + + def on_mouse_up(self, event: events.MouseUp) -> None: + if self._dragging: + self._dragging = False + self.release_mouse() + persist = getattr(self.app, "_persist_pane_sizes", None) + if callable(persist): + persist() + event.stop() diff --git a/tests/ui/keybindings/test_pane_resize.py b/tests/ui/keybindings/test_pane_resize.py new file mode 100644 index 00000000..b72ce3b8 --- /dev/null +++ b/tests/ui/keybindings/test_pane_resize.py @@ -0,0 +1,394 @@ +"""UI tests for pane resize keybindings and persistence.""" + +from __future__ import annotations + +import pytest + +from sqlit.core.keymap import get_keymap +from sqlit.domains.shell.app.main import SSMSTUI +from sqlit.shared.ui.widgets_pane_splitter import PaneSplitter + +from ..mocks import MockConnectionStore, MockSettingsStore, build_test_services + + +class _FakeMouseEvent: + """Minimal stand-in for a textual Mouse* event used to drive splitters.""" + + def __init__(self, delta_x: int = 0, delta_y: int = 0) -> None: + self.delta_x = delta_x + self.delta_y = delta_y + self.stopped = False + + def stop(self) -> None: + self.stopped = True + + +def _make_app(settings: dict | None = None) -> SSMSTUI: + services = build_test_services( + connection_store=MockConnectionStore(), + settings_store=MockSettingsStore({"theme": "tokyo-night", **(settings or {})}), + ) + return SSMSTUI(services=services) + + +def _width(app: SSMSTUI) -> int | None: + scalar = app.sidebar.styles.width + return int(scalar.value) if scalar is not None else None + + +def _query_height(app: SSMSTUI) -> int | None: + scalar = app.query_area.styles.height + return int(scalar.value) if scalar is not None else None + + +class TestPaneResizeRegistration: + def test_leader_actions_registered_in_keymap(self) -> None: + km = get_keymap() + leader = {c.action for c in km.get_leader_commands()} + assert {"grow_active_pane", "shrink_active_pane"} <= leader + + def test_direct_resize_keys_unbound_by_default(self) -> None: + # ctrl+arrow clashes with macOS Spaces/Mission Control, so the direct + # resize actions ship with no default key (still rebindable). + km = get_keymap() + actions = {a.action for a in km.get_action_keys()} + assert not ({"grow_sidebar", "shrink_sidebar", "grow_split", "shrink_split"} & actions) + + def test_action_methods_exist(self) -> None: + for name in ( + "action_grow_sidebar", + "action_shrink_sidebar", + "action_grow_split", + "action_shrink_split", + "action_grow_active_pane", + "action_shrink_active_pane", + ): + assert hasattr(SSMSTUI, name), name + + +class TestSidebarResize: + @pytest.mark.asyncio + async def test_grow_and_shrink(self) -> None: + app = _make_app() + async with app.run_test(size=(120, 40)) as pilot: + app.action_focus_explorer() + await pilot.pause() + start = _width(app) or 35 + + app.action_grow_sidebar() + await pilot.pause() + assert _width(app) == start + app._RESIZE_STEP + + app.action_shrink_sidebar() + await pilot.pause() + assert _width(app) == start + + @pytest.mark.asyncio + async def test_clamped_at_bounds(self) -> None: + app = _make_app() + async with app.run_test(size=(160, 40)) as pilot: + app.action_focus_explorer() + await pilot.pause() + for _ in range(40): + app.action_grow_sidebar() + await pilot.pause() + assert _width(app) == app._SIDEBAR_MAX + for _ in range(40): + app.action_shrink_sidebar() + await pilot.pause() + assert _width(app) == app._SIDEBAR_MIN + + @pytest.mark.asyncio + async def test_count_prefix_multiplies_step(self) -> None: + app = _make_app() + async with app.run_test(size=(120, 40)) as pilot: + app.action_focus_explorer() + await pilot.pause() + start = _width(app) or 35 + app._count_buffer = "3" + app.action_grow_sidebar() + await pilot.pause() + assert _width(app) == start + 3 * app._RESIZE_STEP + + +class TestSplitResize: + @pytest.mark.asyncio + async def test_query_height_changes_results_absorbs(self) -> None: + app = _make_app() + async with app.run_test(size=(120, 40)) as pilot: + app.action_focus_query() + await pilot.pause() + # Default height is a % rule; measure the rendered cell height. + before_cells = app.query_area.size.height + + app.action_grow_split() + await pilot.pause() + # After resizing, height is pinned in cells and larger than before. + scalar = app.query_area.styles.height + assert scalar is not None and scalar.unit.name == "CELLS" + assert int(scalar.value) == before_cells + app._RESIZE_STEP + # results-area stays flexible (fr), never pinned to cells + results_scalar = app.results_area.styles.height + assert results_scalar is None or results_scalar.unit.name != "CELLS" + + +class TestLeaderKeyResize: + @pytest.mark.asyncio + async def test_space_equals_and_minus_resize_focused_pane(self) -> None: + # End-to-end through the leader menu. '=' / '-' are Textual key names + # equals_sign / minus; a raw "=" binding would never match the event. + app = _make_app() + async with app.run_test(size=(120, 40)) as pilot: + app.action_focus_explorer() + await pilot.pause() + start = _width(app) or 35 + + await pilot.press("space") + await pilot.pause() + await pilot.press("=") + await pilot.pause() + assert _width(app) == start + app._RESIZE_STEP + + await pilot.press("space") + await pilot.pause() + await pilot.press("-") + await pilot.pause() + assert _width(app) == start + + +class TestActivePaneResize: + @pytest.mark.asyncio + async def test_leader_targets_focused_boundary(self) -> None: + app = _make_app() + async with app.run_test(size=(120, 40)) as pilot: + app.action_focus_explorer() + await pilot.pause() + start = _width(app) or 35 + app.action_grow_active_pane() + await pilot.pause() + assert _width(app) == start + app._RESIZE_STEP + + app.action_focus_query() + await pilot.pause() + width_after = _width(app) + app.action_grow_active_pane() + await pilot.pause() + # sidebar untouched when query focused + assert _width(app) == width_after + + +class TestPersistence: + @pytest.mark.asyncio + async def test_resize_persists_to_settings(self) -> None: + app = _make_app() + async with app.run_test(size=(120, 40)) as pilot: + app.action_focus_explorer() + await pilot.pause() + app.action_grow_sidebar() + await pilot.pause() + assert app.services.settings_store.get("sidebar_width") == _width(app) + + @pytest.mark.asyncio + async def test_persisted_width_reapplied_on_startup(self) -> None: + app = _make_app({"sidebar_width": 50}) + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + assert _width(app) == 50 + + @pytest.mark.asyncio + async def test_persisted_width_clamped_on_startup(self) -> None: + app = _make_app({"sidebar_width": 9999}) + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + assert _width(app) == app._SIDEBAR_MAX + + +class TestSplitterRender: + @pytest.mark.asyncio + async def test_splitters_render_blank_not_css_identifier(self) -> None: + # Regression: a bare Widget.render() leaks the CSS identifier as text. + app = _make_app() + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + for sid in ("#sidebar-splitter", "#query-results-splitter"): + splitter = app.query_one(sid, PaneSplitter) + rendered = splitter.render() + assert str(getattr(rendered, "plain", rendered)) == "" + + +class TestMouseDragResize: + @pytest.mark.asyncio + async def test_vertical_drag_grows_sidebar_and_persists(self) -> None: + app = _make_app() + async with app.run_test(size=(120, 40)) as pilot: + app.action_focus_explorer() + await pilot.pause() + splitter = app.query_one("#sidebar-splitter", PaneSplitter) + start = _width(app) or 35 + + splitter.on_mouse_down(_FakeMouseEvent()) + await pilot.pause() + assert splitter._dragging is True + + splitter.on_mouse_move(_FakeMouseEvent(delta_x=6)) + await pilot.pause() + # positive delta_x widens the sidebar + assert _width(app) == start + 6 + + splitter.on_mouse_up(_FakeMouseEvent()) + await pilot.pause() + # drag flag resets and the final size is persisted + assert splitter._dragging is False + assert app.services.settings_store.get("sidebar_width") == _width(app) + + @pytest.mark.asyncio + async def test_vertical_drag_clamps_at_max(self) -> None: + app = _make_app() + async with app.run_test(size=(160, 40)) as pilot: + app.action_focus_explorer() + await pilot.pause() + splitter = app.query_one("#sidebar-splitter", PaneSplitter) + splitter._dragging = True + splitter.on_mouse_move(_FakeMouseEvent(delta_x=9999)) + await pilot.pause() + assert _width(app) == app._SIDEBAR_MAX + + @pytest.mark.asyncio + async def test_horizontal_drag_grows_query_height(self) -> None: + app = _make_app() + async with app.run_test(size=(120, 40)) as pilot: + app.action_focus_query() + await pilot.pause() + splitter = app.query_one("#query-results-splitter", PaneSplitter) + before_cells = app.query_area.size.height + + splitter._dragging = True + splitter.on_mouse_move(_FakeMouseEvent(delta_y=5)) + await pilot.pause() + scalar = app.query_area.styles.height + assert scalar is not None and scalar.unit.name == "CELLS" + assert int(scalar.value) == before_cells + 5 + + splitter.on_mouse_up(_FakeMouseEvent()) + await pilot.pause() + assert splitter._dragging is False + assert app.services.settings_store.get("query_area_height") == _query_height(app) + + @pytest.mark.asyncio + async def test_move_without_drag_is_ignored(self) -> None: + app = _make_app() + async with app.run_test(size=(120, 40)) as pilot: + app.action_focus_explorer() + await pilot.pause() + splitter = app.query_one("#sidebar-splitter", PaneSplitter) + start = _width(app) or 35 + # no mouse-down yet -> not dragging -> ignored + splitter.on_mouse_move(_FakeMouseEvent(delta_x=6)) + await pilot.pause() + assert _width(app) == start + + @pytest.mark.asyncio + async def test_mouse_up_without_drag_is_a_noop(self) -> None: + app = _make_app() + async with app.run_test(size=(120, 40)) as pilot: + app.action_focus_explorer() + await pilot.pause() + splitter = app.query_one("#sidebar-splitter", PaneSplitter) + assert splitter._dragging is False + event = _FakeMouseEvent() + # A stray mouse-up (no preceding mouse-down) must not persist, + # release the (uncaptured) mouse, or consume the event. + splitter.on_mouse_up(event) + await pilot.pause() + assert splitter._dragging is False + assert event.stopped is False + assert app.services.settings_store.get("sidebar_width") is None + + @pytest.mark.asyncio + async def test_horizontal_drag_clamps_at_min(self) -> None: + app = _make_app() + async with app.run_test(size=(120, 40)) as pilot: + app.action_focus_query() + await pilot.pause() + splitter = app.query_one("#query-results-splitter", PaneSplitter) + splitter._dragging = True + # A large negative delta_y shrinks the query pane past its floor. + splitter.on_mouse_move(_FakeMouseEvent(delta_y=-9999)) + await pilot.pause() + assert _query_height(app) == app._QUERY_MIN + + +class TestFullscreenInteraction: + @pytest.mark.asyncio + async def test_inline_width_cleared_in_fullscreen_restored_after(self) -> None: + app = _make_app() + async with app.run_test(size=(120, 40)) as pilot: + app.action_focus_explorer() + await pilot.pause() + app.action_grow_sidebar() + await pilot.pause() + resized = _width(app) + + app._set_fullscreen_mode("explorer") + await pilot.pause() + # inline width dropped so fullscreen CSS (width: 1fr) wins + assert app.sidebar.styles.width is None or app.sidebar.styles.width.unit.name != "CELLS" + + app._set_fullscreen_mode("none") + await pilot.pause() + assert _width(app) == resized + + @pytest.mark.asyncio + async def test_splitters_hidden_in_fullscreen_modes(self) -> None: + app = _make_app() + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + sidebar_splitter = app.query_one("#sidebar-splitter") + query_splitter = app.query_one("#query-results-splitter") + + # Both splitters visible in the normal layout. + assert sidebar_splitter.display is True + assert query_splitter.display is True + + # explorer-fullscreen: main-panel (and its splitter) gone, + # sidebar-splitter hidden too since there is nothing to divide. + app._set_fullscreen_mode("explorer") + await pilot.pause() + assert sidebar_splitter.display is False + assert query_splitter.display is False + + # results-fullscreen: sidebar is hidden, so its splitter must be too. + app._set_fullscreen_mode("results") + await pilot.pause() + assert sidebar_splitter.display is False + assert query_splitter.display is False + + # query-fullscreen: same expectation. + app._set_fullscreen_mode("query") + await pilot.pause() + assert sidebar_splitter.display is False + assert query_splitter.display is False + + # Back to normal: both splitters reappear. + app._set_fullscreen_mode("none") + await pilot.pause() + assert sidebar_splitter.display is True + assert query_splitter.display is True + + @pytest.mark.asyncio + async def test_sidebar_splitter_hidden_when_explorer_hidden(self) -> None: + app = _make_app() + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + sidebar_splitter = app.query_one("#sidebar-splitter") + assert sidebar_splitter.display is True + + app.action_toggle_explorer() + await pilot.pause() + assert app.screen.has_class("explorer-hidden") + assert sidebar_splitter.display is False + + app.action_toggle_explorer() + await pilot.pause() + assert not app.screen.has_class("explorer-hidden") + assert sidebar_splitter.display is True