Skip to content
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
9 changes: 9 additions & 0 deletions sqlit/core/keymap.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
"circumflex_accent": "^",
"dollar_sign": "$",
"percent_sign": "%",
"equals_sign": "=",
"minus": "-",
"plus": "+",
"space": "<space>",
"escape": "<esc>",
"enter": "<enter>",
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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),
Expand Down
22 changes: 21 additions & 1 deletion sqlit/domains/shell/app/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand All @@ -83,6 +99,8 @@

#sidebar {
width: 35;
min-width: 15;
max-width: 80;
border: round $border;
padding: 1;
margin: 0;
Expand All @@ -98,6 +116,7 @@

#query-area {
height: 50%;
min-height: 5;
border: round $border;
padding: 1;
margin: 0;
Expand All @@ -110,7 +129,8 @@
}

#results-area {
height: 50%;
height: 1fr;
min-height: 5;
padding: 1;
border: round $border;
margin: 0;
Expand Down
5 changes: 5 additions & 0 deletions sqlit/domains/shell/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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))
Expand Down
19 changes: 19 additions & 0 deletions sqlit/domains/shell/app/startup_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions sqlit/domains/shell/state/machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions sqlit/domains/shell/state/main_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="<space>", label="Commands")
Expand Down
6 changes: 6 additions & 0 deletions sqlit/domains/shell/ui/mixins/ui_leader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
149 changes: 149 additions & 0 deletions sqlit/domains/shell/ui/mixins/ui_navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -29,13 +37,34 @@ 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":
self.screen.add_class("query-fullscreen")
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
Expand Down Expand Up @@ -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)
Expand Down
Loading