cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Add overlays and Sheets-style dropdown menus

Dami committed f59a529
Menus open from the menu bar as boxes composited over the grid with
Lip Gloss layers, so the grid underneath never moves. Alt+F, Alt+E,
Alt+V, Alt+I, Alt+O, Alt+H (as in Sheets) or F10 open them; arrows move
and cross between menus, Right and Left open and close submenus, Enter
runs, Esc closes one level and a letter jumps to the items starting
with it. With the mouse, click a title to open it, hover to highlight
and to switch menus, click an item to run it and click elsewhere to
close. Each item shows its shortcut as a key chip; unavailable items are
dimmed and skipped, items whose command isn't registered are hidden,
and menus too tall for the screen scroll. The status line describes the
highlighted item.

Quitting with unsaved changes asks on the context line instead of in a
menu: Enter saves and quits, D discards, Esc cancels. Key hints now
show keys as chips too.
commit
f59a529b9025e20c88796cddd913f62404d25e6f
parent
71a8e6cb649f80b4d32a6a9bccc58bbfb6f07a7f
author
Dami <dami.etoile@gmail.com>
37 files changed, +1152 -316
Me2e/app_test.go17-
Me2e/screens_test.go10+-
Ae2e/shell_test.go45++++
Me2e/testdata/screens/budget-light.html2+-
Me2e/testdata/screens/budget.html2+-
Me2e/testdata/screens/copy-marker-light.html4+-
Me2e/testdata/screens/copy-marker-selected-light.html2+-
Me2e/testdata/screens/copy-marker-selected.html2+-
Me2e/testdata/screens/copy-marker.html4+-
Me2e/testdata/screens/edit-error.html2+-
Me2e/testdata/screens/entry-formula.html4+-
Me2e/testdata/screens/hover-resize-handle.html2+-
Ae2e/testdata/screens/menu-light.html30++
Me2e/testdata/screens/menu.html20+-
Me2e/testdata/screens/narrow-copy.html4+-
Me2e/testdata/screens/narrow.html2+-
Me2e/testdata/screens/point-range-light.html4+-
Me2e/testdata/screens/point-range.html4+-
Me2e/testdata/screens/prompt-width.html4+-
Me2e/testdata/screens/quit-confirm-light.html4+-
Me2e/testdata/screens/quit-confirm.html4+-
Me2e/testdata/screens/ready-empty.html2+-
Me2e/testdata/screens/resizing-column-light.html2+-
Me2e/testdata/screens/resizing-column.html2+-
Minternal/ui/commands.go15-
Ainternal/ui/dialog.go101++++++++
Minternal/ui/edit_test.go10+-
Minternal/ui/menu.go173+-------------
Minternal/ui/menubar.go381++++++++++++++++++++++++++++++
Minternal/ui/model.go24+-
Minternal/ui/model_test.go41---
Minternal/ui/mouse.go5+-
Minternal/ui/mouse_test.go20+-
Ainternal/ui/overlay.go211+++++++++++++++++
Minternal/ui/panel.go42+++-
Minternal/ui/shell_test.go259++++++++++++++++++++-
Minternal/ui/theme.go8+-
M e2e/app_test.go +0 -17
M e2e/screens_test.go +5 -5
A e2e/shell_test.go +45 -0
M e2e/testdata/screens/budget-light.html +1 -1
M e2e/testdata/screens/budget.html +1 -1
M e2e/testdata/screens/copy-marker-light.html +2 -2
@@ -1,6 +1,6 @@
11 <pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">H</span>elp <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> READY</span><span style="background:var(--vt-palette-6)"> </span>22 <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> C2 </span>3-Copied B3:B5 <span style="font-weight:bold">Ctrl+V</span> <span style="color:var(--vt-palette-0)">paste </span><span style="font-weight:bold">Ctrl+Shift+V</span> <span style="color:var(--vt-palette-0)">paste values </span><span style="font-weight:bold">Esc</span> <span style="color:var(--vt-palette-0)">clear</span>3+Copied B3:B5 <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> Ctrl+V </span> <span style="color:var(--vt-palette-0)">paste</span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> Ctrl+Shift+V </span> <span style="color:var(--vt-palette-0)">paste values</span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> Esc </span> <span style="color:var(--vt-palette-0)">clear</span>44 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> A B </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> C </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> D E F G H I </span>55 <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 1 </span> Household budget 202666 <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> 2 </span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-6)"> </span>
@@ -27,4 +27,4 @@ Copied B3:B5 <span style="font-weight:bold">Ctrl+V</span> <span style="color:v
2727 <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 23 </span>2828 <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 24 </span>2929 <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 25 </span>30-untitled<span style="color:var(--vt-palette-0)"> modified</span> <span style="font-weight:bold">Ctrl+/</span> <span style="color:var(--vt-palette-0)">shortcuts </span><span style="font-weight:bold">F10</span> <span style="color:var(--vt-palette-0)">menu </span><span style="font-weight:bold">Ctrl+Q</span> <span style="color:var(--vt-palette-0)">quit</span></pre>\ No newline at end of file30+untitled<span style="color:var(--vt-palette-0)"> modified</span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> Ctrl+/ </span> <span style="color:var(--vt-palette-0)">shortcuts</span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> F10 </span> <span style="color:var(--vt-palette-0)">menu</span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> Ctrl+Q </span> <span style="color:var(--vt-palette-0)">quit</span></pre>\ No newline at end of file
M e2e/testdata/screens/copy-marker-selected-light.html +1 -1
@@ -1,6 +1,6 @@
11 <pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">H</span>elp <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> READY</span><span style="background:var(--vt-palette-6)"> </span>22 <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> A3:B5 </span> Rent3-Cut A3:B5 <span style="font-weight:bold">Ctrl+V</span> <span style="color:var(--vt-palette-0)">move here </span><span style="font-weight:bold">Esc</span> <span style="color:var(--vt-palette-0)">cancel</span>3+Cut A3:B5 <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> Ctrl+V </span> <span style="color:var(--vt-palette-0)">move here</span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> Esc </span> <span style="color:var(--vt-palette-0)">cancel</span>44 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> A </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-4)"> B </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> C D E F G H I </span>55 <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 1 </span> Household budget 202666 <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 2 </span>
M e2e/testdata/screens/copy-marker-selected.html +1 -1
@@ -1,6 +1,6 @@
11 <pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">H</span>elp <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> READY</span><span style="background:var(--vt-palette-6)"> </span>22 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> A3:B5 </span> Rent3-Cut A3:B5 <span style="font-weight:bold">Ctrl+V</span> <span style="color:var(--vt-palette-8)">move here </span><span style="font-weight:bold">Esc</span> <span style="color:var(--vt-palette-8)">cancel</span>3+Cut A3:B5 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Ctrl+V </span> <span style="color:var(--vt-palette-8)">move here</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Esc </span> <span style="color:var(--vt-palette-8)">cancel</span>44 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> A </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-4)"> B </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> C D E F G H I </span>55 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 1 </span> Household budget 202666 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 2 </span>
M e2e/testdata/screens/copy-marker.html +2 -2
@@ -1,6 +1,6 @@
11 <pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">H</span>elp <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> READY</span><span style="background:var(--vt-palette-6)"> </span>22 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> C2 </span>3-Copied B3:B5 <span style="font-weight:bold">Ctrl+V</span> <span style="color:var(--vt-palette-8)">paste </span><span style="font-weight:bold">Ctrl+Shift+V</span> <span style="color:var(--vt-palette-8)">paste values </span><span style="font-weight:bold">Esc</span> <span style="color:var(--vt-palette-8)">clear</span>3+Copied B3:B5 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Ctrl+V </span> <span style="color:var(--vt-palette-8)">paste</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Ctrl+Shift+V </span> <span style="color:var(--vt-palette-8)">paste values</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Esc </span> <span style="color:var(--vt-palette-8)">clear</span>44 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> A B </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> C </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> D E F G H I </span>55 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 1 </span> Household budget 202666 <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> 2 </span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-6)"> </span>
@@ -27,4 +27,4 @@ Copied B3:B5 <span style="font-weight:bold">Ctrl+V</span> <span style="color:v
2727 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 23 </span>2828 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 24 </span>2929 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 25 </span>30-untitled<span style="color:var(--vt-palette-8)"> modified</span> <span style="font-weight:bold">Ctrl+/</span> <span style="color:var(--vt-palette-8)">shortcuts </span><span style="font-weight:bold">F10</span> <span style="color:var(--vt-palette-8)">menu </span><span style="font-weight:bold">Ctrl+Q</span> <span style="color:var(--vt-palette-8)">quit</span></pre>\ No newline at end of file30+untitled<span style="color:var(--vt-palette-8)"> modified</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Ctrl+/ </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> F10 </span> <span style="color:var(--vt-palette-8)">menu</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Ctrl+Q </span> <span style="color:var(--vt-palette-8)">quit</span></pre>\ No newline at end of file
M e2e/testdata/screens/edit-error.html +1 -1
M e2e/testdata/screens/entry-formula.html +2 -2
@@ -1,6 +1,6 @@
11 <pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">H</span>elp <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> ENTER</span><span style="background:var(--vt-palette-6)"> </span>22 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> B8 </span> =B7*123-<span style="font-weight:bold">Enter</span> <span style="color:var(--vt-palette-8)">accept </span><span style="font-weight:bold">Tab</span> <span style="color:var(--vt-palette-8)">accept and go right </span><span style="font-weight:bold">Arrows after an operator</span> <span style="color:var(--vt-palette-8)">pick cells </span><span style="font-weight:bold">Esc</span> <span style="color:var(--vt-palette-8)">cancel</span>3+<span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Enter </span> <span style="color:var(--vt-palette-8)">accept</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Tab </span> <span style="color:var(--vt-palette-8)">accept and go right</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Arrows </span> <span style="color:var(--vt-palette-8)">pick cells after an operator</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Esc </span> <span style="color:var(--vt-palette-8)">cancel</span>44 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> A </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> B </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> C D E F G H I </span>55 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 1 </span> Household budget 202666 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 2 </span>
@@ -27,4 +27,4 @@
2727 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 23 </span>2828 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 24 </span>2929 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 25 </span>30-untitled<span style="color:var(--vt-palette-8)"> modified</span> <span style="font-weight:bold">Ctrl+/</span> <span style="color:var(--vt-palette-8)">shortcuts </span><span style="font-weight:bold">F10</span> <span style="color:var(--vt-palette-8)">menu </span><span style="font-weight:bold">Ctrl+Q</span> <span style="color:var(--vt-palette-8)">quit</span></pre>\ No newline at end of file30+untitled<span style="color:var(--vt-palette-8)"> modified</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Ctrl+/ </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> F10 </span> <span style="color:var(--vt-palette-8)">menu</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Ctrl+Q </span> <span style="color:var(--vt-palette-8)">quit</span></pre>\ No newline at end of file
M e2e/testdata/screens/hover-resize-handle.html +1 -1
A e2e/testdata/screens/menu-light.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);text-decoration:underline">F</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">ile </span> <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">H</span>elp <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> MENU</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="color:var(--vt-palette-8)">┌────────────────────┐</span>)3+<span style="color:var(--vt-palette-8)">│</span> New <span style="color:var(--vt-palette-8)">│</span>4+<span style="color:var(--vt-palette-8)">│</span> Open <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> Ctrl+O </span> <span style="color:var(--vt-palette-8)">│</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> C D E F G H I </span>5+<span style="color:var(--vt-palette-8)">│</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)"> Save Ctrl+S </span><span style="color:var(--vt-palette-8)">│</span>t 20266+<span style="color:var(--vt-palette-8)">│</span> Save as <span style="color:var(--vt-palette-8)">│</span>7+<span style="color:var(--vt-palette-8)">├────────────────────┤</span>4508+<span style="color:var(--vt-palette-8)">│</span> Quit <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> Ctrl+Q </span> <span style="color:var(--vt-palette-8)">│</span>2.49+<span style="color:var(--vt-palette-8)">└────────────────────┘</span> 9610+<span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 6 </span> Savings r<span style="color:var(--vt-palette-1)"> #DIV/0! </span>11+<span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> 7 </span> Total <span style="color:var(--vt-palette-0);background:var(--vt-palette-6)"> 2158.4 </span>12+<span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 8 </span>13+<span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 9 </span>14+<span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 10 </span>15+<span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 11 </span>16+<span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 12 </span>17+<span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 13 </span>18+<span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 14 </span>19+<span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 15 </span>20+<span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 16 </span>21+<span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 17 </span>22+<span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 18 </span>23+<span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 19 </span>24+<span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 20 </span>25+<span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 21 </span>26+<span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 22 </span>27+<span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 23 </span>28+<span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 24 </span>29+<span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 25 </span>30+Save the sheet <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> Arrows </span> <span style="color:var(--vt-palette-0)">move</span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> Enter </span> <span style="color:var(--vt-palette-0)">choose</span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> Esc </span> <span style="color:var(--vt-palette-0)">close</span></pre>\ No newline at end of file
M e2e/testdata/screens/menu.html +10 -10
@@ -1,12 +1,12 @@
1-<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">H</span>elp <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> MENU</span><span style="background:var(--vt-palette-6)"> </span>2-<span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> B7 </span> =SUM(B3:B5)3-<span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">File</span> Edit Format <span style="color:var(--vt-palette-8)">New Open Save As Quit</span>4-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> A </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> B </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> C D E F G H I </span>5-<span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 1 </span> Household budget 20266-<span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 2 </span>7-<span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 3 </span> Rent 14508-<span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 4 </span> Groceries 612.49-<span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 5 </span> Transit 961+<pre class="screen"><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);text-decoration:underline">F</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">ile </span> <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">H</span>elp <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> MENU</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="color:var(--vt-palette-8)">┌────────────────────┐</span>)3+<span style="color:var(--vt-palette-8)">│</span> New <span style="color:var(--vt-palette-8)">│</span>4+<span style="color:var(--vt-palette-8)">│</span> Open <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Ctrl+O </span> <span style="color:var(--vt-palette-8)">│</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> C D E F G H I </span>5+<span style="color:var(--vt-palette-8)">│</span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)"> Save Ctrl+S </span><span style="color:var(--vt-palette-8)">│</span>t 20266+<span style="color:var(--vt-palette-8)">│</span> Save as <span style="color:var(--vt-palette-8)">│</span>7+<span style="color:var(--vt-palette-8)">├────────────────────┤</span>4508+<span style="color:var(--vt-palette-8)">│</span> Quit <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Ctrl+Q </span> <span style="color:var(--vt-palette-8)">│</span>2.49+<span style="color:var(--vt-palette-8)">└────────────────────┘</span> 961010 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 6 </span> Savings r<span style="color:var(--vt-palette-1)"> #DIV/0! </span>1111 <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> 7 </span> Total <span style="color:var(--vt-palette-0);background:var(--vt-palette-6)"> 2158.4 </span>1212 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 8 </span>
@@ -27,4 +27,4 @@
2727 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 23 </span>2828 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 24 </span>2929 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 25 </span>30-untitled<span style="color:var(--vt-palette-8)"> modified</span> <span style="font-weight:bold">Ctrl+/</span> <span style="color:var(--vt-palette-8)">shortcuts </span><span style="font-weight:bold">F10</span> <span style="color:var(--vt-palette-8)">menu </span><span style="font-weight:bold">Ctrl+Q</span> <span style="color:var(--vt-palette-8)">quit</span></pre>\ No newline at end of file30+Save the sheet <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Arrows </span> <span style="color:var(--vt-palette-8)">move</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Enter </span> <span style="color:var(--vt-palette-8)">choose</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Esc </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file
M e2e/testdata/screens/narrow-copy.html +2 -2
@@ -1,6 +1,6 @@
11 <pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">H</span>elp <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> READY</span><span style="background:var(--vt-palette-6)"> </span>22 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> B8 </span>3-Copied B7 <span style="font-weight:bold">Ctrl+V</span> <span style="color:var(--vt-palette-8)">paste </span><span style="font-weight:bold">Esc</span> <span style="color:var(--vt-palette-8)">clear</span>3+Copied B7 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Ctrl+V </span> <span style="color:var(--vt-palette-8)">paste</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Esc </span> <span style="color:var(--vt-palette-8)">clear</span>44 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> A </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> B </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> C D E </span>55 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 1 </span> Household budget 202666 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 2 </span>
@@ -13,4 +13,4 @@ Copied B7 <span style="font-weight:bold">Ctrl+V</span> <span style="color:var(
1313 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 9 </span>1414 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 10 </span>1515 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 11 </span>16-untitled<span style="color:var(--vt-palette-8)"> modified</span> <span style="font-weight:bold">Ctrl+/</span> <span style="color:var(--vt-palette-8)">shortcuts </span><span style="font-weight:bold">F10</span> <span style="color:var(--vt-palette-8)">menu </span><span style="font-weight:bold">Ctrl+Q</span> <span style="color:var(--vt-palette-8)">quit</span></pre>\ No newline at end of file16+untitled<span style="color:var(--vt-palette-8)"> modified</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Ctrl+/ </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> F10 </span> <span style="color:var(--vt-palette-8)">menu</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Ctrl+Q </span></pre>\ No newline at end of file
M e2e/testdata/screens/narrow.html +1 -1
M e2e/testdata/screens/point-range-light.html +2 -2
@@ -1,6 +1,6 @@
11 <pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">H</span>elp <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> POINT</span><span style="background:var(--vt-palette-6)"> </span>22 <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> C7 </span> =AVERAGE(<span style="color:var(--vt-palette-15);background:var(--vt-palette-4)">B3:B5</span>3-<span style="font-weight:bold">Arrows</span> <span style="color:var(--vt-palette-0)">pick a cell </span><span style="font-weight:bold">Shift+arrows</span> <span style="color:var(--vt-palette-0)">pick a range </span><span style="font-weight:bold">Enter</span> <span style="color:var(--vt-palette-0)">accept </span><span style="font-weight:bold">Esc</span> <span style="color:var(--vt-palette-0)">back</span>3+<span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> Arrows </span> <span style="color:var(--vt-palette-0)">pick a cell</span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> Shift+arrows </span> <span style="color:var(--vt-palette-0)">pick a range</span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> Enter </span> <span style="color:var(--vt-palette-0)">accept</span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> Esc </span> <span style="color:var(--vt-palette-0)">back</span>44 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> A </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> B </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> C D E F G H I </span>55 <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 1 </span> Household budget 202666 <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 2 </span>
@@ -27,4 +27,4 @@
2727 <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 23 </span>2828 <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 24 </span>2929 <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 25 </span>30-untitled<span style="color:var(--vt-palette-0)"> modified</span> <span style="font-weight:bold">Ctrl+/</span> <span style="color:var(--vt-palette-0)">shortcuts </span><span style="font-weight:bold">F10</span> <span style="color:var(--vt-palette-0)">menu </span><span style="font-weight:bold">Ctrl+Q</span> <span style="color:var(--vt-palette-0)">quit</span></pre>\ No newline at end of file30+untitled<span style="color:var(--vt-palette-0)"> modified</span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> Ctrl+/ </span> <span style="color:var(--vt-palette-0)">shortcuts</span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> F10 </span> <span style="color:var(--vt-palette-0)">menu</span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> Ctrl+Q </span> <span style="color:var(--vt-palette-0)">quit</span></pre>\ No newline at end of file
M e2e/testdata/screens/point-range.html +2 -2
@@ -1,6 +1,6 @@
11 <pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">H</span>elp <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> POINT</span><span style="background:var(--vt-palette-6)"> </span>22 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> C7 </span> =AVERAGE(<span style="color:var(--vt-palette-0);background:var(--vt-palette-4)">B3:B5</span>3-<span style="font-weight:bold">Arrows</span> <span style="color:var(--vt-palette-8)">pick a cell </span><span style="font-weight:bold">Shift+arrows</span> <span style="color:var(--vt-palette-8)">pick a range </span><span style="font-weight:bold">Enter</span> <span style="color:var(--vt-palette-8)">accept </span><span style="font-weight:bold">Esc</span> <span style="color:var(--vt-palette-8)">back</span>3+<span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Arrows </span> <span style="color:var(--vt-palette-8)">pick a cell</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Shift+arrows </span> <span style="color:var(--vt-palette-8)">pick a range</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Enter </span> <span style="color:var(--vt-palette-8)">accept</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Esc </span> <span style="color:var(--vt-palette-8)">back</span>44 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> A </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> B </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> C D E F G H I </span>55 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 1 </span> Household budget 202666 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 2 </span>
@@ -27,4 +27,4 @@
2727 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 23 </span>2828 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 24 </span>2929 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 25 </span>30-untitled<span style="color:var(--vt-palette-8)"> modified</span> <span style="font-weight:bold">Ctrl+/</span> <span style="color:var(--vt-palette-8)">shortcuts </span><span style="font-weight:bold">F10</span> <span style="color:var(--vt-palette-8)">menu </span><span style="font-weight:bold">Ctrl+Q</span> <span style="color:var(--vt-palette-8)">quit</span></pre>\ No newline at end of file30+untitled<span style="color:var(--vt-palette-8)"> modified</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Ctrl+/ </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> F10 </span> <span style="color:var(--vt-palette-8)">menu</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Ctrl+Q </span> <span style="color:var(--vt-palette-8)">quit</span></pre>\ No newline at end of file
M e2e/testdata/screens/prompt-width.html +2 -2
@@ -1,6 +1,6 @@
11 <pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">H</span>elp <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> WIDTH</span><span style="background:var(--vt-palette-6)"> </span>22 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> A1 </span> Household budget 20263-Column width (1-240): 13 <span style="font-weight:bold">Left/Right</span> <span style="color:var(--vt-palette-8)">adjust </span><span style="font-weight:bold">Enter</span> <span style="color:var(--vt-palette-8)">apply </span><span style="font-weight:bold">Esc</span> <span style="color:var(--vt-palette-8)">cancel</span>3+Column width (1-240): 13 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Left/Right </span> <span style="color:var(--vt-palette-8)">adjust</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Enter </span> <span style="color:var(--vt-palette-8)">apply</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Esc </span> <span style="color:var(--vt-palette-8)">cancel</span>44 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> A </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> B C D E F G H I </span>55 <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> 1 </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)"> Household bu</span>dget 202666 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 2 </span>
@@ -27,4 +27,4 @@ Column width (1-240): 13 <span style="font-weight
2727 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 23 </span>2828 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 24 </span>2929 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 25 </span>30-untitled<span style="color:var(--vt-palette-8)"> modified</span> <span style="font-weight:bold">Ctrl+/</span> <span style="color:var(--vt-palette-8)">shortcuts </span><span style="font-weight:bold">F10</span> <span style="color:var(--vt-palette-8)">menu </span><span style="font-weight:bold">Ctrl+Q</span> <span style="color:var(--vt-palette-8)">quit</span></pre>\ No newline at end of file30+untitled<span style="color:var(--vt-palette-8)"> modified</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Ctrl+/ </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> F10 </span> <span style="color:var(--vt-palette-8)">menu</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Ctrl+Q </span> <span style="color:var(--vt-palette-8)">quit</span></pre>\ No newline at end of file
M e2e/testdata/screens/quit-confirm-light.html +2 -2
@@ -1,6 +1,6 @@
11 <pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">H</span>elp <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> MENU</span><span style="background:var(--vt-palette-6)"> </span>22 <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> B7 </span> =SUM(B3:B5)3-Cancel <span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Quit without saving</span> <span style="color:var(--vt-palette-0)">You have unsaved changes. Close one23 anyway?</span>3+<span style="color:var(--vt-palette-3)">You have unsaved changes.</span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> Enter </span> Save and quit <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> D </span> Discard <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> Esc </span> Cancel44 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> A </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> B </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> C D E F G H I </span>55 <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 1 </span> Household budget 202666 <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 2 </span>
@@ -27,4 +27,4 @@ Cancel <span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Q
2727 <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 23 </span>2828 <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 24 </span>2929 <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> 25 </span>30-untitled<span style="color:var(--vt-palette-0)"> modified</span> <span style="font-weight:bold">Ctrl+/</span> <span style="color:var(--vt-palette-0)">shortcuts </span><span style="font-weight:bold">F10</span> <span style="color:var(--vt-palette-0)">menu </span><span style="font-weight:bold">Ctrl+Q</span> <span style="color:var(--vt-palette-0)">quit</span></pre>\ No newline at end of file30+untitled<span style="color:var(--vt-palette-0)"> modified</span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> Ctrl+/ </span> <span style="color:var(--vt-palette-0)">shortcuts</span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> F10 </span> <span style="color:var(--vt-palette-0)">menu</span> <span style="color:var(--vt-palette-0);background:var(--vt-palette-7)"> Ctrl+Q </span> <span style="color:var(--vt-palette-0)">quit</span></pre>\ No newline at end of file
M e2e/testdata/screens/quit-confirm.html +2 -2
@@ -1,6 +1,6 @@
11 <pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">H</span>elp <span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> MENU</span><span style="background:var(--vt-palette-6)"> </span>22 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> B7 </span> =SUM(B3:B5)3-Cancel <span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Quit without saving</span> <span style="color:var(--vt-palette-8)">You have unsaved changes. Close one23 anyway?</span>3+<span style="color:var(--vt-palette-3)">You have unsaved changes.</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Enter </span> Save and quit <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> D </span> Discard <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Esc </span> Cancel44 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> A </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold"> B </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> C D E F G H I </span>55 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 1 </span> Household budget 202666 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 2 </span>
@@ -27,4 +27,4 @@ Cancel <span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Q
2727 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 23 </span>2828 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 24 </span>2929 <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> 25 </span>30-untitled<span style="color:var(--vt-palette-8)"> modified</span> <span style="font-weight:bold">Ctrl+/</span> <span style="color:var(--vt-palette-8)">shortcuts </span><span style="font-weight:bold">F10</span> <span style="color:var(--vt-palette-8)">menu </span><span style="font-weight:bold">Ctrl+Q</span> <span style="color:var(--vt-palette-8)">quit</span></pre>\ No newline at end of file30+untitled<span style="color:var(--vt-palette-8)"> modified</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Ctrl+/ </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> F10 </span> <span style="color:var(--vt-palette-8)">menu</span> <span style="color:var(--vt-palette-15);background:var(--vt-palette-8)"> Ctrl+Q </span> <span style="color:var(--vt-palette-8)">quit</span></pre>\ No newline at end of file
M e2e/testdata/screens/ready-empty.html +1 -1
M e2e/testdata/screens/resizing-column-light.html +1 -1
M e2e/testdata/screens/resizing-column.html +1 -1
M internal/ui/commands.go +0 -15
A internal/ui/dialog.go +101 -0
@@ -0,0 +1,101 @@
1+package ui2+3+import (4+ "strings"5+6+ tea "charm.land/bubbletea/v2"7+ "github.com/charmbracelet/x/ansi"8+)9+10+// choiceBar is a small question asked on the context line and answered11+// with a key, the way terminal programs confirm things, e.g.12+// "You have unsaved changes. Enter Save and quit D Discard Esc Cancel".13+// It takes the keyboard like an overlay but draws no box.14+type choiceBar struct {15+ msg string16+ choices []choice17+}18+19+type choice struct {20+ key string // as tea reports it, e.g. "enter" or "d"21+ label string22+ run func(m *Model) tea.Cmd23+}24+25+func (c *choiceBar) indicator() string { return "MENU" }26+func (c *choiceBar) layout(*Model) []box { return nil }27+func (c *choiceBar) status(*Model) (string, string) { return "", "" }28+29+func (c *choiceBar) key(m *Model, k tea.KeyPressMsg) tea.Cmd {30+ key := strings.ToLower(k.String())31+ for _, ch := range c.choices {32+ if key == ch.key {33+ return c.choose(m, ch)34+ }35+ }36+ return nil37+}38+39+func (c *choiceBar) choose(m *Model, ch choice) tea.Cmd {40+ m.closeOverlay()41+ return ch.run(m)42+}43+44+// mouse runs a choice when its key chip or label is clicked. A click45+// anywhere else cancels, like Esc.46+func (c *choiceBar) mouse(m *Model, e mouseEvent) tea.Cmd {47+ if e.kind != mousePress {48+ return nil49+ }50+ if e.y == contextLine {51+ x := ansi.StringWidth(c.prefix(m))52+ for _, ch := range c.choices {53+ w := ansi.StringWidth(c.item(m, ch))54+ if e.x >= x && e.x < x+w {55+ return c.choose(m, ch)56+ }57+ x += w + len(choiceGap)58+ }59+ }60+ m.closeOverlay()61+ return nil62+}63+64+const choiceGap = " "65+66+func (c *choiceBar) prefix(m *Model) string {67+ return m.th.warning.Render(c.msg) + choiceGap68+}69+70+func (c *choiceBar) item(m *Model, ch choice) string {71+ return m.chip(keyLabel(ch.key)) + " " + ch.label72+}73+74+// line renders the bar for the context line.75+func (c *choiceBar) line(m *Model) string {76+ items := make([]string, len(c.choices))77+ for i, ch := range c.choices {78+ items[i] = c.item(m, ch)79+ }80+ return c.prefix(m) + strings.Join(items, choiceGap)81+}82+83+// quit exits, asking first when there are unsaved changes. Enter saves84+// and quits, so the quick path never loses work.85+func (m *Model) quit() tea.Cmd {86+ if !m.changed {87+ return exit()88+ }89+ m.openOverlay(&choiceBar{90+ msg: "You have unsaved changes.",91+ choices: []choice{92+ {key: "enter", label: "Save and quit", run: func(m *Model) tea.Cmd {93+ m.quitAfterSave = true94+ return m.save()95+ }},96+ {key: "d", label: "Discard", run: func(*Model) tea.Cmd { return exit() }},97+ {key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }},98+ },99+ })100+ return nil101+}
M internal/ui/edit_test.go +6 -4
@@ -52,7 +52,8 @@ func TestUndoRedoKeys(t *testing.T) {
5252 5353 func TestUndoColumnWidthPrompt(t *testing.T) {5454 m := newModel()55- press(t, m, "<f10>", "f", "<enter>", "c", "<right>", "<right>", "<enter>")55+ run(m, m.runCommand("column.width"))56+ press(t, m, "<right>", "<right>", "<enter>")5657 if m.sheet.ColWidth(0) != 12 {5758 t.Fatalf("width %d", m.sheet.ColWidth(0))5859 }
@@ -60,7 +61,8 @@ func TestUndoColumnWidthPrompt(t *testing.T) {
6061 if m.sheet.ColWidth(0) != 10 || m.sheet.CanUndo() {6162 t.Errorf("width preview and result should be one step: width %d", m.sheet.ColWidth(0))6263 }63- press(t, m, "<f10>", "f", "<enter>", "c", "<right>", "<esc>")64+ run(m, m.runCommand("column.width"))65+ press(t, m, "<right>", "<esc>")6466 if m.sheet.CanUndo() || m.changed {6567 t.Errorf("cancelled width prompt left an undo step: %v %v %d %v", m.sheet.CanUndo(), m.changed, m.sheet.ColWidth(0), m.mode)6668 }
@@ -83,7 +85,7 @@ func TestCopyPaste(t *testing.T) {
8385 if got := clipboardText(t, m, "edit.copy"); got != "1\t2" {8486 t.Errorf("system clipboard %q", got)8587 }86- if !m.copyMarked(addr("B1")) || !strings.HasPrefix(line(m, 2), "Copied A1:B1 Ctrl+V paste") {88+ if l := line(m, 2); !m.copyMarked(addr("B1")) || !strings.HasPrefix(l, "Copied A1:B1") || !strings.Contains(l, "Ctrl+V") {8789 t.Fatalf("marker %v, line %q", m.copyMarked(addr("B1")), line(m, 2))8890 }8991 press(t, m, "<down>", "<ctrl+v>")
@@ -124,7 +126,7 @@ func TestEditClearsCopyMarker(t *testing.T) {
124126 func TestCutPasteMoves(t *testing.T) {125127 m := newModel()126128 press(t, m, "5", "<enter>", "=A1+1", "<enter>", "<ctrl+home>", "<ctrl+x>")127- if !strings.HasPrefix(line(m, 2), "Cut A1 Ctrl+V move here") {129+ if l := line(m, 2); !strings.HasPrefix(l, "Cut A1") || !strings.Contains(l, "move here") {128130 t.Errorf("context line %q", line(m, 2))129131 }130132 press(t, m, "<right>", "<right>", "<ctrl+v>")
M internal/ui/menu.go +11 -162
@@ -14,164 +14,6 @@ import (
1414 "one23/internal/sheet"1515 )1616 17-// menuItem is one entry in the slash menu. An item either opens a submenu18-// (items) or runs a registered command (cmd). Descriptions come from the19-// command unless the item overrides them.20-type slashItem struct {21- name string22- desc string23- descFn func(m *Model) string // overrides desc when set24- items []slashItem25- cmd string26-}27-28-type menuLevel struct {29- items []slashItem30- sel int31-}32-33-// rootMenu is interim: the Sheets-style menu bar replaces it.34-var rootMenu = []slashItem{35- {name: "File", items: []slashItem{36- {name: "New", descFn: unsavedWarning("Start a new, empty sheet"), cmd: "file.new"},37- {name: "Open", cmd: "file.open"},38- {name: "Save", cmd: "file.save"},39- {name: "As", desc: "Save the sheet under a new name", cmd: "file.saveas"},40- {name: "Quit", cmd: "quit"},41- }},42- {name: "Edit", items: []slashItem{43- {name: "Clear", cmd: "clear"},44- {name: "Go to", cmd: "goto"},45- {name: "Select all", cmd: "select.all"},46- }},47- {name: "Format", items: []slashItem{48- {name: "Column width", cmd: "column.width"},49- {name: "Reset column width", cmd: "column.reset"},50- }},51-}52-53-// quitConfirm is shown by Quit when there are unsaved changes.54-var quitConfirm = []slashItem{55- {name: "Cancel", desc: "Keep working"},56- {name: "Quit without saving", descFn: unsavedWarning("Close one23"), cmd: "quit.force"},57-}58-59-func init() {60- register(&command{id: "quit.force", title: "Quit without saving", desc: "Close one23, discarding changes", run: func(*Model) tea.Cmd { return exit() }})61-}62-63-func unsavedWarning(desc string) func(m *Model) string {64- return func(m *Model) string {65- if m.changed {66- return "You have unsaved changes. " + desc + " anyway?"67- }68- return desc69- }70-}71-72-func (it slashItem) description(m *Model) string {73- switch {74- case it.descFn != nil:75- return it.descFn(m)76- case it.items != nil:77- names := make([]string, len(it.items))78- for i, sub := range it.items {79- names[i] = sub.name80- }81- return strings.Join(names, " ")82- case it.desc != "":83- return it.desc84- case it.cmd != "":85- return commands[it.cmd].desc86- }87- return ""88-}89-90-// slashMenuLine shows the open menu level and the highlighted item's91-// description.92-func (m *Model) slashMenuLine() string {93- lvl := m.menu[len(m.menu)-1]94- parts := make([]string, len(lvl.items))95- for i, it := range lvl.items {96- parts[i] = it.name97- if i == lvl.sel {98- parts[i] = m.th.menuSelected.Render(it.name)99- }100- }101- return strings.Join(parts, " ") + " " + m.th.muted.Render(lvl.items[lvl.sel].description(m))102-}103-104-func (m *Model) openMenu() {105- m.mode = modeMenu106- m.menu = []menuLevel{{items: rootMenu}}107-}108-109-// menuKey handles MENU mode: arrows highlight, Enter or an item's first110-// letter selects, Esc backs out one level.111-func (m *Model) menuKey(k tea.KeyPressMsg) tea.Cmd {112- lvl := &m.menu[len(m.menu)-1]113- n := len(lvl.items)114- switch k.String() {115- case "left", "shift+tab":116- lvl.sel = (lvl.sel + n - 1) % n117- case "right", "tab", "space":118- lvl.sel = (lvl.sel + 1) % n119- case "home":120- lvl.sel = 0121- case "end":122- lvl.sel = n - 1123- case "enter":124- return m.choose(lvl.items[lvl.sel])125- case "esc":126- m.menu = m.menu[:len(m.menu)-1]127- if len(m.menu) == 0 {128- m.mode = modeReady129- }130- default:131- // A letter opens the only item starting with it, or cycles through132- // the items when several do.133- text := strings.ToUpper(typed(k))134- if text == "" {135- return nil136- }137- var matches []int138- for i, it := range lvl.items {139- if strings.HasPrefix(strings.ToUpper(it.name), text) {140- matches = append(matches, i)141- }142- }143- switch len(matches) {144- case 0:145- case 1:146- lvl.sel = matches[0]147- return m.choose(lvl.items[lvl.sel])148- default:149- next := matches[0]150- for _, i := range matches {151- if i > lvl.sel {152- next = i153- break154- }155- }156- lvl.sel = next157- }158- }159- return nil160-}161-162-func (m *Model) choose(it slashItem) tea.Cmd {163- if it.items != nil {164- m.menu = append(m.menu, menuLevel{items: it.items})165- return nil166- }167- m.menu = nil168- m.mode = modeReady169- if it.cmd == "" {170- return nil171- }172- return m.runCommand(it.cmd)173-}174-17517 type promptKind int17618 17719 const (
@@ -180,8 +22,8 @@ const (
18022 promptWidth18123 )18224 183-// prompt is a question on the second control panel line, such as a file184-// name or a range to act on.25+// prompt is a question on the context line, such as a file name or a26+// range to act on.18527 type prompt struct {18628 kind promptKind18729 label string
@@ -303,6 +145,7 @@ func (m *Model) promptKey(k tea.KeyPressMsg) tea.Cmd {
303145 if p.onCancel != nil {304146 p.onCancel(m)305147 }148+ m.quitAfterSave = false // cancelling Save as cancels Save and quit306149 m.closePrompt()307150 return nil308151 case "enter":
@@ -441,13 +284,19 @@ func listFilesCmd() tea.Msg {
441284 return filesMsg(files)442285 }443286 444-func (m *Model) handleSaved(msg savedMsg) {287+func (m *Model) handleSaved(msg savedMsg) tea.Cmd {288+ quit := m.quitAfterSave289+ m.quitAfterSave = false445290 if msg.err != nil {446291 m.fail(fmt.Sprintf("Couldn't save %s: %v", msg.name, msg.err))447- return292+ return nil448293 }449294 m.filename = msg.name450295 m.changed = false296+ if quit {297+ return exit()298+ }299+ return nil451300 }452301 453302 func (m *Model) handleLoaded(msg loadedMsg) {
M internal/ui/menubar.go +381 -0
@@ -1,7 +1,11 @@
11 package ui22 33 import (4+ "strconv"45 "strings"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"59 )610 711 // The menu bar follows Google Sheets: File Edit View Insert Format Data
@@ -146,5 +150,382 @@ func (m *Model) menuBarTitles() string {
146150 147151 // openBarMenu is the index in barMenus of the open dropdown, or -1.148152 func (m *Model) openBarMenu() int {153+ if o, ok := m.overlay.(*menuOverlay); ok {154+ return o.bar155+ }156+ return -1157+}158+159+// barMenuAt returns the index in barMenus of the title at column x, or -1.160+func barMenuAt(x int) int {161+ for i, bm := range barMenus() {162+ if x >= bm.x && x < bm.x+bm.w {163+ return i164+ }165+ }149166 return -1150167 }168+169+// barMenuFor returns the index in barMenus of the menu Alt+key opens, or170+// -1 if key isn't one.171+func barMenuFor(key string) int {172+ letter, ok := strings.CutPrefix(key, "alt+")173+ if !ok || len(letter) != 1 {174+ return -1175+ }176+ for i, bm := range barMenus() {177+ if bm.def.accel == letter[0] {178+ return i179+ }180+ }181+ return -1182+}183+184+func init() {185+ register(&command{id: "menu", title: "Menu", desc: "Open the menu bar", run: func(m *Model) tea.Cmd {186+ m.showBarMenu(0)187+ return nil188+ }})189+ keymap["f10"] = "menu"190+}191+192+// menuOverlay is an open dropdown from the menu bar, or a context menu,193+// with any submenus opened from it.194+type menuOverlay struct {195+ bar int // index in barMenus of the open title; -1 for a context menu196+ x, y int // where a context menu opened197+ levels []*menuLevel198+}199+200+// menuLevel is one open box: the dropdown or a submenu.201+type menuLevel struct {202+ items []menuItem203+ sel int // -1 while nothing is highlighted, e.g. a submenu opened by hovering204+ top int // first item shown when the box is too tall for the screen205+}206+207+// showBarMenu opens dropdown i of barMenus.208+func (m *Model) showBarMenu(i int) {209+ menus := barMenus()210+ if len(menus) == 0 {211+ return212+ }213+ i = (i%len(menus) + len(menus)) % len(menus)214+ m.openOverlay(&menuOverlay{bar: i, levels: []*menuLevel{m.newLevel(visibleItems(menus[i].def.items), true)}})215+}216+217+// showContextMenu opens a menu of items with its corner at x, y.218+func (m *Model) showContextMenu(items []menuItem, x, y int) {219+ if items = visibleItems(items); len(items) > 0 {220+ m.openOverlay(&menuOverlay{bar: -1, x: x, y: y, levels: []*menuLevel{m.newLevel(items, true)}})221+ }222+}223+224+func (m *Model) newLevel(items []menuItem, highlight bool) *menuLevel {225+ l := &menuLevel{items: items, sel: -1}226+ if highlight {227+ l.step(m, 1)228+ }229+ return l230+}231+232+func (l *menuLevel) selectable(m *Model, i int) bool {233+ it := l.items[i]234+ return !it.sep && (it.items != nil || commands[it.cmd].available(m))235+}236+237+// step moves the highlight to the next selectable item in direction d,238+// wrapping around.239+func (l *menuLevel) step(m *Model, d int) {240+ n, i := len(l.items), l.sel241+ if i < 0 && d < 0 {242+ i = n243+ }244+ for range n {245+ i = ((i+d)%n + n) % n246+ if l.selectable(m, i) {247+ l.sel = i248+ return249+ }250+ }251+}252+253+func (o *menuOverlay) top() *menuLevel { return o.levels[len(o.levels)-1] }254+255+func (o *menuOverlay) indicator() string { return "MENU" }256+257+// key handles a key in an open menu: Up/Down highlight, Right opens a258+// submenu or the next menu, Left closes a submenu or opens the previous259+// menu, Enter runs, Esc closes one level, and a letter jumps to the items260+// starting with it, running the item if it's the only one.261+func (o *menuOverlay) key(m *Model, k tea.KeyPressMsg) tea.Cmd {262+ l := o.top()263+ key := k.String()264+ if i := barMenuFor(key); i >= 0 {265+ m.showBarMenu(i)266+ return nil267+ }268+ switch key {269+ case "up", "shift+tab":270+ l.step(m, -1)271+ case "down", "tab":272+ l.step(m, 1)273+ case "home":274+ l.sel = -1275+ l.step(m, 1)276+ case "end":277+ l.sel = -1278+ l.step(m, -1)279+ case "right":280+ if l.sel >= 0 && l.items[l.sel].items != nil {281+ return o.choose(m)282+ }283+ if l.sel < 0 {284+ l.step(m, 1)285+ } else if o.bar >= 0 {286+ m.showBarMenu(o.bar + 1)287+ }288+ case "left":289+ if len(o.levels) > 1 {290+ o.levels = o.levels[:len(o.levels)-1]291+ } else if o.bar >= 0 {292+ m.showBarMenu(o.bar - 1)293+ }294+ case "enter", "space":295+ if l.sel < 0 {296+ l.step(m, 1)297+ return nil298+ }299+ return o.choose(m)300+ case "esc":301+ if len(o.levels) > 1 {302+ o.levels = o.levels[:len(o.levels)-1]303+ } else {304+ m.closeOverlay()305+ }306+ case "f10":307+ m.closeOverlay()308+ default:309+ return o.jump(m, typed(k))310+ }311+ return nil312+}313+314+// jump highlights the next item starting with letter, running it if it's315+// the only one.316+func (o *menuOverlay) jump(m *Model, letter string) tea.Cmd {317+ l := o.top()318+ if letter == "" {319+ return nil320+ }321+ var matches []int322+ for i, it := range l.items {323+ if l.selectable(m, i) && strings.HasPrefix(strings.ToLower(it.label()), strings.ToLower(letter)) {324+ matches = append(matches, i)325+ }326+ }327+ switch len(matches) {328+ case 0:329+ return nil330+ case 1:331+ l.sel = matches[0]332+ return o.choose(m)333+ }334+ next := matches[0]335+ for _, i := range matches {336+ if i > l.sel {337+ next = i338+ break339+ }340+ }341+ l.sel = next342+ return nil343+}344+345+// choose opens the highlighted submenu or runs the highlighted command.346+func (o *menuOverlay) choose(m *Model) tea.Cmd {347+ l := o.top()348+ if l.sel < 0 || !l.selectable(m, l.sel) {349+ return nil350+ }351+ it := l.items[l.sel]352+ if it.items != nil {353+ o.levels = append(o.levels, m.newLevel(it.items, true))354+ return nil355+ }356+ return m.runFromOverlay(it.cmd)357+}358+359+func menuBoxID(level int) string { return "menu" + strconv.Itoa(level) }360+361+// mouse handles hovering and clicking items, switching menus by hovering362+// or clicking the menu bar, and closing the menu on a click elsewhere.363+func (o *menuOverlay) mouse(m *Model, e mouseEvent) tea.Cmd {364+ for lv, l := range o.levels {365+ if e.box != menuBoxID(lv) {366+ continue367+ }368+ i := l.top + e.row - 1369+ if e.kind == mouseWheel {370+ o.levels = o.levels[:lv+1]371+ l.wheel(m, e.button)372+ return nil373+ }374+ if e.row < 1 || i >= len(l.items) || !l.selectable(m, i) {375+ return nil376+ }377+ switch e.kind {378+ case mouseMotion:379+ if i != l.sel || len(o.levels) == lv+1 {380+ o.levels, l.sel = o.levels[:lv+1], i381+ if sub := l.items[i].items; sub != nil {382+ o.levels = append(o.levels, m.newLevel(sub, false))383+ }384+ }385+ case mousePress:386+ o.levels, l.sel = o.levels[:lv+1], i387+ return o.choose(m)388+ }389+ return nil390+ }391+ if e.y == menuLine && e.box == "" {392+ if t := barMenuAt(e.x); t >= 0 {393+ switch {394+ case e.kind == mousePress && t == o.bar:395+ m.closeOverlay()396+ case e.kind == mousePress, e.kind == mouseMotion && o.bar >= 0 && t != o.bar:397+ m.showBarMenu(t)398+ }399+ return nil400+ }401+ }402+ if e.kind == mousePress {403+ m.closeOverlay()404+ }405+ return nil406+}407+408+// wheel moves the highlight, which scrolls a menu too tall for the screen.409+func (l *menuLevel) wheel(m *Model, b tea.MouseButton) {410+ switch b {411+ case tea.MouseWheelUp:412+ l.step(m, -1)413+ case tea.MouseWheelDown:414+ l.step(m, 1)415+ }416+}417+418+func (o *menuOverlay) status(m *Model) (string, string) {419+ keys := m.keyHints("Up/Down", "move", "Enter", "choose", "Esc", "close")420+ if o.bar >= 0 {421+ keys = m.keyHints("Arrows", "move", "Enter", "choose", "Esc", "close")422+ }423+ l := o.top()424+ if l.sel < 0 {425+ return "", keys426+ }427+ it := l.items[l.sel]428+ if it.items != nil {429+ names := make([]string, 0, len(it.items))430+ for _, sub := range it.items {431+ if !sub.sep {432+ names = append(names, sub.label())433+ }434+ }435+ return strings.Join(names, ", "), keys436+ }437+ return commands[it.cmd].desc, keys438+}439+440+// layout places the dropdown under its title (or a context menu at the441+// mouse) and each submenu beside the item that opened it, flipping left or442+// up where the screen runs out. The status line stays visible.443+func (o *menuOverlay) layout(m *Model) []box {444+ boxes := make([]box, 0, len(o.levels))445+ maxRows := max(m.height-4, 3)446+ for i, l := range o.levels {447+ rows := min(len(l.items), maxRows)448+ if l.sel >= 0 {449+ l.top = clamp(l.top, l.sel-rows+1, l.sel)450+ }451+ l.top = clamp(l.top, 0, len(l.items)-rows)452+ lines := m.dropdown(l, rows)453+ w, h := ansi.StringWidth(lines[0]), len(lines)454+ var x, y int455+ switch {456+ case i > 0:457+ p := boxes[i-1]458+ x, y = p.x+p.width(), p.y+o.levels[i-1].sel-o.levels[i-1].top459+ if x+w > m.width {460+ x = p.x - w461+ }462+ case o.bar >= 0:463+ x, y = barMenus()[o.bar].x, menuLine+1464+ default:465+ x, y = o.x, o.y466+ if y+h > m.height-1 {467+ y = o.y - h468+ }469+ }470+ x = clamp(x, 0, m.width-w)471+ y = clamp(y, 0, m.height-1-h)472+ boxes = append(boxes, box{id: menuBoxID(i), x: x, y: y, lines: lines})473+ }474+ return boxes475+}476+477+// dropdown draws a menu box: each item with its shortcut right-aligned, a478+// › for submenus, separators between groups, unavailable items dimmed and479+// arrows in the border when the items don't all fit.480+func (m *Model) dropdown(l *menuLevel, rows int) []string {481+ lw, kw := 0, 0482+ for _, it := range l.items {483+ lw = max(lw, ansi.StringWidth(it.label()))484+ kw = max(kw, ansi.StringWidth(m.itemKey(it, false)))485+ }486+ inner := max(lw+kw+5, 18)487+ out := make([]string, 0, rows)488+ for i := l.top; i < l.top+rows; i++ {489+ it := l.items[i]490+ if it.sep {491+ out = append(out, sepRow)492+ continue493+ }494+ style := m.th.menuBar495+ switch {496+ case i == l.sel:497+ style = m.th.menuSelected498+ case !l.selectable(m, i):499+ style = m.th.disabled500+ }501+ k := m.itemKey(it, !l.selectable(m, i))502+ if it.items != nil || i == l.sel {503+ // The highlight runs unbroken across the row.504+ k = style.Render(ansi.Strip(k))505+ }506+ out = append(out, cells(style, " "+it.label(), inner-ansi.StringWidth(k)-1)+k+style.Render(" "))507+ }508+ var up, down string509+ if l.top > 0 {510+ up = "▲"511+ }512+ if l.top+rows < len(l.items) {513+ down = "▼"514+ }515+ return m.frame(inner, up, down, out)516+}517+518+// itemKey is what an item shows on the right: its shortcut as a key chip519+// (plain when the item is unavailable), or › for a submenu.520+func (m *Model) itemKey(it menuItem, disabled bool) string {521+ k := shortcut(it.cmd)522+ switch {523+ case it.items != nil:524+ return "›"525+ case k == "":526+ return ""527+ case disabled:528+ return m.th.disabled.Render(" " + k + " ")529+ }530+ return m.chip(k)531+}
M internal/ui/model.go +18 -6
@@ -55,6 +55,8 @@ type Model struct {
5555 copied clipboard // see clipboard.go5656 note string // feedback on the last action, e.g. "Undid: clear B3"5757 58+ quitAfterSave bool // "Save and quit" is waiting for the save to finish59+5860 cur sheet.Addr // the active cell5961 top, left int // first visible row and column6062 width, height int
@@ -90,10 +92,10 @@ type Model struct {
9092 pointPrefix string // entry text before the reference being pointed at9193 pointSuffix string // entry text after the caret while pointing9294 93- menu []menuLevel94- prompt *prompt95- files []string // file list shown by File Open96- errMsg string95+ overlay overlay // open menu, palette or dialog, if any (modeMenu)96+ prompt *prompt97+ files []string // file list shown by File Open98+ errMsg string9799 98100 th theme99101 }
@@ -114,6 +116,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
114116 before, beforeMode := *m.focus(), m.mode115117 state := m.beginUpdate(msg)116118 var cmd tea.Cmd119+ if mouse, ok := msg.(tea.MouseMsg); ok {120+ var handled bool121+ if cmd, handled = m.shellMouse(mouse); handled {122+ msg = nil // taken by the menu bar or an overlay, not the grid123+ }124+ }117125 switch msg := msg.(type) {118126 case tea.WindowSizeMsg:119127 m.width, m.height = msg.Width, msg.Height
@@ -135,7 +143,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
135143 case tea.MouseWheelMsg:136144 m.handleWheel(msg.Mouse())137145 case savedMsg:138- m.handleSaved(msg)146+ cmd = m.handleSaved(msg)139147 if msg.err == nil {140148 m.saved = m.sheet.StateID()141149 }
@@ -191,7 +199,7 @@ func (m *Model) handleKey(k tea.KeyPressMsg) tea.Cmd {
191199 case modePoint:192200 return m.pointKey(k)193201 case modeMenu:194- return m.menuKey(k)202+ return m.overlay.key(m, k)195203 case modePrompt:196204 return m.promptKey(k)197205 case modeHelp, modeError:
@@ -207,6 +215,10 @@ func (m *Model) readyKey(k tea.KeyPressMsg) tea.Cmd {
207215 m.tabbing = false208216 return nil209217 }218+ if i := barMenuFor(key); i >= 0 {219+ m.showBarMenu(i)220+ return nil221+ }210222 if id, ok := keymap[key]; ok {211223 return m.runCommand(id)212224 }
M internal/ui/model_test.go +0 -41
@@ -346,28 +346,6 @@ func TestWheelDoesNotSnapBack(t *testing.T) {
346346 }347347 }348348 349-func TestMenuColumnWidthOnSelection(t *testing.T) {350- m := newModel()351- press(t, m, "<shift+right>", "<f10>")352- if l := line(m, contextLine); !strings.HasPrefix(l, "File Edit Format") {353- t.Fatalf("menu line %q", l)354- }355- press(t, m, "f") // two items start with F: cycles to Format356- press(t, m, "<enter>", "c", "15", "<enter>")357- if m.mode != modeReady || m.sheet.ColWidth(0) != 15 || m.sheet.ColWidth(1) != 15 {358- t.Errorf("mode %v widths %d %d", m.mode, m.sheet.ColWidth(0), m.sheet.ColWidth(1))359- }360- // Arrows preview live; Esc restores.361- press(t, m, "<f10>", "<left>", "<enter>", "c", "<right>", "<right>")362- if m.sheet.ColWidth(0) != 17 {363- t.Errorf("preview width %d", m.sheet.ColWidth(0))364- }365- press(t, m, "<esc>")366- if m.sheet.ColWidth(0) != 15 {367- t.Errorf("width after esc %d", m.sheet.ColWidth(0))368- }369-}370-371349 func TestGoto(t *testing.T) {372350 m := newModel()373351 press(t, m, "<ctrl+g>", "Z100", "<enter>")
@@ -416,25 +394,6 @@ func TestSaveAndOpen(t *testing.T) {
416394 }417395 }418396 419-func TestQuitConfirmsUnsavedChanges(t *testing.T) {420- m := newModel()421- if _, ok := press(t, m, "<ctrl+q>").(tea.QuitMsg); !ok {422- t.Fatal("Ctrl+Q with no changes did not quit")423- }424- m = newModel()425- press(t, m, "1", "<enter>")426- if msg := press(t, m, "<ctrl+q>"); msg != nil || m.mode != modeMenu {427- t.Fatalf("Ctrl+Q with changes: %v mode %v", msg, m.mode)428- }429- press(t, m, "<right>")430- if !strings.Contains(line(m, 2), "unsaved changes") {431- t.Errorf("no unsaved warning: %q", line(m, 2))432- }433- if _, ok := press(t, m, "<enter>").(tea.QuitMsg); !ok {434- t.Error("Quit without saving did not quit")435- }436-}437-438397 func TestScrollFollowsCursor(t *testing.T) {439398 m := newModel()440399 for range 40 {
M internal/ui/mouse.go +1 -4
M internal/ui/mouse_test.go +10 -10
@@ -9,7 +9,7 @@ import (
99 "one23/internal/sheet"1010 )1111 12-func mouseAt(x, y int) tea.Mouse { return tea.Mouse{X: x, Y: y, Button: tea.MouseLeft} }12+func leftAt(x, y int) tea.Mouse { return tea.Mouse{X: x, Y: y, Button: tea.MouseLeft} }1313 1414 // barText is the text in the formula bar after the name box.1515 func barText(m *Model) string {
@@ -37,9 +37,9 @@ func TestClickInsertsReferenceIntoFormula(t *testing.T) {
3737 t.Fatalf("mode %v formula bar %q", m.mode, barText(m))3838 }3939 // Dragging makes it a range.40- send(m, tea.MouseClickMsg(mouseAt(cellX(0), gridTop)))41- send(m, tea.MouseMotionMsg(mouseAt(cellX(0), gridTop+1)))42- send(m, tea.MouseReleaseMsg(mouseAt(cellX(0), gridTop+1)))40+ send(m, tea.MouseClickMsg(leftAt(cellX(0), gridTop)))41+ send(m, tea.MouseMotionMsg(leftAt(cellX(0), gridTop+1)))42+ send(m, tea.MouseReleaseMsg(leftAt(cellX(0), gridTop+1)))4343 if barText(m) != "=A1:A2" {4444 t.Fatalf("formula bar %q", barText(m))4545 }
@@ -72,15 +72,15 @@ func TestResizeColumnByDragging(t *testing.T) {
7272 if m.hover.kind != hitColBorder || !strings.Contains(line(m, headerLine), "▐") {7373 t.Fatalf("hover %v header %q", m.hover.kind, line(m, headerLine))7474 }75- send(m, tea.MouseClickMsg(mouseAt(border, headerLine)))76- send(m, tea.MouseMotionMsg(mouseAt(border+5, headerLine)))75+ send(m, tea.MouseClickMsg(leftAt(border, headerLine)))76+ send(m, tea.MouseMotionMsg(leftAt(border+5, headerLine)))7777 if w := m.sheet.ColWidth(0); w != sheet.DefaultWidth+5 {7878 t.Errorf("width while dragging %d", w)7979 }8080 if !strings.Contains(line(m, 2), "Column A width 15") {8181 t.Errorf("context line %q", line(m, 2))8282 }83- send(m, tea.MouseReleaseMsg(mouseAt(border+5, headerLine)))83+ send(m, tea.MouseReleaseMsg(leftAt(border+5, headerLine)))8484 if m.drag != dragNone || !m.changed {8585 t.Error("resize did not finish")8686 }
@@ -99,8 +99,8 @@ func TestDoubleClickBorderAutofits(t *testing.T) {
9999 100100 func TestDragAutoscrolls(t *testing.T) {101101 m := newModel()102- send(m, tea.MouseClickMsg(mouseAt(cellX(0), gridTop)))103- _, cmd := m.Update(tea.MouseMotionMsg(mouseAt(cellX(0), m.height-1)))102+ send(m, tea.MouseClickMsg(leftAt(cellX(0), gridTop)))103+ _, cmd := m.Update(tea.MouseMotionMsg(leftAt(cellX(0), m.height-1)))104104 if cmd == nil || !m.autoscrolling {105105 t.Fatal("dragging below the grid did not start autoscroll")106106 }
@@ -111,7 +111,7 @@ func TestDragAutoscrolls(t *testing.T) {
111111 if m.ext.Row != bottom+5 || m.top == 0 {112112 t.Errorf("ext %v top %d", m.ext, m.top)113113 }114- send(m, tea.MouseReleaseMsg(mouseAt(cellX(0), m.height-1)))114+ send(m, tea.MouseReleaseMsg(leftAt(cellX(0), m.height-1)))115115 send(m, autoscrollMsg{})116116 if m.autoscrolling {117117 t.Error("autoscroll kept going after release")
A internal/ui/overlay.go +211 -0
@@ -0,0 +1,211 @@
1+package ui2+3+import (4+ "strings"5+6+ tea "charm.land/bubbletea/v2"7+ "charm.land/lipgloss/v2"8+ "github.com/charmbracelet/x/ansi"9+)10+11+// overlay is a floating box drawn over the grid: a dropdown or context12+// menu, the command palette or a dialog. While one is open it takes every13+// key and mouse event, and Esc closes one level. Overlays are composited14+// on top of the finished screen, so the grid underneath never moves.15+type overlay interface {16+ // indicator is the mode shown at the top right, e.g. "MENU".17+ indicator() string18+ // layout places the overlay's boxes on the screen, bottom first.19+ layout(m *Model) []box20+ key(m *Model, k tea.KeyPressMsg) tea.Cmd21+ mouse(m *Model, e mouseEvent) tea.Cmd22+ // status is what the status line says while the overlay is open: what23+ // the highlighted item does, and the keys that apply.24+ status(m *Model) (desc, keys string)25+}26+27+// textOverlay is an overlay with a text input, which gets the terminal28+// cursor. The text is edited in m.buf, like any other entry.29+type textOverlay interface {30+ overlay31+ cursor(m *Model) (x, y int)32+ changed(m *Model) // the text changed33+}34+35+// box is a rectangle of styled lines at a screen position. Every line has36+// the same display width.37+type box struct {38+ id string // identifies the box in mouse hit tests39+ x, y int40+ lines []string41+}42+43+func (b box) width() int { return ansi.StringWidth(b.lines[0]) }44+func (b box) height() int { return len(b.lines) }45+46+type mouseKind int47+48+const (49+ mousePress mouseKind = iota50+ mouseMotion51+ mouseRelease52+ mouseWheel53+)54+55+// mouseEvent is a mouse event with what's under it already worked out.56+type mouseEvent struct {57+ kind mouseKind58+ button tea.MouseButton59+ x, y int60+ box string // id of the topmost box under the mouse, "" for none61+ col, row int // position inside that box62+}63+64+// openOverlay shows o, replacing any open overlay.65+func (m *Model) openOverlay(o overlay) {66+ m.overlay = o67+ m.mode = modeMenu68+}69+70+// closeOverlay closes the open overlay and returns to READY.71+func (m *Model) closeOverlay() {72+ m.overlay = nil73+ m.buf, m.bufPos = nil, 074+ if m.mode == modeMenu {75+ m.mode = modeReady76+ }77+}78+79+// runFromOverlay closes the overlay, then runs a command, which may open80+// a prompt or another overlay of its own. Unavailable commands do nothing.81+func (m *Model) runFromOverlay(id string) tea.Cmd {82+ c, ok := commands[id]83+ if !ok || !c.available(m) {84+ return nil85+ }86+ m.closeOverlay()87+ return m.runCommand(id)88+}89+90+// compositor stacks boxes over the screen in order, each on its own91+// layer so hit tests find the topmost box.92+func compositor(boxes []box) *lipgloss.Compositor {93+ layers := make([]*lipgloss.Layer, len(boxes))94+ for i, b := range boxes {95+ layers[i] = lipgloss.NewLayer(strings.Join(b.lines, "\n")).X(b.x).Y(b.y).Z(i + 1).ID(b.id)96+ }97+ return lipgloss.NewCompositor(layers...)98+}99+100+// compose draws the open overlay over the rendered screen.101+func (m *Model) compose(screen string) string {102+ c := lipgloss.NewCanvas(m.width, m.height)103+ c.Compose(lipgloss.NewLayer(screen))104+ c.Compose(compositor(m.overlay.layout(m)))105+ return c.Render()106+}107+108+// shellMouse gives the open overlay, or else the menu bar, the first look109+// at a mouse message. It reports false for messages the grid handles.110+func (m *Model) shellMouse(msg tea.MouseMsg) (tea.Cmd, bool) {111+ if m.overlay != nil {112+ return m.overlayMouse(msg), true113+ }114+ click, ok := msg.(tea.MouseClickMsg)115+ if !ok || m.mode != modeReady {116+ return nil, false117+ }118+ if mouse := click.Mouse(); mouse.Y == menuLine && mouse.Button == tea.MouseLeft {119+ if i := barMenuAt(mouse.X); i >= 0 {120+ m.showBarMenu(i)121+ return nil, true122+ }123+ }124+ return nil, false125+}126+127+// overlayMouse hands a mouse message to the open overlay, with the box and128+// position under the mouse.129+func (m *Model) overlayMouse(msg tea.MouseMsg) tea.Cmd {130+ mouse := msg.Mouse()131+ e := mouseEvent{button: mouse.Button, x: mouse.X, y: mouse.Y}132+ switch msg.(type) {133+ case tea.MouseClickMsg:134+ e.kind = mousePress135+ case tea.MouseMotionMsg:136+ e.kind = mouseMotion137+ case tea.MouseReleaseMsg:138+ e.kind = mouseRelease139+ case tea.MouseWheelMsg:140+ e.kind = mouseWheel141+ }142+ if h := compositor(m.overlay.layout(m)).Hit(e.x, e.y); !h.Empty() {143+ e.box = h.ID()144+ e.col, e.row = e.x-h.Bounds().Min.X, e.y-h.Bounds().Min.Y145+ }146+ return m.overlay.mouse(m, e)147+}148+149+// A box is framed with light box-drawing lines, which keep the crisp150+// character-grid look. Rows are exactly the inner width; separator rows151+// become a line joined to the frame.152+153+// sepRow marks a separator in the rows passed to frame.154+const sepRow = "\x00"155+156+// frame draws a border around rows. A title sits in the top border and a157+// footer at the right of the bottom border.158+func (m *Model) frame(inner int, title, footer string, rows []string) []string {159+ b := m.th.border160+ top := "┌" + strings.Repeat("─", inner) + "┐"161+ if title != "" {162+ t := ansi.Truncate(" "+title+" ", inner-1, "…")163+ top = b.Render("┌─") + m.th.title.Render(t) + b.Render(strings.Repeat("─", inner-1-ansi.StringWidth(t))+"┐")164+ } else {165+ top = b.Render(top)166+ }167+ bottom := b.Render("└" + strings.Repeat("─", inner) + "┘")168+ if footer != "" && ansi.StringWidth(footer)+4 <= inner {169+ f := " " + footer + " "170+ bottom = b.Render("└"+strings.Repeat("─", inner-1-ansi.StringWidth(f))) + m.th.muted.Render(f) + b.Render("─┘")171+ }172+ lines := make([]string, 0, len(rows)+2)173+ lines = append(lines, top)174+ for _, r := range rows {175+ if r == sepRow {176+ lines = append(lines, b.Render("├"+strings.Repeat("─", inner)+"┤"))177+ continue178+ }179+ lines = append(lines, b.Render("│")+r+b.Render("│"))180+ }181+ return append(lines, bottom)182+}183+184+// cells pads or truncates s to exactly w columns, then styles it.185+func cells(style lipgloss.Style, s string, w int) string {186+ return style.Render(padRight(ansi.Truncate(s, w, "…"), w))187+}188+189+// clampBox keeps a w by h box at x, y on screen, shifting it left and up190+// as needed.191+func (m *Model) clampBox(x, y, w, h int) (int, int) {192+ return max(min(x, m.width-w), 0), max(min(y, m.height-h), 0)193+}194+195+// list is the highlighted row and scroll position of a list in a box.196+type list struct {197+ sel, top int198+}199+200+// move highlights the row d steps away, wrapping around the ends.201+func (l *list) move(d, n int) {202+ if n > 0 {203+ l.sel = ((l.sel+d)%n + n) % n204+ }205+}206+207+// show scrolls so the highlighted row is among the rows visible ones.208+func (l *list) show(rows int) {209+ l.top = clamp(l.top, l.sel-rows+1, l.sel)210+ l.top = max(l.top, 0)211+}
M internal/ui/panel.go +34 -8
@@ -32,7 +32,11 @@ func (m *Model) View() tea.View {
3232 lines[i] = ansi.Truncate(l, m.width, "")3333 }3434 35- v := tea.NewView(strings.Join(lines, "\n"))35+ content := strings.Join(lines, "\n")36+ if m.overlay != nil {37+ content = m.compose(content)38+ }39+ v := tea.NewView(content)3640 v.AltScreen = true3741 v.MouseMode = tea.MouseModeAllMotion // hover feedback; see mouse.go3842 v.WindowTitle = "one23 - " + m.displayName()
@@ -55,7 +59,10 @@ func (m *Model) displayName() string {
5559 5660 // indicator is the mode shown at the top right.5761 func (m *Model) indicator() string {58- if m.mode == modePrompt {62+ switch {63+ case m.overlay != nil:64+ return m.overlay.indicator()65+ case m.mode == modePrompt:5966 return m.prompt.indicator6067 }6168 return m.mode.String()
@@ -107,13 +114,15 @@ func (m *Model) contextLineText() string {
107114 case m.mode == modeReady:108115 left = m.readyLine()109116 case m.mode == modeMenu:110- left = m.slashMenuLine()117+ if c, ok := m.overlay.(*choiceBar); ok {118+ left = c.line(m)119+ }111120 case m.mode == modePrompt:112121 left, right = m.promptLine()113122 case m.mode == modePoint:114123 left = m.keyHints("Arrows", "pick a cell", "Shift+arrows", "pick a range", "Enter", "accept", "Esc", "back")115124 case m.mode == modeEnter && m.isFormula():116- left = m.keyHints("Enter", "accept", "Tab", "accept and go right", "Arrows after an operator", "pick cells", "Esc", "cancel")125+ left = m.keyHints("Enter", "accept", "Tab", "accept and go right", "Arrows", "pick cells after an operator", "Esc", "cancel")117126 case m.mode == modeEnter:118127 left = m.keyHints("Enter", "accept", "Tab", "accept and go right", "Arrows", "accept and move", "Esc", "cancel")119128 case m.mode == modeEdit:
@@ -151,18 +160,29 @@ func (m *Model) promptPrefix() string {
151160 return m.prompt.label + " "152161 }153162 154-// keyHints renders key and description pairs, e.g. "Enter accept".163+// keyHints renders key and description pairs, e.g. "Enter accept", each164+// key as a chip.155165 func (m *Model) keyHints(pairs ...string) string {156166 parts := make([]string, 0, len(pairs)/2)157167 for i := 0; i+1 < len(pairs); i += 2 {158- parts = append(parts, m.th.key.Render(pairs[i])+" "+m.th.muted.Render(pairs[i+1]))168+ parts = append(parts, m.chip(pairs[i])+" "+m.th.muted.Render(pairs[i+1]))159169 }160- return strings.Join(parts, m.th.muted.Render(" "))170+ return strings.Join(parts, " ")171+}172+173+// chip draws a key name as a key cap, e.g. "Ctrl+S".174+func (m *Model) chip(label string) string {175+ return m.th.keyChip.Render(" " + label + " ")161176 }162177 163178 // cursorPos returns where the terminal cursor goes: in the formula bar164-// while typing an entry, on the context line in a text prompt.179+// while typing an entry, on the context line in a text prompt, or in an180+// overlay's search field.165181 func (m *Model) cursorPos() (x, y int, ok bool) {182+ if o, isText := m.overlay.(textOverlay); isText {183+ x, y = o.cursor(m)184+ return x, y, true185+ }166186 switch {167187 case m.mode == modeEnter, m.mode == modeEdit:168188 return formulaBarTextX() + ansi.StringWidth(string(m.buf[:m.bufPos])), formulaLine, true
@@ -176,6 +196,12 @@ func (m *Model) statusLine() string {
176196 if m.mode == modeError {177197 return m.th.error.Render(m.errMsg) + m.th.muted.Render(" press any key")178198 }199+ if m.overlay != nil {200+ // What the highlighted item does and the keys that apply.201+ if desc, keys := m.overlay.status(m); desc != "" || keys != "" {202+ return m.spread(desc, keys)203+ }204+ }179205 left := m.displayName()180206 if m.changed {181207 left += m.th.muted.Render(" modified")
M internal/ui/shell_test.go +257 -2
@@ -3,8 +3,48 @@ package ui
33 import (44 "strings"55 "testing"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"69 )710 11+// fakeCommand registers a command for the length of a test, standing in12+// for commands other tracks register. It returns a run counter.13+func fakeCommand(t *testing.T, id, title string, enabled func(*Model) bool) *int {14+ t.Helper()15+ runs := new(int)16+ register(&command{id: id, title: title, desc: title + " (test)", enabled: enabled,17+ run: func(*Model) tea.Cmd { *runs++; return nil }})18+ t.Cleanup(func() { delete(commands, id) })19+ return runs20+}21+22+func mouseAt(m *Model, msg tea.MouseMsg) { send(m, msg) }23+24+func leftClick(m *Model, x, y int) {25+ mouseAt(m, tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})26+ mouseAt(m, tea.MouseReleaseMsg{X: x, Y: y, Button: tea.MouseLeft})27+}28+29+// menu returns the open menu overlay, failing if there is none.30+func openMenu(t *testing.T, m *Model) *menuOverlay {31+ t.Helper()32+ o, ok := m.overlay.(*menuOverlay)33+ if !ok {34+ t.Fatalf("no menu open (mode %v, overlay %T)", m.mode, m.overlay)35+ }36+ return o37+}38+39+func highlighted(t *testing.T, m *Model) string {40+ t.Helper()41+ l := openMenu(t, m).top()42+ if l.sel < 0 {43+ return ""44+ }45+ return l.items[l.sel].label()46+}47+848 func TestControlPanelLayout(t *testing.T) {949 m := newModel()1050 if l := line(m, menuLine); !strings.HasPrefix(l, " File Edit") || !strings.HasSuffix(l, "READY") {
@@ -18,14 +58,229 @@ func TestControlPanelLayout(t *testing.T) {
1858 if x, y, _ := m.cursorPos(); y != formulaLine || x != formulaBarTextX()+3 {1959 t.Errorf("entry cursor at %d,%d", x, y)2060 }21- if !strings.Contains(line(m, contextLine), "Enter accept") {61+ if !strings.Contains(line(m, contextLine), "Enter accept") {2262 t.Errorf("context line %q", line(m, contextLine))2363 }2464 press(t, m, "<esc>", "<ctrl+g>")25- if l := line(m, contextLine); !strings.HasPrefix(l, "Go to: A1") || !strings.HasSuffix(l, "Esc cancel") {65+ if l := line(m, contextLine); !strings.HasPrefix(l, "Go to: A1") || !strings.HasSuffix(l, "Esc cancel") {2666 t.Errorf("prompt line %q", l)2767 }2868 if x, y, _ := m.cursorPos(); y != contextLine || x != len("Go to: A1") {2969 t.Errorf("prompt cursor at %d,%d", x, y)3070 }3171 }72+73+func TestVisibleItemsHidesMissingCommands(t *testing.T) {74+ items := []menuItem{75+ sep, {cmd: "nope.one"}, {cmd: "clear"}, sep, sep, {cmd: "nope.two"}, sep,76+ {title: "Empty", items: []menuItem{{cmd: "nope.three"}}},77+ {cmd: "goto"}, sep,78+ }79+ var got []string80+ for _, it := range visibleItems(items) {81+ if it.sep {82+ got = append(got, "-")83+ } else {84+ got = append(got, it.label())85+ }86+ }87+ if s := strings.Join(got, ","); s != "Clear,-,Go to" {88+ t.Errorf("visible items %s", s)89+ }90+ // Data has no registered commands yet, so the bar hides it.91+ for _, bm := range barMenus() {92+ if bm.def.title == "Data" {93+ t.Error("empty Data menu shown")94+ }95+ }96+}97+98+func TestMenuBarKeyboard(t *testing.T) {99+ m := newModel()100+ press(t, m, "<alt+f>")101+ if o := openMenu(t, m); barMenus()[o.bar].def.title != "File" || highlighted(t, m) != "New" {102+ t.Fatalf("Alt+F opened %d at %q", o.bar, highlighted(t, m))103+ }104+ if !strings.HasSuffix(line(m, menuLine), "MENU") {105+ t.Errorf("indicator %q", line(m, menuLine))106+ }107+ // Up wraps to the last item, skipping the separator.108+ press(t, m, "<up>")109+ if highlighted(t, m) != "Quit" {110+ t.Errorf("up highlighted %q", highlighted(t, m))111+ }112+ if !strings.Contains(line(m, m.height-1), "Close one23") {113+ t.Errorf("status line should describe the item: %q", line(m, m.height-1))114+ }115+ press(t, m, "<right>")116+ if barMenus()[openMenu(t, m).bar].def.title != "Edit" {117+ t.Error("Right did not move to Edit")118+ }119+ press(t, m, "<left>", "<left>")120+ if barMenus()[openMenu(t, m).bar].def.title != "Help" {121+ t.Error("Left from File did not wrap to Help")122+ }123+ press(t, m, "<esc>")124+ if m.mode != modeReady || m.overlay != nil {125+ t.Errorf("Esc left mode %v", m.mode)126+ }127+ press(t, m, "<f10>")128+ if barMenus()[openMenu(t, m).bar].def.title != "File" {129+ t.Error("F10 did not open File")130+ }131+ press(t, m, "<f10>")132+ if m.overlay != nil {133+ t.Error("F10 did not close the menu")134+ }135+}136+137+func TestMenuLetterRunsCommand(t *testing.T) {138+ m := newModel()139+ press(t, m, "<shift+right>", "<alt+o>", "c", "15", "<enter>")140+ if m.mode != modeReady || m.sheet.ColWidth(0) != 15 || m.sheet.ColWidth(1) != 15 {141+ t.Errorf("mode %v widths %d %d", m.mode, m.sheet.ColWidth(0), m.sheet.ColWidth(1))142+ }143+ // With several items starting with the letter, it cycles instead.144+ press(t, m, "<alt+f>", "s")145+ if highlighted(t, m) != "Save" {146+ t.Errorf("s highlighted %q", highlighted(t, m))147+ }148+ press(t, m, "s")149+ if highlighted(t, m) != "Save as" {150+ t.Errorf("second s highlighted %q", highlighted(t, m))151+ }152+ press(t, m, "<enter>")153+ if m.mode != modePrompt || m.prompt.label != "Save as:" {154+ t.Errorf("Save as did not prompt: mode %v", m.mode)155+ }156+}157+158+func TestSubmenuAndDisabledItems(t *testing.T) {159+ percent := fakeCommand(t, "format.percent", "Percent", nil)160+ fakeCommand(t, "format.number", "Number", func(*Model) bool { return false })161+ m := newModel()162+ press(t, m, "<alt+o>")163+ if highlighted(t, m) != "Number" || !strings.Contains(line(m, 2), "│ Number › │") {164+ t.Fatalf("Format opened at %q:\n%s", highlighted(t, m), screen(m))165+ }166+ press(t, m, "<right>")167+ // The unavailable Number format is skipped.168+ if o := openMenu(t, m); len(o.levels) != 2 || highlighted(t, m) != "Percent" {169+ t.Fatalf("submenu at %q", highlighted(t, m))170+ }171+ press(t, m, "<down>")172+ if highlighted(t, m) != "Percent" {173+ t.Errorf("down moved onto %q", highlighted(t, m))174+ }175+ press(t, m, "<left>")176+ if len(openMenu(t, m).levels) != 1 {177+ t.Error("Left did not close the submenu")178+ }179+ press(t, m, "<enter>", "<esc>")180+ if len(openMenu(t, m).levels) != 1 {181+ t.Error("Esc closed more than the submenu")182+ }183+ press(t, m, "<enter>", "<enter>")184+ if *percent != 1 || m.overlay != nil || m.mode != modeReady {185+ t.Errorf("percent ran %d times, overlay %T", *percent, m.overlay)186+ }187+}188+189+func TestMenuMouse(t *testing.T) {190+ m := newModel()191+ before := strings.Split(screen(m), "\n")192+ leftClick(m, 2, menuLine) // "File"193+ o := openMenu(t, m)194+ if barMenus()[o.bar].def.title != "File" {195+ t.Fatalf("clicked File, opened %d", o.bar)196+ }197+ if !strings.Contains(line(m, menuLine+1), "┌") || m.View().MouseMode != tea.MouseModeAllMotion {198+ t.Errorf("dropdown not drawn under the title: %q", line(m, menuLine+1))199+ }200+ // The dropdown covers the grid without moving it.201+ after := strings.Split(screen(m), "\n")202+ w := o.layout(m)[0].width()203+ for y := gridTop; y < m.height-1; y++ {204+ a, b := ansi.Cut(after[y], w, m.width), ansi.Cut(before[y], w, m.width)205+ if strings.TrimRight(a, " ") != strings.TrimRight(b, " ") {206+ t.Errorf("row %d moved:\n%q\n%q", y, a, b)207+ }208+ }209+ box := o.layout(m)[0]210+ // Hovering highlights; the Save item is the third row.211+ mouseAt(m, tea.MouseMotionMsg{X: box.x + 3, Y: box.y + 3})212+ if highlighted(t, m) != "Save" {213+ t.Errorf("hover highlighted %q", highlighted(t, m))214+ }215+ // Hovering another title switches menus.216+ mouseAt(m, tea.MouseMotionMsg{X: barMenus()[1].x + 1, Y: menuLine})217+ if openMenu(t, m).bar != 1 {218+ t.Error("hovering Edit did not switch menus")219+ }220+ // Clicking the open title closes it; clicking outside closes too.221+ leftClick(m, barMenus()[1].x+1, menuLine)222+ if m.overlay != nil {223+ t.Error("clicking the open title did not close it")224+ }225+ leftClick(m, 2, menuLine)226+ leftClick(m, cellX(5), gridTop+10)227+ if m.overlay != nil || m.cur != addr("A1") {228+ t.Errorf("outside click: overlay %T, cur %v (the click should only close)", m.overlay, m.cur)229+ }230+ // Clicking an item runs it.231+ t.Chdir(t.TempDir())232+ leftClick(m, 2, menuLine)233+ box = openMenu(t, m).layout(m)[0]234+ leftClick(m, box.x+3, box.y+4) // Save as235+ if m.mode != modePrompt || m.prompt.label != "Save as:" {236+ t.Errorf("click on Save as: mode %v", m.mode)237+ }238+}239+240+func TestQuitAsksWithChoiceBar(t *testing.T) {241+ m := newModel()242+ if _, ok := press(t, m, "<ctrl+q>").(tea.QuitMsg); !ok {243+ t.Fatal("Ctrl+Q with no changes did not quit")244+ }245+ m = newModel()246+ press(t, m, "1", "<enter>")247+ if msg := press(t, m, "<ctrl+q>"); msg != nil || m.mode != modeMenu {248+ t.Fatalf("Ctrl+Q with changes: %v mode %v", msg, m.mode)249+ }250+ if l := line(m, contextLine); !strings.HasPrefix(l, "You have unsaved changes.") || !strings.Contains(l, "D Discard") {251+ t.Errorf("choice bar %q", l)252+ }253+ press(t, m, "<esc>")254+ if m.mode != modeReady {255+ t.Errorf("Esc: mode %v", m.mode)256+ }257+ press(t, m, "<ctrl+q>")258+ if _, ok := press(t, m, "d").(tea.QuitMsg); !ok {259+ t.Error("D did not discard and quit")260+ }261+}262+263+func TestSaveAndQuit(t *testing.T) {264+ t.Chdir(t.TempDir())265+ m := newModel()266+ press(t, m, "1", "<enter>", "<ctrl+q>", "<enter>")267+ // Untitled: Save and quit asks for a name first; Esc cancels both.268+ if m.mode != modePrompt {269+ t.Fatalf("mode %v", m.mode)270+ }271+ press(t, m, "<esc>")272+ if m.quitAfterSave {273+ t.Error("cancelling Save as left Save and quit pending")274+ }275+ press(t, m, "<ctrl+q>", "<enter>")276+ if _, ok := press(t, m, "sheet", "<enter>").(tea.QuitMsg); !ok || m.changed {277+ t.Errorf("did not save and quit: changed %v err %q", m.changed, m.errMsg)278+ }279+ // Clicking a choice on the context line works too.280+ m = newModel()281+ press(t, m, "1", "<enter>", "<ctrl+q>")282+ x := strings.Index(line(m, contextLine), "Discard")283+ if msg, _ := m.shellMouse(tea.MouseClickMsg{X: x, Y: contextLine, Button: tea.MouseLeft}); msg == nil {284+ t.Error("clicking Discard did not quit")285+ }286+}
M internal/ui/theme.go +4 -4
@@ -24,7 +24,8 @@ type theme struct {
2424 warning lipgloss.Style // recoverable problems, e.g. a formula error2525 error lipgloss.Style // ERROR mode message2626 muted lipgloss.Style // secondary text: key hints, file lists27- key lipgloss.Style // a key name inside a hint, e.g. "Enter"27+ key lipgloss.Style // emphasized text in the status line, e.g. a range28+ keyChip lipgloss.Style // a key cap in hints, menus and the palette, e.g. " Enter "2829 errorCell lipgloss.Style // cells whose value is ERR or NA2930 // copied marks the range on the clipboard, like Sheets' dashed border:3031 // a dashed underline across every cell, layered on the cell's own
@@ -34,14 +35,13 @@ type theme struct {
3435 // Chrome: the menu bar, dropdowns, the palette and dialogs.3536 menuBar lipgloss.Style // menu bar titles3637 menuAccel lipgloss.Style // a title's accelerator letter37- menuSelected lipgloss.Style // open title, highlighted item or button38+ menuSelected lipgloss.Style // open title, highlighted item3839 menuAccelSelected lipgloss.Style // accelerator letter of the open title3940 border lipgloss.Style // box borders and separators4041 title lipgloss.Style // box titles and group headings4142 disabled lipgloss.Style // items that can't run right now4243 match lipgloss.Style // characters matched by a search4344 matchSelected lipgloss.Style // matched characters in the highlighted row44- button lipgloss.Style // dialog buttons without focus4545 }4646 4747 func newTheme(dark bool) theme {
@@ -68,6 +68,7 @@ func newTheme(dark bool) theme {
6868 error: lipgloss.NewStyle().Foreground(lipgloss.BrightRed).Bold(true),6969 muted: lipgloss.NewStyle().Foreground(muted),7070 key: lipgloss.NewStyle().Bold(true),71+ keyChip: lipgloss.NewStyle().Background(headerBg).Foreground(headerFg),7172 errorCell: lipgloss.NewStyle().Foreground(lipgloss.Red),7273 copied: lipgloss.NewStyle().Foreground(lipgloss.Magenta).7374 UnderlineStyle(lipgloss.UnderlineDashed).UnderlineSpaces(true),
@@ -81,6 +82,5 @@ func newTheme(dark bool) theme {
8182 disabled: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack), // exempt from contrast, like Sheets8283 match: lipgloss.NewStyle().Foreground(match).Bold(true),8384 matchSelected: accent.Bold(true).Underline(true),84- button: lipgloss.NewStyle().Background(headerBg).Foreground(headerFg),8585 }8686 }