Skip to content

Commit a06c796

Browse files
tjazerzenclaude
andcommitted
Add mark & copy support with smart ctrl+c handler
Implement copy-first, quit-second design: ctrl+c copies selected text to clipboard, or enters quit-pending state requiring a second ctrl+c to quit (ESC cancels). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6398a9b commit a06c796

3 files changed

Lines changed: 72 additions & 4 deletions

File tree

tui/components.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,33 @@
1313
class CustomFooter(Horizontal):
1414
"""A custom footer with keyboard shortcuts and render ID."""
1515

16+
NORMAL_TEXT = "ctrl+c: copy/quit * ctrl+l: toggle logs"
17+
QUIT_PENDING_TEXT = "Press ctrl+c again to quit * esc: cancel"
18+
1619
def __init__(self, render_id: str = "", **kwargs):
1720
super().__init__(**kwargs)
1821
self.render_id = render_id
22+
self._footer_text_widget: Optional[Static] = None
1923

2024
def compose(self):
21-
yield Static("ctrl+c: quit * ctrl+l: toggle logs", classes="custom-footer-text")
25+
self._footer_text_widget = Static(self.NORMAL_TEXT, classes="custom-footer-text")
26+
yield self._footer_text_widget
2227
if self.render_id:
2328
yield Static(f"render id: {self.render_id}", classes="custom-footer-render-id")
2429

30+
def update_quit_state(self, quit_pending: bool) -> None:
31+
"""Update footer text based on quit-pending state."""
32+
if self._footer_text_widget is None:
33+
return
34+
if quit_pending:
35+
self._footer_text_widget.update(self.QUIT_PENDING_TEXT)
36+
self._footer_text_widget.remove_class("custom-footer-text")
37+
self._footer_text_widget.add_class("custom-footer-quit-pending")
38+
else:
39+
self._footer_text_widget.update(self.NORMAL_TEXT)
40+
self._footer_text_widget.remove_class("custom-footer-quit-pending")
41+
self._footer_text_widget.add_class("custom-footer-text")
42+
2543

2644
class ScriptOutputType(str, Enum):
2745
UNIT_TEST_OUTPUT_TEXT = "Unit tests output: "

tui/plain2code_tui.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
from typing import Callable, Optional
55

66
from textual.app import App, ComposeResult
7+
from textual.binding import Binding
78
from textual.containers import Vertical, VerticalScroll
9+
from textual.css.query import NoMatches
810
from textual.widgets import ContentSwitcher, Static
911
from textual.worker import Worker, WorkerFailed, WorkerState
1012

@@ -53,7 +55,8 @@ class Plain2CodeTUI(App):
5355
"""A Textual TUI for plain2code."""
5456

5557
BINDINGS = [
56-
("ctrl+c", "quit", "Quit"),
58+
Binding("ctrl+c", "smart_quit", "Copy/Quit", show=False),
59+
Binding("escape", "cancel_quit", "Cancel Quit", show=False),
5760
("ctrl+l", "toggle_logs", "Toggle Logs"),
5861
]
5962

@@ -77,6 +80,7 @@ def __init__(
7780
self.conformance_tests_script: Optional[str] = conformance_tests_script
7881
self.prepare_environment_script: Optional[str] = prepare_environment_script
7982
self.state_machine_version = state_machine_version
83+
self._quit_pending = False
8084

8185
# Initialize state handlers
8286
self._state_handlers: dict[str, StateHandler] = {
@@ -298,12 +302,53 @@ def ensure_exit():
298302
# daemon=True ensures this thread dies with the process if it exits before the timer fires
299303
threading.Thread(target=ensure_exit, daemon=True).start()
300304

305+
@property
306+
def quit_pending(self) -> bool:
307+
"""Whether a quit confirmation is pending."""
308+
return self._quit_pending
309+
310+
async def action_smart_quit(self) -> None:
311+
"""Handle ctrl+c: copy selected text if any, otherwise quit.
312+
313+
Copy-first, quit-second design:
314+
- If text is selected -> copy it to clipboard
315+
- If no text is selected -> enter quit-pending state
316+
- If already in quit-pending state -> actually quit
317+
- ESC cancels the quit confirmation
318+
"""
319+
selected_text = self.screen.get_selected_text()
320+
if selected_text:
321+
self.copy_to_clipboard(selected_text)
322+
self.screen.clear_selection()
323+
self.notify("Copied to clipboard", timeout=2)
324+
return
325+
326+
if self._quit_pending:
327+
self.action_quit()
328+
return
329+
330+
self._quit_pending = True
331+
self._refresh_footer()
332+
333+
def action_cancel_quit(self) -> None:
334+
"""Cancel the quit confirmation when ESC is pressed."""
335+
if self._quit_pending:
336+
self._quit_pending = False
337+
self._refresh_footer()
338+
339+
def _refresh_footer(self) -> None:
340+
"""Refresh the CustomFooter widget to reflect current quit-pending state."""
341+
try:
342+
footer = self.screen.query_one(CustomFooter)
343+
footer.update_quit_state(self._quit_pending)
344+
except NoMatches:
345+
pass
346+
301347
def action_quit(self) -> None:
302-
"""An action to quit the application immediately when user presses 'q'.
348+
"""Quit the application immediately.
303349
304350
Note: Force exit may leave files partially written if interrupted during file I/O operations.
305351
This is acceptable since the folders in which we are writing are git versioned and are reset in the next render.
306352
"""
307-
# Show stopping message to user
308353
self.render_worker.cancel()
309354
self.exit()

tui/styles.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,11 @@ CustomFooter {
394394
color: #888;
395395
}
396396

397+
.custom-footer-quit-pending {
398+
width: 1fr;
399+
color: #e5c07b;
400+
}
401+
397402
.custom-footer-render-id {
398403
width: auto;
399404
color: #888;

0 commit comments

Comments
 (0)