feat: soft j/k field navigation in New Session dialog
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
|
from textual.binding import Binding
|
||||||
from textual.containers import Horizontal, Vertical
|
from textual.containers import Horizontal, Vertical
|
||||||
from textual.screen import ModalScreen
|
from textual.screen import ModalScreen
|
||||||
from textual.widgets import Button, Checkbox, Input, Label, Select, Switch
|
from textual.widgets import Button, Checkbox, Input, Label, Select, Switch
|
||||||
@@ -10,6 +11,14 @@ from hqt.sandbox import SandboxPolicy
|
|||||||
class NewSessionScreen(
|
class NewSessionScreen(
|
||||||
ModalScreen[tuple[str, str, str | None, str | None, SandboxPolicy | None] | None]
|
ModalScreen[tuple[str, str, str | None, str | None, SandboxPolicy | None] | None]
|
||||||
):
|
):
|
||||||
|
# Soft vim-style field navigation: j/k walk the dialog's focus chain. Inputs
|
||||||
|
# consume printable keys first, so j/k type literally while editing text and
|
||||||
|
# only move focus when a toggle/select/button is focused.
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("j", "focus_next", "Next field", show=False),
|
||||||
|
Binding("k", "focus_previous", "Prev field", show=False),
|
||||||
|
]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
harness_names: list[str],
|
harness_names: list[str],
|
||||||
@@ -21,6 +30,14 @@ class NewSessionScreen(
|
|||||||
self.sandbox_available = sandbox_available
|
self.sandbox_available = sandbox_available
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
|
# focus_next/focus_previous are screen methods, not binding actions, so the
|
||||||
|
# j/k bindings need these thin action_* wrappers to dispatch to them.
|
||||||
|
def action_focus_next(self) -> None:
|
||||||
|
self.focus_next()
|
||||||
|
|
||||||
|
def action_focus_previous(self) -> None:
|
||||||
|
self.focus_previous()
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with Vertical(id="new-session-dialog"):
|
with Vertical(id="new-session-dialog"):
|
||||||
yield Label("New Session")
|
yield Label("New Session")
|
||||||
|
|||||||
@@ -1913,3 +1913,54 @@ def test_dialog_focus_highlight_uses_accent():
|
|||||||
):
|
):
|
||||||
assert selector in css, f"missing focus rule: {selector}"
|
assert selector in css, f"missing focus rule: {selector}"
|
||||||
assert "border: tall $accent;" in css
|
assert "border: tall $accent;" in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_session_has_jk_focus_bindings():
|
||||||
|
"""j -> focus_next, k -> focus_previous, declared on the screen."""
|
||||||
|
from hqt.tui.screens.new_session import NewSessionScreen
|
||||||
|
|
||||||
|
actions = {b.key: b.action for b in NewSessionScreen.BINDINGS}
|
||||||
|
assert actions.get("j") == "focus_next"
|
||||||
|
assert actions.get("k") == "focus_previous"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_jk_moves_focus_between_controls():
|
||||||
|
"""On a non-text control, j moves focus to another control."""
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
from hqt.tui.screens.new_session import NewSessionScreen
|
||||||
|
from textual.widgets import Switch
|
||||||
|
|
||||||
|
app.push_screen(
|
||||||
|
NewSessionScreen(["claude"], is_git_repo=True, sandbox_available=True)
|
||||||
|
)
|
||||||
|
await pilot.pause()
|
||||||
|
switch = app.screen.query_one("#sandbox-switch", Switch)
|
||||||
|
switch.focus()
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.focused is switch
|
||||||
|
|
||||||
|
await pilot.press("j")
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.focused is not switch
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_jk_typed_into_input_is_literal():
|
||||||
|
"""Inside a text input, j/k type literally and do not move focus."""
|
||||||
|
app = HqtApp()
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
from hqt.tui.screens.new_session import NewSessionScreen
|
||||||
|
from textual.widgets import Input
|
||||||
|
|
||||||
|
app.push_screen(NewSessionScreen(["claude"], is_git_repo=True))
|
||||||
|
await pilot.pause()
|
||||||
|
nickname = app.screen.query_one("#nickname-input", Input)
|
||||||
|
nickname.focus()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
await pilot.press("j", "k")
|
||||||
|
await pilot.pause()
|
||||||
|
assert nickname.value == "jk"
|
||||||
|
assert app.focused is nickname
|
||||||
|
|||||||
Reference in New Issue
Block a user