1package client
2
3import (
4 "fmt"
5
6 renderer "github.com/bnema/vev-vt"
7 ansirenderer "github.com/bnema/vev-vt/ansi"
8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/protocol"
10 "github.com/bnema/vev/internal/usecase/picker"
11)
12
13// This file owns the client side of the picker interaction: source admission,
14// local model ownership, and typed commit/cancel. It mirrors the
15// inventoryRelay admission discipline (navigation_inventory.go) but never
16// canonicalizes line order: picker order is semantically significant. The
17// loop consumes terminal-pump records, never raw reads; commit sends a typed
18// PickerSelection on the ordered control channel, never picker bytes toward
19// the PTY.
20
21// pickerInteraction tracks one picker namespace: the admitted interaction ID
22// and the newest interaction this attachment retired. A retired interaction is
23// never resurrected by a late snapshot.
24type pickerInteraction struct {
25 open bool
26 interaction uint64
27 retired uint64
28 // intent is the intent the serving daemon published for this interaction.
29 // Move intents present destination rows instead of navigation rows.
30 intent protocol.PickerIntent
31 // sourceRevisions tracks the newest admitted revision per source. Every
32 // source publishes its own revision, so one source's refresh never
33 // invalidates a selection displayed from another.
34 sourceRevisions map[string]uint64
35}
36
37// setOpen starts or stops the interaction namespace. Superseding an open
38// interaction retires it too: a late snapshot for the replaced ID must not
39// take presentation back. Closing retires the namespace for good.
40func (p *pickerInteraction) setOpen(open bool, interaction uint64) {
41 if p == nil {
42 return
43 }
44 if open && interaction != p.interaction && p.open && p.interaction > p.retired {
45 p.retired = p.interaction
46 }
47 p.open = open
48 if open && interaction != p.interaction {
49 p.interaction = interaction
50 p.sourceRevisions = make(map[string]uint64)
51 }
52 if !open && interaction > p.retired {
53 p.retired = interaction
54 }
55 if !open {
56 p.intent = 0
57 p.sourceRevisions = nil
58 }
59}
60
61// setIntent records the intent published with the offer that opened the
62// interaction.
63func (p *pickerInteraction) setIntent(interaction uint64, intent protocol.PickerIntent) {
64 if p == nil || !p.open || p.interaction != interaction {
65 return
66 }
67 p.intent = intent
68}
69
70// admitSnapshot validates one source publication against the open interaction
71// and reports whether it carries a newer revision worth displaying. Retired
72// interactions, foreign interactions, and older or duplicate source revisions
73// discard.
74func (p *pickerInteraction) admitSnapshot(snapshot protocol.PickerSnapshot, wantsEvent bool) bool {
75 if p == nil {
76 return false
77 }
78 if protocol.ValidatePickerSnapshot(snapshot) != nil {
79 return false
80 }
81 if snapshot.InteractionID <= p.retired {
82 return false
83 }
84 if !p.open || snapshot.InteractionID != p.interaction {
85 return false
86 }
87 if snapshot.SourceRevision <= p.sourceRevisions[snapshot.SourceID] {
88 return false
89 }
90 if wantsEvent && p.intent == 0 {
91 // The offer carries the intent; a snapshot that arrives first is
92 // still admitted so a lost offer cannot wedge the interaction.
93 }
94 p.sourceRevisions[snapshot.SourceID] = snapshot.SourceRevision
95 return true
96}
97
98// pickerLoop owns one admitted *picker.Model plus the source publication it
99// was built from. Cursor, search, and sort are local-only presentation;
100// commit and cancel cross the wire as typed messages. The daemon revalidates
101// the committed source revision and key, so the client never keeps a second
102// copy of the row set.
103type pickerLoop struct {
104 model *picker.Model
105 interaction uint64
106 intent protocol.PickerIntent
107 sources map[string]protocol.PickerSnapshot
108 order []string
109 rows map[string]pickerRowIdentity
110}
111
112type pickerRowIdentity struct {
113 sourceID string
114 sourceRevision uint64
115 key string
116}
117
118// pickerLoopFromSnapshot builds the local model from the first admitted source.
119// Source-owned opaque keys are namespaced only inside the presentation model;
120// commits translate them back to the exact source identity published on wire.
121func pickerLoopFromSnapshot(snapshot protocol.PickerSnapshot, intent protocol.PickerIntent, sort picker.SortMode) *pickerLoop {
122 snapshot = protocol.NormalizePickerSnapshot(snapshot)
123 loop := &pickerLoop{interaction: snapshot.InteractionID, intent: intent, sources: make(map[string]protocol.PickerSnapshot)}
124 loop.sources[snapshot.SourceID] = snapshot
125 loop.order = append(loop.order, snapshot.SourceID)
126 projection := pickerSnapshotProjection(snapshot, sort)
127 cursor := projection.Cursor
128 if cursor.Key != "" {
129 cursor.Key = fmt.Sprintf("%d:%s:%s", len(snapshot.SourceID), snapshot.SourceID, cursor.Key)
130 }
131 loop.rebuild(cursor, sort)
132 return loop
133}
134
135// replaceLines applies one source publication while retaining every other
136// source. Independent source revisions therefore cannot invalidate each other.
137func (l *pickerLoop) replaceLines(snapshot protocol.PickerSnapshot) {
138 snapshot = protocol.NormalizePickerSnapshot(snapshot)
139 if l == nil || l.model == nil || snapshot.InteractionID != l.interaction {
140 return
141 }
142 if _, exists := l.sources[snapshot.SourceID]; !exists {
143 l.order = append(l.order, snapshot.SourceID)
144 }
145 selectedKey := ""
146 if selected, ok := l.model.Selected(); ok {
147 selectedKey = selected.Key
148 }
149 l.sources[snapshot.SourceID] = snapshot
150 l.rebuild(protocol.PickerCursor{Key: selectedKey, Index: -1}, l.model.SortMode(), true)
151}
152
153func (l *pickerLoop) rebuild(cursor protocol.PickerCursor, sort picker.SortMode, preserveEditor ...bool) {
154 lines := make([]protocol.PickerLine, 0)
155 rows := make(map[string]pickerRowIdentity)
156 cursorKey := ""
157 for _, sourceID := range l.order {
158 snapshot, ok := l.sources[sourceID]
159 if !ok {
160 continue
161 }
162 projection := pickerSnapshotProjection(snapshot, sort)
163 for index, published := range projection.Lines {
164 line := published
165 if line.Key != "" {
166 presented := fmt.Sprintf("%d:%s:%s", len(sourceID), sourceID, line.Key)
167 rows[presented] = pickerRowIdentity{sourceID: sourceID, sourceRevision: snapshot.SourceRevision, key: line.Key}
168 if presented == cursor.Key || cursor.Key == "" && sourceID == snapshot.SourceID && index == projection.Cursor.Index {
169 cursorKey = presented
170 }
171 line.Key = presented
172 }
173 lines = append(lines, line)
174 }
175 }
176 l.rows = rows
177 modelCursor := protocol.PickerCursor{Key: cursorKey, Index: -1}
178 if len(preserveEditor) != 0 && preserveEditor[0] && l.model != nil {
179 l.model.ReplaceProjection(lines, modelCursor, sort)
180 return
181 }
182 l.model = picker.New(lines, picker.Config{Intent: l.intent, Cursor: modelCursor, Sort: sort})
183}
184
185// up moves the cursor one focusable row up.
186func (l *pickerLoop) up() {
187 if l == nil || l.model == nil {
188 return
189 }
190 l.model.Up()
191}
192
193// down moves the cursor one focusable row down.
194func (l *pickerLoop) down() {
195 if l == nil || l.model == nil {
196 return
197 }
198 l.model.Down()
199}
200
201// enterSearch begins search mode; text inserts through insert().
202func (l *pickerLoop) enterSearch() {
203 if l == nil || l.model == nil {
204 return
205 }
206 if !l.model.SearchActive() {
207 l.model.EnterSearch()
208 }
209}
210
211// insert types one rune into the active search, entering search first.
212func (l *pickerLoop) insert(r rune) {
213 if l == nil || l.model == nil {
214 return
215 }
216 l.enterSearch()
217 l.model.InsertSearch(r)
218}
219
220// backspace deletes one search rune; Escape handling distinguishes
221// search-exit from picker-close at the call site.
222func (l *pickerLoop) backspace() {
223 if l == nil || l.model == nil {
224 return
225 }
226 l.model.BackspaceSearch()
227}
228
229// escape handles one Escape press: exit search when searching, else report
230// picker-close. The boolean is true when the caller must send PickerClose.
231func (l *pickerLoop) escape() bool {
232 if l == nil || l.model == nil {
233 return true
234 }
235 if l.model.SearchActive() {
236 l.model.ExitSearch()
237 return false
238 }
239 return true
240}
241
242// toggleSort flips the local ordering between recency and grouped and reports
243// whether the display changed.
244func (l *pickerLoop) toggleSort() bool {
245 if l == nil || l.model == nil {
246 return false
247 }
248 next := picker.SortGrouped
249 if l.model.SortMode() == picker.SortGrouped {
250 next = picker.SortRecent
251 }
252 selected := protocol.PickerCursor{Index: -1}
253 if line, ok := l.model.Selected(); ok {
254 selected.Key = line.Key
255 }
256 l.rebuild(selected, next, true)
257 return true
258}
259
260func pickerSnapshotProjection(snapshot protocol.PickerSnapshot, sort picker.SortMode) protocol.PickerProjection {
261 if sort == picker.SortGrouped {
262 return snapshot.Grouped
263 }
264 return snapshot.Recent
265}
266
267// selectedAction reports the action the displayed row authorises: navigation
268// rows navigate, move rows move. The daemon publishes the bits, so the client
269// never invents authority.
270func (l *pickerLoop) selectedAction() (protocol.PickerAction, bool) {
271 if l == nil || l.model == nil {
272 return 0, false
273 }
274 line, ok := l.model.Selected()
275 if !ok {
276 return 0, false
277 }
278 switch {
279 case line.Actions&protocol.PickerCanNavigate != 0:
280 return protocol.PickerActionNavigate, true
281 case line.Actions&protocol.PickerCanMove != 0:
282 return protocol.PickerActionMove, true
283 default:
284 return 0, false
285 }
286}
287
288// killAction reports whether the cursor row authorises destruction.
289func (l *pickerLoop) killAction() bool {
290 if l == nil || l.model == nil {
291 return false
292 }
293 line, ok := l.model.Selected()
294 return ok && line.Actions&protocol.PickerCanKill != 0
295}
296
297// defaultPickerSort is the client-local initial ordering mode: sessions
298// grouped under their host, local first.
299func defaultPickerSort() picker.SortMode { return picker.SortGrouped }
300
301// pickerPreviewSize is the viewport the modal can display for one terminal: the
302// preview pane inside the shared picker geometry.
303func pickerPreviewSize(terminal domain.Size) domain.Size {
304 return picker.Size(picker.PreviewRect(terminal))
305}
306
307// emptyPickerPreview is the zero preview used until the daemon publishes one.
308func emptyPickerPreview() picker.Preview { return picker.Preview{} }
309
310// clampPreviewDimension bounds one requested viewport dimension.
311func clampPreviewDimension(value int, maxValue uint16) uint16 {
312 if value <= 0 {
313 return 1
314 }
315 if value > int(maxValue) {
316 return maxValue
317 }
318 return uint16(value)
319}
320
321// pickerRenderer encodes one picker frame to terminal bytes. It owns a
322// dedicated ANSI renderer shadow (never the daemon output shadow): the
323// client-picker frame is a local presentation overlay, not daemon output,
324// so its bytes never enter the output ACK window.
325type pickerRenderer struct {
326 renderer *ansirenderer.Renderer
327 profile ansirenderer.ColorProfile
328 renderStyles []picker.RenderStyles
329 size domain.Size
330 // prevBounds is the box drawn last: when it moves or resizes, its previous
331 // cells are blanked together with the new ones, and the rest of the screen
332 // stays untouched.
333 prevBounds *domain.Rect
334 // primed records that the renderer shadow was seeded with the screen as it
335 // is, so the first box is diffed against it instead of replacing the screen.
336 primed bool
337}
338
339func newPickerRenderer(profile ansirenderer.ColorProfile) *pickerRenderer {
340 return &pickerRenderer{profile: profile}
341}
342
343// pickerColorProfile maps the terminal's detected color capability to the
344// picker renderer profile. A terminal without truecolor gets indexed colors,
345// so RGB surfaces are quantized instead of dropped (#280).
346func pickerColorProfile(trueColor bool) ansirenderer.ColorProfile {
347 if trueColor {
348 return ansirenderer.ColorProfileTrueColor
349 }
350 return ansirenderer.ColorProfileANSI256
351}
352
353// render composes the loop model into terminal bytes for one display
354// refresh. Previews arrive as source data (picker_preview.go) and are attached
355// to the model by the caller; a full redraw every refresh keeps the shadow
356// trivially consistent: the frame is small and modal, never a PTY stream.
357// render composes one floating modal over the session: the picker never takes
358// the whole screen. The box is resolved from the shared picker geometry, its
359// inner content is rendered into that rectangle, and only the box's own cells
360// are written, so the session stays visible around it until the daemon's
361// authoritative repaint after the close.
362func (r *pickerRenderer) render(loop *pickerLoop, size domain.Size, preview picker.Preview) []byte {
363 if r == nil || loop == nil || loop.model == nil {
364 return nil
365 }
366 if size.Cols <= 0 || size.Rows <= 0 {
367 return nil
368 }
369 if r.renderer == nil || r.size != size {
370 r.renderer = ansirenderer.NewWithColorProfile(ansirenderer.Capabilities{}, r.profile)
371 r.size = size
372 // Keep prevBounds across a resize. The new renderer is primed with an
373 // empty shadow below, so damaging the old rectangle explicitly clears
374 // the part of the previous modal that remains visible at the new size.
375 r.primed = false
376 }
377 // The picker is a floating box over a screen the client does not own: a
378 // fresh renderer would repaint the whole terminal from its empty shadow and
379 // wipe the session. Seed the shadow with the empty screen instead, without
380 // emitting anything, so only the box's own cells are ever written.
381 if !r.primed {
382 if prepared, err := r.renderer.Prepare(renderer.NewFrame(size.Cols, size.Rows), nil, false); err == nil {
383 prepared.Commit()
384 r.primed = true
385 }
386 }
387 presentation := picker.Modal.Resolve(size)
388 base := renderer.NewFrame(size.Cols, size.Rows)
389 border := renderer.DefaultStyle()
390 border.Attrs |= renderer.AttrDim
391 picker.Modal.CompositePresentation(base, presentation, border, renderer.DefaultStyle())
392 inner := loop.model.Render(picker.Size(presentation.Inner), preview, r.renderStyles...)
393 copyFrameRect(base, presentation.Inner, inner)
394
395 // Only the box is written: the session stays on screen around it. A box
396 // that moved (a resize, or a wider title) blanks the cells it used to own.
397 var damage []ansirenderer.Damage
398 if r.prevBounds != nil && *r.prevBounds != presentation.Bounds {
399 damage = append(damage, damageRect(*r.prevBounds))
400 }
401 damage = append(damage, damageRect(presentation.Bounds))
402 data, err := r.renderer.Draw(base, damage)
403 if err != nil {
404 return nil
405 }
406 bounds := presentation.Bounds
407 r.prevBounds = &bounds
408 return data
409}
410
411// invalidate forgets everything the renderer believes is on screen. The next
412// render re-primes an empty shadow and writes the whole box again: someone
413// else (an attachment's output or its authoritative repaint) owned the
414// terminal since the last picker frame, so a diff against the old shadow would
415// leave parts of the box missing.
416func (r *pickerRenderer) invalidate() {
417 if r == nil {
418 return
419 }
420 r.renderer = nil
421 r.primed = false
422 r.prevBounds = nil
423}
424
425// damageRect is the damage one rectangle of a composed frame produces.
426func damageRect(rect domain.Rect) ansirenderer.Damage {
427 return ansirenderer.Damage{
428 Kind: renderer.DamageText,
429 X: rect.X, Y: rect.Y,
430 Width: rect.Width, Height: rect.Height, Count: 1,
431 }
432}
433
434// copyFrameRect blits src into dst at the target rectangle, clipped to both.
435func copyFrameRect(dst renderer.Frame, target domain.Rect, src renderer.Frame) {
436 left := max(target.X, 0)
437 top := max(target.Y, 0)
438 right := min(target.X+target.Width, dst.Width)
439 bottom := min(target.Y+target.Height, dst.Height)
440 if left >= right || top >= bottom {
441 return
442 }
443 sourceX := left - target.X
444 sourceY := top - target.Y
445 width := min(right-left, src.Width-sourceX)
446 height := min(bottom-top, src.Height-sourceY)
447 if width <= 0 || height <= 0 {
448 return
449 }
450 for y := range height {
451 for x := range width {
452 dst.Set(left+x, top+y, src.Cell(sourceX+x, sourceY+y))
453 }
454 }
455}
456
457// selection builds the typed selection for the row under the cursor at the
458// displayed source revision. Zero picker bytes travel toward the PTY: the
459// opaque key plus revision ride a PickerSelection on the ordered control
460// channel and the owning source resolves them. A kill additionally requires
461// the row to authorise destruction, so the source can verify it again.
462func (l *pickerLoop) selection(action protocol.PickerAction, causeActionID uint64) (protocol.PickerSelection, bool) {
463 if l == nil || l.model == nil {
464 return protocol.PickerSelection{}, false
465 }
466 line, ok := l.model.Selected()
467 if !ok || (action == protocol.PickerActionKill && line.Actions&protocol.PickerCanKill == 0) {
468 return protocol.PickerSelection{}, false
469 }
470 identity, ok := l.rows[line.Key]
471 if !ok {
472 return protocol.PickerSelection{}, false
473 }
474 selection := protocol.PickerSelection{
475 CauseActionID: causeActionID, InteractionID: l.interaction,
476 SourceID: identity.sourceID, SourceRevision: identity.sourceRevision,
477 Key: identity.key, Action: action,
478 }
479 if protocol.ValidatePickerSelection(selection) != nil {
480 return protocol.PickerSelection{}, false
481 }
482 return selection, true
483}
484
485// selectionKey returns the source key a selection of action would carry, or
486// the empty string when the cursor row does not admit it.
487func (l *pickerLoop) selectionKey(action protocol.PickerAction) string {
488 selection, ok := l.selection(action, 0)
489 if !ok {
490 return ""
491 }
492 return selection.Key
493}
494
495// displayedKey names the source key of the row under the cursor, whether or
496// not it admits an action.
497func (l *pickerLoop) displayedKey() (string, bool) {
498 if l == nil || l.model == nil {
499 return "", false
500 }
501 line, ok := l.model.Cursor()
502 if !ok {
503 return "", false
504 }
505 identity, ok := l.rows[line.Key]
506 return identity.key, ok
507}
508
509// sortMode reports the loop's current ordering, or ok=false without a model.
510func (l *pickerLoop) sortMode() (picker.SortMode, bool) {
511 if l == nil || l.model == nil {
512 return 0, false
513 }
514 return l.model.SortMode(), true
515}
516
517// pickerOp is the presentation decision one driver operation asks the attach
518// loop to carry out. Close wins over commit when both land in one op,
519// mirroring the overlay exit short-circuit.
520type pickerOp struct {
521 commit bool
522 kill bool
523 close bool
524 // exit marks the explicit exit key (Ctrl+C). It always travels with close:
525 // over a live attachment it cancels back to that attachment like any close,
526 // and only a picker with no attachment to return to ends the process on it.
527 exit bool
528}
529
530// drive applies one ui-driver operation to an open loop. Keys
531// drive cursor/search/exit: arrows and j/k move in normal mode (j/k are
532// literal in search), `/` enters search, `s` reorders locally, `x` asks to
533// destroy the cursor row, `q`/Ctrl-C/Escape close in normal mode (Escape
534// exits search first), Backspace edits the query. Text runes insert only
535// while search is active; normal-mode typing is ignored exactly like the
536// overlay.
537func (loop *pickerLoop) drive(keys []string, text string) (op pickerOp, changed bool) {
538 if loop == nil || loop.model == nil {
539 return pickerOp{}, false
540 }
541 insertLit := func(r rune) {
542 if loop.model.SearchActive() {
543 loop.insert(r)
544 }
545 }
546 for _, key := range keys {
547 active := loop.model.SearchActive()
548 beforeIndex, beforeSearch := loop.model.SelectedIndex(), active
549 beforeQuery := loop.model.Query()
550 switch key {
551 case "Up":
552 loop.up()
553 case "Down":
554 loop.down()
555 case "j":
556 if active {
557 loop.insert('j')
558 } else {
559 loop.down()
560 }
561 case "k":
562 if active {
563 loop.insert('k')
564 } else {
565 loop.up()
566 }
567 case "Enter":
568 // Commit is typed at the call site via selection; the
569 // key itself sends zero bytes toward the PTY.
570 op.commit = true
571 case "Escape":
572 if loop.escape() {
573 return pickerOp{close: true}, changed
574 }
575 case "Backspace":
576 if active {
577 loop.backspace()
578 }
579 case "/":
580 if active {
581 loop.insert('/')
582 } else {
583 loop.enterSearch()
584 }
585 case "s":
586 if active {
587 loop.insert('s')
588 } else {
589 changed = loop.toggleSort() || changed
590 }
591 case "x":
592 if active {
593 loop.insert('x')
594 } else {
595 op.kill = true
596 }
597 case "q":
598 if active {
599 loop.insert('q')
600 } else {
601 return pickerOp{close: true}, changed
602 }
603 case "Ctrl+C":
604 // The overlay exits on Ctrl-C in both modes; the byte
605 // itself never reaches the query. It is the explicit exit key.
606 return pickerOp{close: true, exit: true}, changed
607 default:
608 if len(key) == 1 {
609 insertLit(rune(key[0]))
610 }
611 }
612 if loop.model.SelectedIndex() != beforeIndex || loop.model.SearchActive() != beforeSearch || loop.model.Query() != beforeQuery {
613 changed = true
614 }
615 }
616 for _, r := range text {
617 if loop.model.SearchActive() {
618 loop.insert(r)
619 changed = true
620 }
621 }
622 return op, changed
623}
624
625// pickerInputBatch is one decoded batch: ordered input events.
626type pickerInputBatch struct {
627 events []pickerEvent
628}
629
630// pickerConsumeOutcome is one decoded operation reported by the stdin
631// pump to the attach loop. The pump decodes and identifies; the attach
632// loop is the only writer of the picker model, so the outcome carries the
633// ordered events and their interaction identity, never a mutation or a
634// message. actionID echoes the admitted record so the loop completes the
635// right ui-driver action.
636type pickerConsumeOutcome struct {
637 consumed bool
638 actionID uint64
639 generation uint64
640 interaction uint64
641 events []pickerEvent
642}
643
644// acceptOutcome reports whether this operation still belongs to the
645// interaction the attachment currently presents. Retired generations and
646// interactions drop.
647func (o pickerConsumeOutcome) acceptOutcome(interaction, generation uint64) bool {
648 return o.consumed && o.interaction != 0 && o.interaction == interaction && o.generation == generation
649}
650
651// apply applies one decoded batch in arrival order. Every event
652// is applied: a terminal read can legitimately carry several keystrokes, so
653// batch size never decides whether input is a command. Paste protection is
654// the decoder's paste state, not a heuristic here: a bracketed paste's
655// content never reaches this function.
656func (loop *pickerLoop) apply(events []pickerEvent) (op pickerOp, changed bool) {
657 if loop == nil || loop.model == nil || len(events) == 0 {
658 return pickerOp{}, false
659 }
660 for _, event := range events {
661 if event.kind == pickerEventRune {
662 if loop.model.SearchActive() {
663 loop.insert(event.r)
664 changed = true
665 }
666 continue
667 }
668 eventOp, eventChanged := loop.drive([]string{event.key}, "")
669 changed = changed || eventChanged
670 if eventOp.close {
671 return pickerOp{close: true, exit: eventOp.exit}, changed
672 }
673 op.commit = op.commit || eventOp.commit
674 op.kill = op.kill || eventOp.kill
675 }
676 return op, changed
677}