Skip to content
Merged
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
7 changes: 3 additions & 4 deletions tools/developer_tools/bely-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,15 +303,14 @@ equivalent of Home's old menu items, plus what Textual provides by default:

**Entry composer**

A Markdown-aware `TextArea` for the entry body, an optional attachment path, and three
buttons (`Tab`/arrows move focus between the fields and buttons; `Enter` presses the
A Markdown-aware `TextArea` for the entry body and three buttons (`Tab`/arrows move focus between the fields and buttons; `Enter` presses the
focused button):

| Button | Action |
|--------|--------|
| Save `^S` | Save (and upload the attachment, if a path was entered). Also `ctrl+s` from anywhere in the dialog. |
| Save `^S` | Save the entry. Also `ctrl+s` from anywhere in the dialog. |
| Edit in $EDITOR | Suspend the TUI and open the buffer in `$EDITOR`; the edited text comes back into the `TextArea`. |
| Cancel `Esc` | Cancel; asks for confirmation first if the buffer or attachment has unsaved changes. Also `Esc` from anywhere in the dialog. |
| Cancel `Esc` | Cancel; asks for confirmation first if the buffer has unsaved changes. Also `Esc` from anywhere in the dialog. |

An empty new entry is skipped rather than saved, matching `entry add`'s behavior.

Expand Down
31 changes: 5 additions & 26 deletions tools/developer_tools/bely-cli/src/bely_cli/tui/screens/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@
from textual import work
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.widgets import Button, Input, Static, TextArea
from textual.containers import Vertical
from textual.widgets import Button, Static, TextArea

from ... import core
from ...common import editor_changed, format_error_message
from .dialog import CANCEL_HINT, SAVE_HINT, DialogButtons, DialogScreen, LoadingScreen, hinted_label
from .dialog import CANCEL_HINT, SAVE_HINT, DialogButtons, DialogScreen, hinted_label


class ComposeScreen(DialogScreen):
Expand All @@ -39,7 +39,6 @@ class ComposeScreen(DialogScreen):

BINDINGS = [Binding("ctrl+s", "submit", "Save", show=False)]

# Save first: right after the attachment field in tab order, since it's used most.
BUTTON_ROWS = [["compose-save", "compose-editor", "compose-cancel"]]

def __init__(self, doc, entry, api, *, is_new, factory=None, reply_to=None):
Expand All @@ -62,9 +61,6 @@ def compose(self) -> ComposeResult:
with Vertical(id="compose-dialog", classes="dialog"):
yield Static(title, id="compose-title")
yield TextArea(self._initial_text, language="markdown", id="compose-area")
with Horizontal(id="compose-attach-row"):
yield Static("Attachment:", id="compose-attach-label")
yield Input(placeholder="optional file path", id="compose-attach")
with DialogButtons():
yield Button(hinted_label("Save", SAVE_HINT), variant="primary", id="compose-save")
yield Button(hinted_label("Edit in $EDITOR"), id="compose-editor")
Expand All @@ -91,8 +87,7 @@ def action_cancel(self):

def _is_dirty(self):
area = self.query_one("#compose-area", TextArea)
attach_path = self.query_one("#compose-attach", Input).value.strip()
return editor_changed(self._initial_text, area.text) or bool(attach_path)
return editor_changed(self._initial_text, area.text)

# -- cancel, with a dirty-buffer confirmation --

Expand Down Expand Up @@ -137,29 +132,13 @@ async def _save(self):
self.dismiss(None)
return

attach_path = self.query_one("#compose-attach", Input).value.strip()
if attach_path:
try:
attach_path = core.validate_attachment_path(attach_path)
except ValueError as e:
self.notify(str(e), severity="error")
return

if not self.is_new and not editor_changed(self._initial_text, text) and not attach_path:
if not self.is_new and not editor_changed(self._initial_text, text):
self.notify("No changes made.")
self.dismiss(None)
return

try:
saved_entry = await asyncio.to_thread(core.save_entry, self.api, self.entry, text)
if attach_path:
await self.app.push_screen(LoadingScreen("Uploading attachment…"))
try:
await asyncio.to_thread(
core.upload_attachment, self.api, self.doc.id,
saved_entry.log_id, attach_path)
finally:
self.app.pop_screen()
except Exception as e:
self.notify(
f"Save failed: {format_error_message(e, self.factory)}", severity="error")
Expand Down
61 changes: 17 additions & 44 deletions tools/developer_tools/bely-cli/test/test_tui_screens.py
Original file line number Diff line number Diff line change
Expand Up @@ -636,25 +636,18 @@ async def test_reply_loads_blank_template_sets_parent_and_titles_composer(self):
self.assertEqual(saved.parent_log_id, 7)
self.assertEqual(saved.log_entry, "reply text")

async def test_reply_uses_existing_attachment_flow(self):
async def test_reply_does_not_show_attachment_field(self):
api = FakeLogbookApi()
api.upload_attachment = lambda **kwargs: None
doc = SimpleNamespace(id=1, name="Doc")
parent = SimpleNamespace(log_id=7)
app = App()
async with app.run_test() as pilot:
task = app.run_worker(open_composer(app, doc, api, reply_to=parent))
await pilot.pause()
screen = app.screen
screen.query_one("#compose-area", TextArea).text = "reply text"
screen.query_one("#compose-attach", Input).value = "/tmp/reply.txt"
with patch("bely_cli.core.validate_attachment_path", return_value="/tmp/reply.txt"), \
patch("bely_cli.core.upload_attachment") as upload:
screen.query_one("#compose-save", Button).press()
await pilot.pause()
await pilot.pause()
saved = await task.wait()
upload.assert_called_once_with(api, 1, saved.log_id, "/tmp/reply.txt")
self.assertEqual(len(app.screen.query("#compose-attach")), 0)
app.screen.query_one("#compose-cancel", Button).press()
await pilot.pause()
await task.wait()

async def test_reply_cancel_returns_none(self):
api = FakeLogbookApi()
Expand Down Expand Up @@ -774,40 +767,20 @@ async def test_editor_button_opens_editor_and_updates_buffer(self):
screen.query_one("#compose-area", TextArea).text, "existing text edited")
# buffer is dirty now, so skip Cancel here -- not what this test covers

async def test_tab_from_attachment_field_reaches_save_next(self):
api = FakeLogbookApi()
doc = SimpleNamespace(id=1, name="Doc")
entry = SimpleNamespace(log_id=7, log_entry="existing text")
app = App()
async with app.run_test() as pilot:
app.run_worker(app.push_screen_wait(ComposeScreen(doc, entry, api, is_new=False)))
await pilot.pause()
screen = app.screen
screen.query_one("#compose-attach", Input).focus()
await pilot.pause()
await pilot.press("tab")
await pilot.pause()
self.assertIs(screen.focused, screen.query_one("#compose-save", Button))

async def test_down_and_up_arrows_move_between_attachment_and_buttons(self):
async def test_composer_modes_do_not_show_attachment_field(self):
api = FakeLogbookApi()
doc = SimpleNamespace(id=1, name="Doc")
entry = SimpleNamespace(log_id=7, log_entry="existing text")
app = App()
async with app.run_test() as pilot:
app.run_worker(app.push_screen_wait(ComposeScreen(doc, entry, api, is_new=False)))
await pilot.pause()
screen = app.screen
screen.query_one("#compose-attach", Input).focus()
await pilot.pause()

await pilot.press("down")
await pilot.pause()
self.assertIs(screen.focused, screen.query_one("#compose-save", Button))

await pilot.press("up")
await pilot.pause()
self.assertIs(screen.focused, screen.query_one("#compose-attach", Input))
for is_new, entry in (
(True, SimpleNamespace(log_id=None, log_entry="")),
(False, SimpleNamespace(log_id=7, log_entry="existing text")),
):
with self.subTest(is_new=is_new):
app = App()
async with app.run_test() as pilot:
app.run_worker(app.push_screen_wait(
ComposeScreen(doc, entry, api, is_new=is_new)))
await pilot.pause()
self.assertEqual(len(app.screen.query("#compose-attach")), 0)

async def test_left_and_right_arrows_cycle_between_buttons(self):
api = FakeLogbookApi()
Expand Down
Loading