Skip to content

Commit fc37b49

Browse files
authored
Merge pull request #39 from darrenburns/dynamic-help
Dynamic help
2 parents 147535f + 8b44a90 commit fc37b49

16 files changed

+371
-23
lines changed

README.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,15 +101,23 @@ Some additional keyboard shortcuts are shown in the table below.
101101
| Copy selection to clipboard | <kbd>y</kbd> or <kbd>c</kbd> | When response body text area is focused |
102102
| Open in pager | <kbd>f3</kbd> | When a text area is focused |
103103
| Open in external editor | <kbd>f4</kbd> | When a text area is focused |
104+
| Show contextual help | <kbd>f1</kbd> or <kbd>ctrl</kbd>+<kbd>?</kbd> (or <kbd>ctrl</kbd>+<kbd>shift</kbd>+<kbd>/</kbd>) | When a widget is focused |
104105

105106
</details>
106107

107108
> [!TIP]
108109
> Many parts of the UI support Vim keys for navigation.
109110

110-
### Exiting Posting TUI
111+
### Contextual help
111112

112-
Press `Ctrl`+`c` to quit the TUI.
113+
Many widgets have additional bindings beyond those displayed in the footer. You can view the full list of keybindings for the currently
114+
focused widget, as well as additional usage information and tips, by pressing <kbd>f1</kbd> or <kbd>ctrl</kbd>+<kbd>?</kbd> (or <kbd>ctrl</kbd>+<kbd>shift</kbd>+<kbd>/</kbd>).
115+
116+
<img width="1229" alt="image" src="https://github.com/user-attachments/assets/707be55f-6dfc-4faf-b9f3-fe7bc5422008">
117+
118+
### Exiting
119+
120+
Press <kbd>ctrl</kbd>+<kbd>c</kbd> to quit Posting.
113121

114122
## Environments
115123

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "posting"
3-
version = "1.2.0"
3+
version = "1.3.0"
44
description = "The modern API client that lives in your terminal."
55
authors = [
66
{ name = "Darren Burns", email = "[email protected]" }

src/posting/app.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333

3434
from posting.commands import PostingProvider
3535
from posting.config import SETTINGS, Settings
36+
from posting.help_screen import HelpScreen
3637
from posting.jump_overlay import JumpOverlay
3738
from posting.jumper import Jumper
3839
from posting.types import PostingLayout
@@ -102,6 +103,7 @@ class AppBody(Vertical):
102103

103104

104105
class MainScreen(Screen[None]):
106+
AUTO_FOCUS = "UrlInput"
105107
BINDINGS = [
106108
Binding("ctrl+j", "send_request", "Send"),
107109
Binding("ctrl+t", "change_method", "Method"),
@@ -117,8 +119,6 @@ class MainScreen(Screen[None]):
117119
),
118120
]
119121

120-
AUTO_FOCUS = "UrlInput"
121-
122122
selected_method: Reactive[HttpRequestMethod] = reactive("GET", init=False)
123123
"""The currently selected method of the request."""
124124
layout: Reactive[PostingLayout] = reactive("vertical", init=False)
@@ -550,14 +550,13 @@ class Posting(PostingApp):
550550
"ctrl+p",
551551
"command_palette",
552552
description="Commands",
553-
show=True,
554553
),
555554
Binding(
556555
"ctrl+o",
557556
"toggle_jump_mode",
558557
description="Jump",
559-
show=True,
560558
),
559+
Binding("f1,ctrl+question_mark", "help", "Help"),
561560
]
562561

563562
themes: dict[str, ColorSystem] = {
@@ -802,3 +801,13 @@ def handle_jump_target(target: str | Widget | None) -> None:
802801

803802
self.clear_notifications()
804803
await self.push_screen(JumpOverlay(self.jumper), callback=handle_jump_target)
804+
805+
async def action_help(self) -> None:
806+
focused = self.focused
807+
808+
def reset_focus(_) -> None:
809+
if focused:
810+
self.screen.set_focus(focused)
811+
812+
self.set_focus(None)
813+
await self.push_screen(HelpScreen(widget=focused), callback=reset_focus)

src/posting/help_screen.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
from dataclasses import dataclass, field
2+
from typing import Protocol, runtime_checkable
3+
from rich.text import Text
4+
from textual.app import ComposeResult
5+
from textual.binding import Binding
6+
from textual.containers import Vertical, VerticalScroll
7+
from textual.screen import ModalScreen
8+
from textual.widget import Widget
9+
from textual.widgets import Label, Markdown
10+
11+
from posting.widgets.datatable import PostingDataTable
12+
13+
14+
@dataclass
15+
class HelpData:
16+
"""Data relating to the widget to be displayed in the HelpScreen"""
17+
18+
title: str = field(default="")
19+
"""Title of the widget"""
20+
description: str = field(default="")
21+
"""Markdown description to be displayed in the HelpScreen"""
22+
23+
24+
@runtime_checkable
25+
class Helpable(Protocol):
26+
"""Widgets which contain information to be displayed in the HelpScreen
27+
should implement this protocol."""
28+
29+
help: HelpData
30+
31+
32+
class HelpModalHeader(Label):
33+
"""The top help bar"""
34+
35+
DEFAULT_CSS = """
36+
HelpModalHeader {
37+
background: $background-lighten-1;
38+
color: $text-muted;
39+
}
40+
"""
41+
42+
43+
class HelpModalFooter(Label):
44+
"""The bottom help bar"""
45+
46+
DEFAULT_CSS = """
47+
HelpModalFooter {
48+
background: $background-lighten-1;
49+
color: $text-muted;
50+
}
51+
"""
52+
53+
54+
class HelpModalFocusNote(Label):
55+
"""A note below the help screen."""
56+
57+
58+
class HelpScreen(ModalScreen[None]):
59+
DEFAULT_CSS = """
60+
HelpScreen {
61+
align: center middle;
62+
& > VerticalScroll {
63+
background: $background;
64+
padding: 1 2;
65+
width: 65%;
66+
height: 80%;
67+
border: wide $background-lighten-2;
68+
border-title-color: $text;
69+
border-title-background: $background;
70+
border-title-style: bold;
71+
}
72+
73+
& DataTable#bindings-table {
74+
width: 1fr;
75+
height: 1fr;
76+
}
77+
78+
& HelpModalHeader {
79+
dock: top;
80+
width: 1fr;
81+
content-align: center middle;
82+
}
83+
84+
#footer-area {
85+
dock: bottom;
86+
height: auto;
87+
margin-top: 1;
88+
& HelpModalFocusNote {
89+
width: 1fr;
90+
content-align: center middle;
91+
color: $text-muted 40%;
92+
}
93+
94+
& HelpModalFooter {
95+
width: 1fr;
96+
content-align: center middle;
97+
}
98+
}
99+
100+
101+
& #bindings-title {
102+
width: 1fr;
103+
content-align: center middle;
104+
background: $background-lighten-1;
105+
color: $text-muted;
106+
}
107+
108+
& #help-description-wrapper {
109+
dock: top;
110+
max-height: 50%;
111+
margin-top: 1;
112+
height: auto;
113+
width: 1fr;
114+
& #help-description {
115+
margin: 0;
116+
width: 1fr;
117+
height: auto;
118+
}
119+
}
120+
}
121+
"""
122+
123+
BINDINGS = [
124+
Binding("escape", "dismiss('')", "Close Help"),
125+
]
126+
127+
def __init__(
128+
self,
129+
widget: Widget,
130+
name: str | None = None,
131+
id: str | None = None,
132+
classes: str | None = None,
133+
) -> None:
134+
super().__init__(name, id, classes)
135+
self.widget = widget
136+
137+
def compose(self) -> ComposeResult:
138+
with VerticalScroll() as vs:
139+
vs.can_focus = False
140+
widget = self.widget
141+
# If the widget has help text, render it.
142+
if isinstance(widget, Helpable):
143+
help = widget.help
144+
help_title = help.title
145+
vs.border_title = f"[not bold]Focused Widget Help ([b]{help_title}[/])"
146+
if help_title:
147+
yield HelpModalHeader(f"[b]{help_title}[/]")
148+
help_markdown = help.description
149+
150+
if help_markdown:
151+
help_markdown = help_markdown.strip()
152+
with VerticalScroll(id="help-description-wrapper") as vs:
153+
yield Markdown(help_markdown, id="help-description")
154+
else:
155+
yield Label(
156+
f"No help available for {help.title}",
157+
id="help-description",
158+
)
159+
else:
160+
name = widget.__class__.__name__
161+
vs.border_title = f"Focused Widget Help ([b]{name}[/])"
162+
yield HelpModalHeader(f"[b]{name}[/] Help")
163+
164+
bindings = widget._bindings
165+
keys: list[tuple[str, Binding]] = [
166+
binding for binding in bindings.keys.items()
167+
]
168+
169+
if keys:
170+
yield Label(" [b]All Keybindings[/]", id="bindings-title")
171+
table = PostingDataTable(
172+
id="bindings-table",
173+
cursor_type="row",
174+
zebra_stripes=True,
175+
)
176+
table.cursor_vertical_escape = False
177+
table.add_columns("Key", "Description")
178+
for key, binding in keys:
179+
table.add_row(
180+
Text(
181+
binding.key_display or self.app.get_key_display(key),
182+
style="bold",
183+
no_wrap=True,
184+
end="",
185+
),
186+
binding.description.lower(),
187+
)
188+
yield table
189+
190+
with Vertical(id="footer-area"):
191+
yield HelpModalFooter("Press [b]ESC[/] to dismiss.")
192+
yield HelpModalFocusNote(
193+
"[b]Note:[/] This page relates to the widget that is currently focused."
194+
)

src/posting/jump_overlay.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class JumpOverlay(ModalScreen[str | Widget]):
2222
"""
2323

2424
BINDINGS = [
25-
Binding("escape,ctrl+o", "dismiss_overlay", "Dismiss", show=False),
25+
Binding("escape", "dismiss_overlay", "Dismiss", show=False),
2626
]
2727

2828
def __init__(
@@ -40,11 +40,19 @@ def __init__(
4040
def on_mount(self) -> None:
4141
self._sync()
4242

43-
def on_key(self, key: events.Key) -> None:
44-
# If they press a key corresponding to a jump target,
45-
# then we jump to it.
43+
def on_key(self, key_event: events.Key) -> None:
44+
# We need to stop the bubbling of these keys, because if they
45+
# arrive at the parent after the overlay is closed, then the parent
46+
# will handle the key event, resulting in the focus being shifted
47+
# again (unexpectedly) after the jump target was focused.
48+
if key_event.key == "tab" or key_event.key == "shift+tab":
49+
key_event.stop()
50+
key_event.prevent_default()
51+
4652
if self.is_active:
47-
target = self.keys_to_widgets.get(key.key)
53+
# If they press a key corresponding to a jump target,
54+
# then we jump to it.
55+
target = self.keys_to_widgets.get(key_event.key)
4856
if target is not None:
4957
self.dismiss(target)
5058
return
@@ -72,3 +80,5 @@ def compose(self) -> ComposeResult:
7280
yield label
7381
with Center(id="textual-jump-info"):
7482
yield Label("Press a key to jump")
83+
with Center(id="textual-jump-dismiss"):
84+
yield Label("[b]ESC[/] to dismiss")

src/posting/posting.scss

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
* {
22
scrollbar-color: $accent 30%;
3-
scrollbar-color-hover: $accent 50%;
3+
scrollbar-color-hover: $accent 80%;
44
scrollbar-color-active: $accent;
55
scrollbar-background: $surface-darken-1;
66
scrollbar-background-hover: $surface-darken-1;
77
scrollbar-background-active: $surface-darken-1;
88
scrollbar-size-vertical: 1;
9+
10+
&:focus {
11+
scrollbar-color: $accent 55%;
12+
}
913
}
1014

1115
AutoComplete {
@@ -372,6 +376,13 @@ Select {
372376
}
373377
}
374378

379+
#textual-jump-dismiss {
380+
dock: bottom;
381+
height: 1;
382+
background: transparent;
383+
color: $text-muted 42%;
384+
}
385+
375386
CollectionBrowser {
376387
&.section {
377388
border-title-align: left;

0 commit comments

Comments
 (0)