Skip to content

Commit 91818df

Browse files
author
gluck
committed
steps 4-7: one picker every drawer conforms to, ^V, and an alert that retires
THREE LISTS BECAME ONE. The completion menu had a sliding window with a marker and ^N/^P cycling; the drawer had a second window with a different marker and its own cursor; the help and status panels had neither -- so the list that tells you how to scroll was the one list you could not scroll, losing its bottom on any pane shorter than the panel with no way to reach it. picker.go is now the only one, and every drawer conforms: help, status, queue, notifications, command output and the hosted live views. j/k, the arrow cluster, u/d and gg/G drive whichever drawer is open -- moving a CURSOR where there is one and the WINDOW where there is not, so `j` means one thing to a reader everywhere. What a drawer still owns is its rows and its verb keys. A MESSAGE IS A GLANCE, NOT A LIST: one row, nothing to move, so it keeps the any-key-dismisses rule. Measured -- gating motions on "has a picker" alone left a failed `:999` note surviving every keystroke, because a note is a drawer too. AndMore is the truncation marker, in one spelling, on both edges of the window ("… 3 more above"). `figaro ls`, `form show` and the drawer had three. ^V toggles the BAR's verbosity -- names beside the glyphs, the model, the hints and the last-interaction time -- seeded by cli.status_verbose and distinct from ^O, which is verbose TOOL OUTPUT. In the command box the same chord is PASTE, which is Gluck's reversal of the "deliberately unbound" note in keymap.go: that argument was that quoting has no use where every printable byte is already literal, and paste is a different verb with an obvious one. THE ALERT RETIRES ON THE TICKER. A notification holds the bar's first slot for cli.notice_ttl (10s) and then goes, or a newer one displaces it. Nothing waits to be dismissed. The expiry rides the 11 Hz ticker that is already alive and already holds the render lock -- no second scheduler, no teardown path -- and the clock is read there rather than in render(), which stays pure. Two things only the pty could say. `g` in a drawer never armed pendG, because the drawer branch returns before the dispatcher's epilogue where the arming lives -- so gg reached the end of a list and never came back; the unit tests press keys through a path that armed it anyway. And a stubbed clipboard is now mandatory in this package (clipboard_stub_test.go): the first ^V implementation shelled out to wl-paste inside the keymap oracle, so the suite asserted against whatever the developer had last copied, and mine was a directory listing. The panel oracles are regenerated, deliberately and in one systematic move: every drawer owning its motions is exactly the change, and the cells that moved are the panel states' motion keys.
1 parent 9aee795 commit 91818df

16 files changed

Lines changed: 753 additions & 145 deletions

‎internal/cli/clipboard.go‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package cli
2+
3+
// Reading the system clipboard, for `^V` in the command box.
4+
//
5+
// Writing is OSC 52 (term.SetClipboard): the terminal takes a string and the
6+
// program never touches a selection. READING has no equivalent worth using --
7+
// OSC 52 queries are refused by most terminals by default, precisely because a
8+
// program that can read your clipboard can read whatever you last copied
9+
// anywhere. So this shells out, and says so when it cannot.
10+
11+
import (
12+
"os/exec"
13+
"strings"
14+
)
15+
16+
// clipboardReaders are tried in order. Wayland first, then X, then macOS: the
17+
// first that EXISTS wins, whether or not it returns anything, because a
18+
// present-but-empty clipboard is an answer and a missing tool is not.
19+
var clipboardReaders = [][]string{
20+
{"wl-paste", "--no-newline"},
21+
{"xclip", "-selection", "clipboard", "-o"},
22+
{"xsel", "--clipboard", "--output"},
23+
{"pbpaste"},
24+
{"powershell.exe", "-NoProfile", "-Command", "Get-Clipboard"},
25+
}
26+
27+
// clipboardRead is the seam. A test that shells out to wl-paste reads the
28+
// DEVELOPER'S clipboard: measured, and it is how a keymap oracle came back
29+
// carrying a directory listing that happened to be on mine. The package's
30+
// tests stub this (see clipboard_stub_test.go); production never touches it.
31+
var clipboardRead = clipboardText
32+
33+
// clipboardText returns the clipboard's contents, or a reason it could not.
34+
func clipboardText() (string, error) {
35+
var missing []string
36+
for _, argv := range clipboardReaders {
37+
path, err := exec.LookPath(argv[0])
38+
if err != nil {
39+
missing = append(missing, argv[0])
40+
continue
41+
}
42+
out, err := exec.Command(path, argv[1:]...).Output()
43+
if err != nil {
44+
return "", err
45+
}
46+
return string(out), nil
47+
}
48+
return "", errNoClipboardTool{tried: missing}
49+
}
50+
51+
type errNoClipboardTool struct{ tried []string }
52+
53+
func (e errNoClipboardTool) Error() string {
54+
if len(e.tried) == 0 {
55+
return "no clipboard tool available"
56+
}
57+
return "no clipboard tool: install one of " + strings.Join(e.tried, ", ")
58+
}
59+
60+
// pasteIntoLine folds pasted text into something a ONE-LINE editor can hold.
61+
// Newlines become spaces rather than being dropped: a multi-line paste into a
62+
// command line is nearly always a prompt that wants to arrive whole, and
63+
// silently truncating at the first newline would send half of it.
64+
func pasteIntoLine(s string) string {
65+
return strings.Join(strings.Fields(s), " ")
66+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package cli
2+
3+
// THE TEST BINARY DOES NOT TOUCH THE REAL CLIPBOARD. Every test in this package
4+
// runs with paste stubbed to "nothing on the clipboard", because a test that
5+
// shells out to wl-paste asserts against whatever the person running it last
6+
// copied -- which is not a test, it is a coin flip that happened to be green
7+
// on the machine where it was written. A test that wants paste sets this
8+
// itself, for the duration it needs.
9+
func init() {
10+
clipboardRead = func() (string, error) { return "", nil }
11+
}

‎internal/cli/drawer.go‎

Lines changed: 75 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ package cli
2929
// nothing else may write to the status row ever again.
3030

3131
import (
32-
"fmt"
3332
"strings"
3433

3534
"github.com/jack-work/figaro/internal/cmdkit"
@@ -93,43 +92,44 @@ type drawer struct {
9392
// for rows and forwards keys to it; it knows nothing about drawers and the
9493
// drawer knows nothing about forms. See cmdkit.LiveView.
9594
live cmdkit.LiveView
96-
// top is the first visible row: pagination is a window over rows, not a
97-
// truncation of them, so "… N more" can be honest in both directions.
98-
top int
95+
// pick is the LIST, and every behaviour of one: the window, the cursor,
96+
// the motions and the truncation marker. The drawer used to own a second
97+
// implementation of all four (see picker.go for the three that existed).
98+
// It is nil only for a drawer with no rows -- a message, or a hosted live
99+
// view that renders itself.
100+
pick *picker
99101
}
100102

101103
func (d *drawer) open() bool { return d.kind != drawerNone }
102104

103105
// close empties the drawer. Esc reaches here from every kind.
104106
func (d *drawer) close() {
105-
if d.live != nil {
106-
d.live.Close()
107-
}
108107
*d = drawer{}
109108
}
110109

111110
// showLive hosts a verb that renders itself. The drawer owns the region and
112111
// the dismissal; the view owns everything inside it.
113112
func (d *drawer) showLive(name string, v cmdkit.LiveView) {
114-
*d = drawer{kind: drawerLive, name: name, live: v, cursor: -1}
113+
d.kind, d.name, d.title = drawerLive, name, ""
114+
d.flash, d.pick = "", nil
115+
d.live = v
115116
}
116117

117118
// showMessage is the one-line drawer: an error, a result, a note. It carries
118119
// its own dismissal hint because a message that cannot be dismissed is a
119120
// message that has taken the screen hostage.
120121
func (d *drawer) showMessage(text string) {
121-
*d = drawer{kind: drawerMessage, name: "message", cursor: -1,
122-
rows: []drawerRow{staticRow(text)}}
122+
d.kind, d.name, d.title = drawerMessage, "message", ""
123+
d.flash, d.live = "", nil
124+
d.pick = newPicker([]drawerRow{staticRow(" " + text)}, false)
123125
}
124126

125127
// showList opens a list drawer, selecting the first selectable row when the
126128
// caller asked for selection.
127129
func (d *drawer) showList(name, title string, rows []drawerRow, selectable bool) {
128-
*d = drawer{kind: drawerList, name: name, title: title, rows: rows, cursor: -1}
129-
if selectable {
130-
d.cursor = d.nextSelectable(-1, 1)
131-
d.scrollToCursor()
132-
}
130+
d.kind, d.name, d.title = drawerList, name, title
131+
d.flash, d.live = "", nil
132+
d.pick = newPicker(rows, selectable)
133133
}
134134

135135
// visible is how many rows of the list fit: the fixed page, bounded by what the
@@ -153,76 +153,81 @@ func (d *drawer) visible(h int) int {
153153

154154
// nextSelectable finds the next selectable row from i in direction dir (+1/-1),
155155
// or i itself when there is none.
156-
func (d *drawer) nextSelectable(i, dir int) int {
157-
for j := i + dir; j >= 0 && j < len(d.rows); j += dir {
158-
if d.rows[j].selectable() {
159-
return j
160-
}
161-
}
162-
if i >= 0 && i < len(d.rows) && d.rows[i].selectable() {
163-
return i
164-
}
165-
// Nothing in that direction: find the first selectable row at all.
166-
for j := 0; j < len(d.rows); j++ {
167-
if d.rows[j].selectable() {
168-
return j
169-
}
170-
}
171-
return -1
172-
}
156+
// THE LIST BEHAVIOURS ARE THE PICKER'S. What used to be six methods here --
157+
// nextSelectable, moveSelection, scrollToCursor, scrollToCursorIn, selected,
158+
// removeSelected -- was a second implementation of what the completion menu
159+
// already did, differing from it in the marker it drew and in nothing else.
160+
// These forward; picker.go decides.
173161

174-
// moveSelection is ^N/^P inside a list drawer.
175162
func (d *drawer) moveSelection(dir int, h int) {
176-
if d.kind != drawerList || d.cursor < 0 {
163+
if d.pick == nil {
177164
return
178165
}
179-
if n := d.nextSelectable(d.cursor, dir); n != d.cursor {
180-
d.cursor = n
181-
}
182-
d.scrollToCursorIn(d.visible(h))
166+
d.pick.height = clampInt(h, 1, pickerRows)
167+
d.pick.move(dir)
183168
}
184169

185-
func (d *drawer) scrollToCursor() { d.scrollToCursorIn(drawerPageRows) }
170+
// scrollBy is j/k and the arrow cluster: it moves the SELECTION in a drawer
171+
// that has one and the WINDOW in a drawer that does not, which is what makes
172+
// `j` mean one thing to a reader across every drawer.
173+
func (d *drawer) scrollBy(dir int, h int) { d.moveSelection(dir, h) }
186174

187-
// scrollToCursorIn keeps the selected row inside the visible window.
188-
func (d *drawer) scrollToCursorIn(n int) {
189-
if d.cursor < 0 {
175+
func (d *drawer) halfPage(dir int, h int) {
176+
if d.pick == nil {
190177
return
191178
}
192-
if d.cursor < d.top {
193-
d.top = d.cursor
194-
}
195-
if d.cursor >= d.top+n {
196-
d.top = d.cursor - n + 1
179+
d.pick.height = clampInt(h, 1, pickerRows)
180+
d.pick.half(dir)
181+
}
182+
183+
func (d *drawer) toTop() {
184+
if d.pick != nil {
185+
d.pick.home()
197186
}
198-
if d.top < 0 {
199-
d.top = 0
187+
}
188+
func (d *drawer) toBottom() {
189+
if d.pick != nil {
190+
d.pick.end()
200191
}
201192
}
202193

203-
// selected is the row under the cursor, if any.
204194
func (d *drawer) selected() (drawerRow, bool) {
205-
if d.kind != drawerList || d.cursor < 0 || d.cursor >= len(d.rows) {
195+
if d.kind != drawerList || d.pick == nil {
206196
return drawerRow{}, false
207197
}
208-
return d.rows[d.cursor], true
198+
return d.pick.selected()
209199
}
210200

211-
// removeSelected drops the selected row and keeps the selection sensible: what
212-
// `x` on a queued message does to the list it was addressing.
213-
func (d *drawer) removeSelected() {
214-
if d.cursor < 0 || d.cursor >= len(d.rows) {
201+
// replaceRows swaps the list under a live cursor, keeping the selection ON THE
202+
// SAME ROW ID rather than at the same index: a queue that re-sorts under the
203+
// cursor on every poll is a queue nobody can hit `x` on, and an index-keyed
204+
// restore is how `x` came to drop the wrong message.
205+
func (d *drawer) replaceRows(rows []drawerRow, keepID string) {
206+
if d.pick == nil {
207+
d.pick = newPicker(rows, true)
215208
return
216209
}
217-
d.rows = append(d.rows[:d.cursor], d.rows[d.cursor+1:]...)
218-
d.cursor = d.nextSelectable(d.cursor-1, 1)
219-
d.scrollToCursor()
210+
sel := d.pick.selectable()
211+
top := d.pick.top
212+
d.pick = newPicker(rows, sel)
213+
d.pick.top = top
214+
if keepID != "" {
215+
for i, r := range rows {
216+
if r.id == keepID {
217+
d.pick.cursor = i
218+
break
219+
}
220+
}
221+
}
222+
d.pick.follow()
223+
}
224+
225+
func (d *drawer) removeSelected() {
226+
if d.pick != nil {
227+
d.pick.remove()
228+
}
220229
}
221230

222-
// lines renders the drawer's body: the title, the visible page, and a marker at
223-
// each end saying what is out of view. THE MARKERS ARE NOT DECORATION -- a list
224-
// that silently shows ten of seven hundred is a list that lies about the size of
225-
// the thing you are looking at.
226231
func (d *drawer) lines(w, h int) []string {
227232
if !d.open() {
228233
return nil
@@ -234,25 +239,17 @@ func (d *drawer) lines(w, h int) []string {
234239
}
235240
return rows
236241
}
237-
n := d.visible(h)
238-
if d.top > max(len(d.rows)-n, 0) {
239-
d.top = max(len(d.rows)-n, 0)
240-
}
241-
out := make([]string, 0, n+3)
242+
out := make([]string, 0, d.visible(h)+3)
242243
if d.title != "" {
243244
out = append(out, drawerGray(clipToWidth(d.title, w)))
244245
}
245-
if d.top > 0 {
246-
out = append(out, drawerGray(clipToWidth(fmt.Sprintf(" … %d more", d.top), w)))
247-
}
248-
end := min(d.top+n, len(d.rows))
249-
for i := d.top; i < end; i++ {
250-
out = append(out, d.rowLine(i, w))
251-
}
252-
if end < len(d.rows) {
253-
out = append(out, drawerGray(clipToWidth(fmt.Sprintf(" … %d more", len(d.rows)-end), w)))
246+
if d.pick == nil {
247+
return out
254248
}
255-
return out
249+
// EVERY LIST IS A PICKER. The window, the cursor, the selection marker and
250+
// the "… N more" on both edges come from one place; what a drawer still
251+
// owns is its rows and its verb keys.
252+
return append(out, d.pick.lines(drawerID(d.name), w, d.visible(h)-len(out))...)
256253
}
257254

258255
// rowLine draws one row, with the selection cue in the gutter the transcript

‎internal/cli/keymap.go‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,18 @@ var keymap = []keyBinding{
175175
chord: byteChord(0x0f), modes: inAnyBox &^ inJumpBox,
176176
open: opensPager, help: helpVerbose, input: inputToggleVerbose,
177177
},
178+
{
179+
// ^V is the BAR's verbosity: state names, the model, the hints and the
180+
// last-interaction time. A different axis from ^O above, which is
181+
// verbose TOOL OUTPUT -- and the two are deliberately different keys
182+
// because they answer different questions.
183+
chord: byteChord(0x16), modes: inAnyBox &^ inJumpBox,
184+
open: opensPager, help: helpBarVerbose, input: inputToggleBarVerbose,
185+
},
186+
{
187+
chord: ctrlChord('v'), modes: inAnyBox &^ inJumpBox,
188+
open: opensPager, help: helpNone, input: inputToggleBarVerbose,
189+
},
178190
{
179191
// In incipit 'y' copies the aria id, a feature of its own, not a
180192
// reason to open the pager. In the search box it is literal text.
@@ -422,6 +434,8 @@ var keymap = []keyBinding{
422434

423435
// Completion.
424436
{chord: byteChord(0x09), modes: inJumpBox, open: staysInline, why: "only reachable with the command line up", help: helpCmdComplete, pager: cmdComplete},
437+
{chord: byteChord(0x16), modes: inJumpBox, open: staysInline, why: "only reachable with the command line up", help: helpCmdPaste, pager: cmdPaste},
438+
{chord: ctrlChord('v'), modes: inJumpBox, open: staysInline, why: "only reachable with the command line up", help: helpNone, pager: cmdPaste},
425439
{chord: metaChord('?'), modes: inJumpBox, open: staysInline, why: "only reachable with the command line up", help: helpNone, pager: cmdListComplete},
426440
{chord: metaChord('*'), modes: inJumpBox, open: staysInline, why: "only reachable with the command line up", help: helpNone, pager: cmdInsertComplete},
427441

@@ -448,11 +462,14 @@ var keymap = []keyBinding{
448462

449463
// NOT BOUND, each for a reason:
450464
//
451-
// ^V / ^Q (quoted-insert) Every printable byte in this box is ALREADY
465+
// ^Q (quoted-insert) Every printable byte in this box is ALREADY
452466
// literal text -- there is no binding to escape
453467
// past -- and the only thing quoting could add
454468
// is a control character the line can neither
455-
// render nor send.
469+
// render nor send. (^V was on this list for the
470+
// same reason and has been taken off it: it is
471+
// PASTE here, which is a different verb with an
472+
// obvious use.)
456473
// ^X <anything> readline's second keymap: macros (^X( ^X) ^Xe),
457474
// ^X^U (undo, which ^_ already is), ^X Del (^U),
458475
// ^X^R (re-read inputrc, meaningless here). A
@@ -524,6 +541,8 @@ const (
524541
helpCmdHistory
525542
helpCmdComplete
526543
helpCmdEdit
544+
helpCmdPaste
545+
helpBarVerbose
527546
helpCmdAbort
528547
helpDrawerDrop
529548
)
@@ -562,6 +581,8 @@ var helpRows = []helpRow{
562581
{helpDrawerDrop, "(in a list) x", "drop the selected entry (queue)"},
563582
{helpYank, "y", "copy selection (or aria id if none)"},
564583
{helpVerbose, "^O", "toggle verbose tool output"},
584+
{helpBarVerbose, "^V", "toggle the status bar's detail (names, model, time)"},
585+
{helpCmdPaste, "(in :) ^V", "paste the clipboard"},
565586
{helpSelect, "^N/^P", "select next/previous node"},
566587
{helpSelectExtend, "^N/^P + Shift", "extend node selection (Alt+^N/^P fallback)"},
567588
{helpExpand, "Enter", "expand tools within the selection"},

0 commit comments

Comments
 (0)