okf-tui 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.okf/decisions/index.md +12 -0
  3. data/.okf/decisions/invents-no-analysis.md +53 -0
  4. data/.okf/decisions/no-version-ceilings.md +69 -0
  5. data/.okf/decisions/okf-capability-drift.md +120 -0
  6. data/.okf/decisions/one-door-the-plugin-seam.md +131 -0
  7. data/.okf/decisions/registry-write-boundary.md +175 -0
  8. data/.okf/decisions/ruby-floor.md +59 -0
  9. data/.okf/decisions/search-facade-coupling.md +146 -0
  10. data/.okf/decisions/undeclared-width-dependency.md +73 -0
  11. data/.okf/index.md +28 -0
  12. data/.okf/interaction/cross-bundle-scope.md +61 -0
  13. data/.okf/interaction/deferred-search.md +49 -0
  14. data/.okf/interaction/esc-peels-one-layer.md +70 -0
  15. data/.okf/interaction/filter-escalates-to-search.md +57 -0
  16. data/.okf/interaction/following-links.md +82 -0
  17. data/.okf/interaction/index.md +12 -0
  18. data/.okf/interaction/key-routing.md +84 -0
  19. data/.okf/interaction/which-registry.md +85 -0
  20. data/.okf/log.md +38 -0
  21. data/.okf/rendering/ansi-aware-width.md +74 -0
  22. data/.okf/rendering/index.md +8 -0
  23. data/.okf/rendering/markdown-rendering-trap.md +63 -0
  24. data/.okf/rendering/status-vocabulary.md +45 -0
  25. data/.okf/rendering/whole-frame-painting.md +52 -0
  26. data/.okf/testing/ci-matrix.md +80 -0
  27. data/.okf/testing/headless-frames.md +74 -0
  28. data/.okf/testing/index.md +8 -0
  29. data/.okf/testing/pty-test.md +73 -0
  30. data/CHANGELOG.md +239 -0
  31. data/LICENSE.txt +201 -0
  32. data/NOTICE +10 -0
  33. data/README.md +194 -0
  34. data/lib/okf/plugin.rb +63 -0
  35. data/lib/okf/tui/app.rb +1908 -0
  36. data/lib/okf/tui/cli.rb +154 -0
  37. data/lib/okf/tui/model.rb +410 -0
  38. data/lib/okf/tui/refs.rb +63 -0
  39. data/lib/okf/tui/ui.rb +308 -0
  40. data/lib/okf/tui/version.rb +7 -0
  41. data/lib/okf/tui/views.rb +1648 -0
  42. data/lib/okf/tui/workspace.rb +527 -0
  43. data/lib/okf/tui.rb +76 -0
  44. metadata +229 -0
@@ -0,0 +1,1908 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-cursor"
4
+ require "tty-reader"
5
+ require "tty-screen"
6
+ require "tty-markdown"
7
+
8
+ require_relative "ui"
9
+ require_relative "model"
10
+ require_relative "views"
11
+
12
+ module OKF::TUI
13
+ # The interactive loop: hold the state, read a key, mutate the state, repaint.
14
+ #
15
+ # Painting is whole-frame. Every view returns a rectangle of rows already
16
+ # padded to the terminal width, so a repaint is "cursor home, print N rows" —
17
+ # each row overwrites the one beneath it exactly, which is what keeps the
18
+ # screen from flickering without any diffing machinery.
19
+ #
20
+ # Two axes of state, and keeping them apart is what makes the views simple:
21
+ # the **active bundle** (what browse, health and graph are about) and the
22
+ # **scope** (which bundles a search covers). Only the bundles view moves the
23
+ # first; only the scope keys move the second.
24
+ class App
25
+ # Control keys, named so the source carries no invisible bytes.
26
+ CTRL_C = "\u0003"
27
+ CTRL_D = "\u0004"
28
+ CTRL_U = "\u0015"
29
+ DELETE = "\u007F"
30
+ # Joins a group slug to one of its members inside a prompt's subject. A NUL can
31
+ # appear in neither, which is why okf uses the same byte to key its own corpus.
32
+ MEMBER_REF = "\u0000"
33
+ ESCAPE = "\e"
34
+ TAB = "\t"
35
+ UP = "\e[A"
36
+ DOWN = "\e[B"
37
+
38
+ # The views that are about one bundle, and so have nothing to show when
39
+ # none can be read.
40
+ SINGLE_BUNDLE_VIEWS = %i[browse health graph].freeze
41
+
42
+ # The views that are a single scrolling page rather than a selectable list.
43
+ CONTENT_VIEWS = %i[health help].freeze
44
+
45
+ # Six, and `help` stays last where a reader expects it. There was a seventh —
46
+ # an `index` map beside `browse` — and it went because browse answers the same
47
+ # question with a key the reader already has: `/index` lists every way in, one
48
+ # per directory, and opens the authored file. What the map could show and browse
49
+ # cannot is real but narrow (the tree indented as it nests, `▪`/`·` for a
50
+ # directory that authored no index.md, okf's synthesized listing for one, and
51
+ # the per-directory type/tag rollups), and it did not earn a permanent tab.
52
+ TABS = [
53
+ [ :bundles, "bundles" ],
54
+ [ :browse, "browse" ],
55
+ [ :search, "search" ],
56
+ [ :graph, "graph" ],
57
+ [ :health, "health" ],
58
+ [ :help, "help" ]
59
+ ].freeze
60
+
61
+ KEY_VIEWS = { "1" => :bundles, "2" => :browse, "3" => :search,
62
+ "4" => :graph, "5" => :health, "6" => :help }.freeze
63
+
64
+ # The views with a list to narrow. `/` starts typing in each of them; the
65
+ # filter belongs to the view, so switching away drops it rather than
66
+ # carrying a bundle filter into the concept list.
67
+ FILTERABLE_VIEWS = %i[bundles browse graph].freeze
68
+
69
+ # A pending question on the status line. `kind` decides what the answer does;
70
+ # `free_text` says whether it collects a line or a single confirming key.
71
+ class Prompt
72
+ attr_reader :kind, :label, :buffer, :subject
73
+
74
+ def initialize(kind:, label:, buffer:, subject:, free_text:)
75
+ @kind = kind
76
+ @label = label
77
+ @buffer = buffer
78
+ @subject = subject
79
+ @free_text = free_text
80
+ end
81
+
82
+ def free_text?
83
+ @free_text
84
+ end
85
+ end
86
+
87
+ def initialize(dirs: [], ref_slugs: {}, home: nil, cwd: nil, output: $stdout)
88
+ @output = output
89
+ @workspace = Workspace.new(dirs: dirs, ref_slugs: ref_slugs, home: home, cwd: cwd)
90
+ # With one readable bundle there is nothing to pick between, so open on it
91
+ # directly. Otherwise start on the list — including when nothing is
92
+ # readable, where the list is the only place the problem can be seen.
93
+ @view = @workspace.entries.length == 1 && @workspace.model ? :browse : :bundles
94
+ @pane = :list
95
+ @cursor = 0
96
+ @filter = +""
97
+ @query = +""
98
+ @searched = +"" # the query the current results belong to; see #search_hits
99
+ @graph_facet = nil
100
+ @search_focus = :results
101
+ @search_mode = :fuzzy
102
+ @find = +"" # find-in-document, when the detail pane has focus
103
+ @finding = false
104
+ @find_index = 0
105
+ @find_jump = false
106
+ @filtering = false
107
+ @following = false # the link picker, over the document in the detail pane
108
+ @follow_cursor = 0
109
+ @follow_scroll = 0
110
+ @group_cursor = 0 # the groups pane
111
+ @group_scroll = 0
112
+ @member_cursor = 0 # the member pane
113
+ @trail = [] # where the reader was before each jump
114
+ @prompt = nil
115
+ @scroll = 0
116
+ @detail_scroll = 0
117
+ @content_scroll = 0
118
+ # health's summary pane, which scrolls apart from its findings
119
+ @health_scroll = 0
120
+ @body_cache = {}
121
+ @message = nil
122
+ @running = true
123
+ @quit_armed = false
124
+ @cursor_control = TTY::Cursor
125
+ @reader = TTY::Reader.new(interrupt: :exit, track_history: false)
126
+ reset_cursor
127
+ end
128
+
129
+ # The active bundle. Nil only when nothing in the workspace could be read.
130
+ def model
131
+ workspace.model
132
+ end
133
+
134
+ def run
135
+ @output.print @cursor_control.hide + @cursor_control.clear_screen
136
+ paint
137
+ loop do
138
+ key = @reader.read_keypress(nonblock: false)
139
+ break unless key
140
+
141
+ handle(key)
142
+ break unless @running
143
+
144
+ paint
145
+ end
146
+ ensure
147
+ @output.print @cursor_control.show + @cursor_control.clear_screen + @cursor_control.move_to(0, 0)
148
+ end
149
+
150
+ # ── frame ────────────────────────────────────────────────────────────────
151
+
152
+ def paint
153
+ width = TTY::Screen.width
154
+ height = TTY::Screen.height
155
+
156
+ header = Views.header(self, width)
157
+ footer = [ Views.footer(self, width) ]
158
+ body_height = [ height - header.length - footer.length - 1, 3 ].max
159
+
160
+ # browse, health and graph are all about *a* bundle. With none readable
161
+ # there is nothing for them to render, so the list stands in — it is the
162
+ # one view that can still show what went wrong.
163
+ view = model.nil? && SINGLE_BUNDLE_VIEWS.include?(@view) ? :bundles : @view
164
+
165
+ body =
166
+ case view
167
+ when :bundles then Views.bundles(self, width, body_height)
168
+ when :search then Views.search(self, width, body_height)
169
+ when :health then Views.health(self, width, body_height)
170
+ when :graph then Views.graph(self, width, body_height)
171
+ when :help then Views.help(self, width, body_height)
172
+ else Views.browse(self, width, body_height)
173
+ end
174
+
175
+ frame = header + Ui.fit_block(body, width: width, height: body_height) + [ message_row(width) ] + footer
176
+
177
+ @output.print @cursor_control.move_to(0, 0)
178
+ @output.print frame.first(height).join("\n")
179
+ @output.flush
180
+ end
181
+
182
+ def message_row(width)
183
+ Ui.line(width) do |row|
184
+ if @prompt
185
+ row.add(" #{@prompt.label} ", :black, :on_yellow, :bold)
186
+ row.add(" #{@prompt.buffer}", :bright_white, :bold)
187
+ row.add("▏", :yellow, :bold) if @prompt.free_text?
188
+ elsif @finding
189
+ row.add(" find: ", :black, :on_yellow, :bold)
190
+ row.add(" #{@find}", :bright_white, :bold)
191
+ row.add("▏", :yellow, :bold)
192
+ elsif @filtering
193
+ row.add(" filter: ", :black, :on_yellow, :bold)
194
+ row.add(" #{@filter}", :bright_white, :bold)
195
+ row.add("▏", :yellow, :bold)
196
+ elsif find_status
197
+ row.add(" #{find_status}", @find_total.to_i.zero? ? :yellow : :bright_black)
198
+ elsif @message
199
+ # Flash, in the strict sense: #handle clears it on the very next key, so
200
+ # this line is on screen only until the user does anything at all. That is
201
+ # what makes a strong mark right here rather than shouting — it cannot
202
+ # become permanent noise, and :bright_black had made the one line that
203
+ # reports what just happened the quietest thing on the screen.
204
+ #
205
+ # Cyan, not the yellow the prompt, find and filter lines wear. This row has
206
+ # two states — it is *asking* you something, or it is *telling* you
207
+ # something — and one colour each keeps them apart without reading a word.
208
+ row.add(" #{@message} ", :black, :on_cyan, :bold)
209
+ end
210
+ end
211
+ end
212
+
213
+ def status_hints
214
+ base =
215
+ case @view
216
+ when :bundles
217
+ if filter_found_nothing?
218
+ [ [ "↵", "search all bundles" ], [ "Esc", "clear filter" ] ]
219
+ elsif @pane == :members
220
+ [ [ "↑↓", "pick a member" ], [ "-", "remove it" ], [ "Esc", "back to the groups" ] ]
221
+ elsif @pane == :groups
222
+ [ [ "↵", "scope to it" ], [ "↑↓", "pick a group" ], [ "Tab", "its members" ],
223
+ [ "n", "rename" ], [ "x", "delete" ], [ "Esc", "back to the bundles" ] ]
224
+ else
225
+ # The scope comes before the config keys, and `A`/`N` beside `space`. It
226
+ # is one of the two axes this view is *about*, and the footer truncates:
227
+ # at 80 columns everything from `d` rightward is already off screen, so
228
+ # what the prefix names is the whole of what a narrow terminal teaches.
229
+ # Reordered after "it is not clear how to control the scope" — which was
230
+ # true, since neither `A` nor `N` appeared here at all.
231
+ #
232
+ #
233
+ # `+` sits inside that prefix, and `a add` gave up the slot for it:
234
+ # of the two keys a reader would have called "add", only one of them adds
235
+ # to a group, and registering a directory is a once-per-bundle act that
236
+ # the empty registry and view 6 both still teach.
237
+ #
238
+ # It does not name the group. The groups pane is right below with its own
239
+ # cursor on the row, and spelling the slug out here read as a claim about
240
+ # something the footer could not point at.
241
+ [ [ "↵", "open" ], [ "space", "scope" ], [ "A/N", "all/none" ] ] +
242
+ (selected_group ? [ [ "+", "join group" ] ] : []) +
243
+ [ [ "/", "filter" ], [ "d", "default" ], [ "n", "rename" ], [ "x", "remove" ],
244
+ [ "c", "group" ] ]
245
+ end
246
+ when :browse
247
+ if @following
248
+ [ [ "↑↓", "pick" ], [ "1-9", "jump straight to one" ], [ "↵", "follow" ],
249
+ [ "Esc", "back to the body" ] ]
250
+ elsif filter_found_nothing?
251
+ [ [ "↵", "search all bundles" ], [ "Esc", "clear filter" ] ]
252
+ elsif reading_body?
253
+ # In the body, `/` looks through the document rather than the list.
254
+ [ [ "↑↓ J/K", "scroll" ], [ "Tab", "pane" ], [ "/", "find in page" ], [ "f", "links" ] ] +
255
+ (@find.empty? ? [] : [ [ "n/N", "next/prev match" ] ])
256
+ else
257
+ [ [ "↑↓", "move" ], [ "Tab", "pane" ], [ "J/K", "scroll body" ], [ "/", "filter" ], [ "f", "links" ] ]
258
+ end
259
+ when :search
260
+ # Both Enter and the whole key set change with focus, so the hints do too.
261
+ if editing_query?
262
+ # `e` deliberately does not cycle the mode here — it is a letter, and
263
+ # every letter belongs to the query while the field has focus. Esc
264
+ # first, which the hint says.
265
+ [ [ "type", "query" ], [ "↵", search_pending? ? "search" : "open" ],
266
+ [ "Esc", "stop editing (then e changes the mode)" ] ]
267
+ elsif @searched.empty?
268
+ # Nothing searched yet, so there is nothing to navigate — the only
269
+ # moves that matter are starting one, and choosing how it is asked.
270
+ [ [ "/", "search" ], [ "e", search_mode_label ] ]
271
+ else
272
+ [ [ "↑↓", "results" ], [ "↵", "open" ], [ "/", "edit query" ], [ "e", search_mode_label ] ]
273
+ end
274
+ when :graph
275
+ act = @pane == :detail ? "open concept" : "narrow by facet"
276
+ hints = [ [ "↑↓", "move" ], [ "Tab", "pane" ], [ "↵", act ], [ "/", "filter" ] ]
277
+ hints + (@graph_facet ? [ [ "Esc", "clear facet" ] ] : [])
278
+ when :health
279
+ # Two panes, each scrolling on its own, so Tab names the one the keys move.
280
+ # A narrow terminal shows them one at a time and Tab swaps which — same key,
281
+ # same meaning, a layout that stops pretending both fit.
282
+ [ [ "↑↓", "scroll" ], [ "Tab", @pane == :detail ? "the findings" : "the standing" ],
283
+ [ "/", "find" ] ] +
284
+ (@find.empty? ? [] : [ [ "n/N", "next/prev match" ] ])
285
+ else
286
+ [ [ "↑↓", "scroll" ], [ "/", "find" ] ] +
287
+ (@find.empty? ? [] : [ [ "n/N", "next/prev match" ] ])
288
+ end
289
+ # Only offered once there is somewhere to go back to, so the row does not
290
+ # advertise a key that would do nothing.
291
+ base += [ [ "⌫", "back" ] ] unless @trail.empty?
292
+ base + [ %w[1-6 views], %w[r reload], %w[qq quit] ]
293
+ end
294
+
295
+ # ── bundles view state ───────────────────────────────────────────────────
296
+
297
+ # The registry rows surviving the filter, matched on slug or path — the two
298
+ # things a row shows that a user would type.
299
+ def visible_entries
300
+ return workspace.entries if @filter.empty?
301
+
302
+ needle = @filter.downcase
303
+ workspace.entries.select do |entry|
304
+ "#{entry.slug} #{entry.dir}".downcase.include?(needle)
305
+ end
306
+ end
307
+
308
+ # ── the bundles view's three panes ───────────────────────────────────────
309
+ #
310
+ # bundles (top left), groups (bottom left), and the detail of whichever of the
311
+ # two has focus (right). Tab cycles; each pane keeps its own cursor.
312
+ #
313
+ # It was one interleaved list under a GROUPS heading, and the shape caused three
314
+ # separate problems. The heading was a row nothing could select, so the cursor
315
+ # had to skip it. A long registry pushed the groups below the fold, so they were
316
+ # not reliably on screen at all. And with only one selection at a time, adding a
317
+ # bundle to a group had nothing to name the bundle *with* — which is why `+`
318
+ # reached for the search scope, and why it was the key that kept being wrong.
319
+ #
320
+ # Two selections make `+` direct: the bundle under the cursor joins the group
321
+ # that is selected. Nothing about searching is involved.
322
+ PANES = %i[bundles groups members].freeze
323
+
324
+ def bundles_pane?
325
+ @view == :bundles && @pane == :bundles
326
+ end
327
+
328
+ def groups_pane?
329
+ @view == :bundles && @pane == :groups
330
+ end
331
+
332
+ def member_pane?
333
+ @view == :bundles && @pane == :members && !selected_group.nil?
334
+ end
335
+
336
+ # Which of the two left panes the detail pane is describing. The members pane is
337
+ # a view *of* a group, so it counts as the groups side.
338
+ def detailing_group?
339
+ @view == :bundles && %i[groups members].include?(@pane) && !selected_group.nil?
340
+ end
341
+
342
+ # Groups surviving the filter, matched on the group's own name, on its members
343
+ # as written, and on the bundles it resolves to.
344
+ #
345
+ # All three, because the resolved set is what the filter is usually asking
346
+ # about — "which named scope covers @nested?" — and for a nested group that
347
+ # bundle appears nowhere in the member list. Matching only the members would
348
+ # answer @docs and hide @everything, which covers @nested just as much.
349
+ def visible_groups
350
+ groups = workspace.groups
351
+ return groups if @filter.empty?
352
+
353
+ needle = @filter.downcase
354
+ groups.select do |group|
355
+ haystack = [ group.slug, group.members.join(" "), group.bundles.join(" ") ].join(" ")
356
+ haystack.downcase.include?(needle)
357
+ end
358
+ end
359
+
360
+ # Each pane indexes its own *filtered* list, so every action lands on the row
361
+ # the user is looking at.
362
+ def selected_entry
363
+ visible_entries[@cursor]
364
+ end
365
+
366
+ def selected_group
367
+ visible_groups[@group_cursor]
368
+ end
369
+
370
+ # ── the member pane ──────────────────────────────────────────────────────
371
+ #
372
+ # A group's members are listed in the detail pane, so Tab moves into them and
373
+ # `-` removes the one under the cursor. That is the same Tab the browse view has
374
+ # always used, and it replaced a `-` on the group row that acted on the
375
+ # *intersection* of the scope and the member list — a set with no row on screen,
376
+ # which a reader had to compute to predict what the key would do.
377
+ #
378
+ # Adding stays on the group row, because the bundles to add are the ones marked
379
+ # `◉` in the list beside it. Removing is the direction with something to point at.
380
+ def group_members
381
+ selected_group&.members || []
382
+ end
383
+
384
+ def selected_member
385
+ group_members[@member_cursor]
386
+ end
387
+
388
+ def scoped?(slug)
389
+ workspace.scoped?(slug)
390
+ end
391
+
392
+ # Whether the scope in force *is* this group's set — set equality rather than
393
+ # overlap. A group's ◉ says "a search right now covers exactly these", so
394
+ # toggling one member off has to clear it; treating a superset as a match would
395
+ # leave two groups both claiming to be in force.
396
+ # The group under the groups cursor, when this bundle is one of its members —
397
+ # which is what decides whether the detail pane has a `+` to offer. Direct
398
+ # members only, deliberately: a bundle reached through a *nested* group is in the
399
+ # set a search covers but not in the list `+` would change.
400
+ def member_group(slug)
401
+ group = selected_group
402
+ group if group&.members&.include?(slug)
403
+ end
404
+
405
+ def group_in_scope?(group)
406
+ return false if group.bundles.empty?
407
+
408
+ workspace.scope.sort == group.bundles.sort
409
+ end
410
+
411
+ # ── browse list state ────────────────────────────────────────────────────
412
+
413
+ # Rows surviving the filter, in catalog order.
414
+ def filtered_rows
415
+ return [] unless model
416
+ return model.rows if @filter.empty?
417
+
418
+ needle = @filter.downcase
419
+ model.rows.select do |row|
420
+ [ row[:id], row[:title], row[:type], Array(row[:tags]).join(" ") ]
421
+ .join(" ").downcase.include?(needle)
422
+ end
423
+ end
424
+
425
+ # The browse list as a flat sequence of directory headings and concept rows. The
426
+ # headings are entries too, so windowing and the cursor share one index
427
+ # space — but only concept entries are selectable, which `move` enforces.
428
+ def list_entries
429
+ @list_entries_key ||= nil
430
+ # `dup` matters: @filter is mutated in place as the user types, so keying
431
+ # on the object itself would compare a key against its own later state and
432
+ # the cache would never invalidate. The active bundle is part of the key
433
+ # too, or switching bundles would keep showing the previous ones list.
434
+ key = [ @filter.dup, workspace.active_slug ]
435
+ return @list_entries if @list_entries_key == key
436
+
437
+ entries = []
438
+ browse_groups.each do |dir, group|
439
+ entries << { kind: :dir, label: dir == "." ? "(root)" : dir, count: group[:concepts].length }
440
+ group[:reserved].each { |path| entries << { kind: :reserved, path: path } }
441
+ group[:concepts].each { |row| entries << { kind: :concept, row: row } }
442
+ end
443
+
444
+ @list_entries_key = key
445
+ @list_entries = entries
446
+ end
447
+
448
+ # The bundle as it sits on disk: each directory in turn, its index.md first,
449
+ # then its log.md, then its concepts — and the root before anything nested,
450
+ # since that is the order someone reads a bundle in (§6 makes index.md the
451
+ # way in). Sorting by path puts every subfolder directly under its parent.
452
+ def browse_groups
453
+ return {} unless model
454
+
455
+ groups = Hash.new { |hash, dir| hash[dir] = { reserved: [], concepts: [] } }
456
+
457
+ model.reserved.each do |entry|
458
+ next unless matches_filter?(entry.path)
459
+
460
+ groups[File.dirname(entry.path)][:reserved] << entry.path
461
+ end
462
+
463
+ filtered_rows.each do |row|
464
+ groups[File.dirname(row[:path])][:concepts] << row
465
+ end
466
+
467
+ groups.each_value { |group| group[:reserved].sort_by! { |path| File.basename(path) } }
468
+ groups.sort_by { |dir, _| dir == "." ? [ 0, "" ] : [ 1, dir ] }
469
+ end
470
+
471
+ def matches_filter?(text)
472
+ @filter.empty? || text.to_s.downcase.include?(@filter.downcase)
473
+ end
474
+
475
+ def selectable_indices
476
+ list_entries.each_index.select { |index| %i[concept reserved].include?(list_entries[index][:kind]) }
477
+ end
478
+
479
+ # The row under the cursor, or the first one, when it is a concept. A
480
+ # reserved file has no catalog row — the detail pane asks for it separately.
481
+ def selected_row
482
+ entry = selected_browse_entry
483
+ entry && entry[:kind] == :concept ? entry[:row] : nil
484
+ end
485
+
486
+ def selected_browse_entry
487
+ entry = list_entries[@cursor]
488
+ return entry if entry && %i[concept reserved].include?(entry[:kind])
489
+
490
+ first = selectable_indices.first
491
+ first ? list_entries[first] : nil
492
+ end
493
+
494
+ # ── graph view state ─────────────────────────────────────────────────────
495
+ #
496
+ # The graph is two selectable lists rather than a page: facets on the left
497
+ # (a type or a tag), concepts on the right. Tab moves between them, and what
498
+ # Enter means follows from which kind of row the cursor is on — narrow by
499
+ # this facet, or go read this concept.
500
+
501
+ attr_reader :workspace, :view, :pane, :cursor, :filter, :query, :searched, :prompt, :find, :find_index,
502
+ :graph_facet, :follow_cursor, :group_cursor, :member_cursor, :search_mode
503
+
504
+ # Concepts the facet admits. Selecting `Capability` does not recompute link
505
+ # degree — the counts stay the bundle's real ones — it narrows *which*
506
+ # concepts the lists are drawn from.
507
+ def faceted_rows
508
+ return [] unless model
509
+ return model.rows if @graph_facet.nil?
510
+
511
+ field = @graph_facet[:field]
512
+ value = @graph_facet[:value]
513
+ model.rows.select { |row| facet_admits?(row, field, value) }
514
+ end
515
+
516
+ # A type and a tag match exactly; a dir names itself and everything beneath it,
517
+ # which is okf's `--dir` rule (see Model.under_dir?). Narrowing to `platform`
518
+ # therefore reaches `platform/services/api`, exactly as `okf catalog --dir
519
+ # platform` does — a dir facet that matched only its own level would be the
520
+ # first-path-segment rollup `--area` was, which okf deprecated for losing every
521
+ # level below it.
522
+ def facet_admits?(row, field, value)
523
+ case field
524
+ when :type then Model.type_label(row[:type]) == value
525
+ when :dir then Model.under_dir?(row[:dir], value)
526
+ # §5.4 and §5.3, narrowed exactly as `--status` and `--trust` narrow —
527
+ # okf's own predicate, so an absent status reads `stable` here the way it
528
+ # reads `stable` there, and a tier folds both spellings. Trust also gates on
529
+ # what the row is willing to claim, so the facet covers exactly the rows
530
+ # wearing a tier: counting rows this screen shows no tier for would narrow
531
+ # to more concepts than the count promised.
532
+ when :status then OKF::Bundle::RowFilter.matches?(row, status: value)
533
+ when :trust then Model.shows_trust?(row) && OKF::Bundle::RowFilter.matches?(row, trust: value)
534
+ else Array(row[:tags]).map(&:to_s).include?(value)
535
+ end
536
+ end
537
+
538
+ def facet_active?(field, value)
539
+ @graph_facet && @graph_facet[:field] == field && @graph_facet[:value] == value
540
+ end
541
+
542
+ # Left pane: the type and tag tallies, counted within the facet in force.
543
+ def graph_facet_entries
544
+ return [] unless model
545
+
546
+ subset = faceted_rows
547
+ types = Views.narrow(model.types_of(subset), @filter)
548
+ tags = Views.narrow(model.tags_of(subset), @filter).first(12)
549
+ dirs = Views.narrow(model.dirs_of(subset), @filter)
550
+
551
+ entries = [ { kind: :heading, label: "types", colour: :cyan } ]
552
+ entries.concat(facet_rows(types, :type))
553
+ entries << { kind: :blank }
554
+ entries << { kind: :heading, label: "tags", colour: :magenta }
555
+ entries.concat(facet_rows(tags, :tag))
556
+
557
+ # Only where the bundle actually nests. The test is a directory with a
558
+ # separator in it, not a count of directories: `conformant/` has two
559
+ # (datasets, tables) and is still one level deep, where every dir facet says
560
+ # exactly what a top-level rollup would — and a rollup is what okf deprecated
561
+ # `--area` for being. okf takes the same view on its graph page, which offers
562
+ # a bundle that does not nest no depth control at all.
563
+ if dirs.any? { |dir, _count| dir.include?("/") }
564
+ entries << { kind: :blank }
565
+ entries << { kind: :heading, label: "dirs", colour: :yellow }
566
+ entries.concat(facet_rows(dirs, :dir))
567
+ end
568
+
569
+ entries.concat(provenance_facets(subset))
570
+ entries
571
+ end
572
+
573
+ # §5's two facets, offered only where the bundle has something to say — the
574
+ # same rule the dir facet follows, and the same rule okf's own graph page
575
+ # applies to these two.
576
+ #
577
+ # Status needs one *declared* value, because a lone `stable` row is what an
578
+ # undeclared status already means; the counts then use the effective value, so
579
+ # the group reads `stable 22 · deprecated 1` rather than hiding the majority
580
+ # the one deprecated concept is measured against. Trust needs one tier okf is
581
+ # willing to claim, or every v0.1 bundle would offer a facet whose only row
582
+ # said "unverified" about a family it never adopted — and both the count and
583
+ # the narrowing read that same predicate, so the row and what it selects agree.
584
+ def provenance_facets(subset)
585
+ entries = []
586
+
587
+ if subset.any? { |row| !row[:status].to_s.empty? }
588
+ statuses = Views.narrow(model.statuses_of(subset), @filter)
589
+ unless statuses.empty?
590
+ entries << { kind: :blank }
591
+ entries << { kind: :heading, label: "status", colour: :yellow }
592
+ entries.concat(facet_rows(statuses, :status))
593
+ end
594
+ end
595
+
596
+ tiers = Views.narrow(model.tiers_of(subset), @filter)
597
+ unless tiers.empty?
598
+ entries << { kind: :blank }
599
+ entries << { kind: :heading, label: "trust", colour: :green }
600
+ entries.concat(facet_rows(tiers, :trust))
601
+ end
602
+
603
+ entries
604
+ end
605
+
606
+ # Each block of bars is scaled to its own tallest, and its labels padded to
607
+ # its own longest, so the two sections read independently.
608
+ def facet_rows(pairs, field)
609
+ peak = pairs.map { |_, count| count }.max
610
+ pad = pairs.map { |value, _| Ui.width(value.to_s) }.max
611
+
612
+ pairs.map do |value, count|
613
+ { kind: :facet, field: field, value: value, count: count, peak: peak, pad: pad }
614
+ end
615
+ end
616
+
617
+ # Right pane: the hubs and orphans among those concepts.
618
+ def graph_concept_entries
619
+ return [] unless model
620
+
621
+ subset = Views.narrow_rows(faceted_rows, @filter)
622
+ entries = []
623
+
624
+ entries << { kind: :heading, label: "most linked-to", colour: :blue }
625
+ entries.concat(degree_entries(subset, :links_in, :blue))
626
+ entries << { kind: :blank }
627
+ entries << { kind: :heading, label: "most linked-from", colour: :green }
628
+ entries.concat(degree_entries(subset, :links_out, :green))
629
+ entries << { kind: :blank }
630
+
631
+ orphans = subset.map { |row| row[:id] } & model.orphan_ids
632
+ entries << { kind: :heading, label: "orphans (no edges either way)", colour: orphans.empty? ? :green : :yellow }
633
+ if orphans.empty?
634
+ entries << { kind: :note, label: "every concept is reachable", colour: :green }
635
+ else
636
+ pad = orphans.first(6).map { |id| Ui.width(id.to_s) }.max
637
+ orphans.first(6).each { |id| entries << { kind: :concept, id: id, count: 0, peak: 1, pad: pad, colour: :yellow } }
638
+ end
639
+
640
+ entries
641
+ end
642
+
643
+ def degree_entries(subset, field, colour)
644
+ top = subset.sort_by { |row| -row[field].to_i }.first(8)
645
+ peak = top.map { |row| row[field].to_i }.max
646
+ pad = top.map { |row| Ui.width(row[:id].to_s) }.max
647
+
648
+ top.map do |row|
649
+ { kind: :concept, id: row[:id], count: row[field].to_i, peak: peak, pad: pad, colour: colour }
650
+ end
651
+ end
652
+
653
+ def graph_entries
654
+ @pane == :detail ? graph_concept_entries : graph_facet_entries
655
+ end
656
+
657
+ def graph_selectable
658
+ graph_entries.each_index.select { |index| %i[facet concept].include?(graph_entries[index][:kind]) }
659
+ end
660
+
661
+ def graph_selected
662
+ graph_entries[@cursor]
663
+ end
664
+
665
+ # ── scrolling ────────────────────────────────────────────────────────────
666
+
667
+ # The first visible index, scrolled just enough to keep the cursor on screen.
668
+ def window(count, height)
669
+ height = [ height, 1 ].max
670
+ @scroll = [ @scroll, @cursor - height + 1 ].max
671
+ @scroll = [ @scroll, @cursor ].min
672
+ @scroll = [ [ @scroll, count - height ].min, 0 ].max
673
+ @scroll
674
+ end
675
+
676
+ # The groups pane scrolls independently of the bundles pane, so it needs its own
677
+ # offset: sharing @scroll would make paging one list drag the other.
678
+ def group_window(count, height)
679
+ height = [ height, 1 ].max
680
+ @group_scroll = [ @group_scroll.to_i, @group_cursor - height + 1 ].max
681
+ @group_scroll = [ @group_scroll, @group_cursor ].min
682
+ @group_scroll = [ [ @group_scroll, count - height ].min, 0 ].max
683
+ end
684
+
685
+ def detail_scroll_for(count, visible)
686
+ max = [ count - visible, 0 ].max
687
+ @detail_scroll = [ [ @detail_scroll, max ].min, 0 ].max
688
+ end
689
+
690
+ # The offset for the views that are one long page rather than a list —
691
+ # health, graph and help. Clamped here, against the row count the view
692
+ # actually produced, so the state can never point past the end.
693
+ def content_scroll_for(count, visible, key = :content)
694
+ max = [ count - visible, 0 ].max
695
+ set_scroll(key, [ [ scroll_for(key), max ].min, 0 ].max)
696
+ end
697
+
698
+ # Health is two pages side by side and each keeps its own place: the findings
699
+ # are unbounded and the summary is not, so one shared offset would drag the
700
+ # short pane to its end and hold it there while the long one scrolled.
701
+ def scroll_for(key)
702
+ key == :health ? @health_scroll : @content_scroll
703
+ end
704
+
705
+ def set_scroll(key, value)
706
+ key == :health ? (@health_scroll = value) : (@content_scroll = value)
707
+ end
708
+
709
+ # Which of the two the keys move: the one with focus.
710
+ def content_scroll_key
711
+ @view == :health && @pane == :detail ? :health : :content
712
+ end
713
+
714
+ # Wide enough that tty-markdown never wraps a line itself.
715
+ #
716
+ # Its wrapper (the `strings` gem) miscounts ANSI escapes and raises
717
+ # IndexError from String#insert on coloured input — 50 of 192 concept/width
718
+ # combinations in the okf bundle alone, and only in a real terminal, since a
719
+ # pipe turns the colour off. Handing it a width it can never reach sidesteps
720
+ # the wrapping entirely, and Ui.reflow does the job instead: it was already
721
+ # re-wrapping this output anyway, because tty-markdown otherwise keeps the
722
+ # source line breaks. Tables come out at their natural width and are clipped
723
+ # to the pane exactly as before.
724
+ PARSE_WIDTH = 10_000
725
+
726
+ # Concept bodies are rendered by tty-markdown once and cached — re-rendering
727
+ # on every keypress is what would make scrolling feel heavy. Keyed by bundle
728
+ # too, since ids are only unique within one.
729
+ def rendered_body(row, width)
730
+ render_markdown([ workspace.active_slug, row[:id], width ], model.body_for(row), width)
731
+ end
732
+
733
+ def rendered_reserved(path, width)
734
+ render_markdown([ workspace.active_slug, :reserved, path, width ], model.reserved_text(path), width)
735
+ end
736
+
737
+ def render_markdown(cache_key, source, width)
738
+ @body_cache[cache_key] ||= begin
739
+ if source.to_s.strip.empty?
740
+ [ Ui.pastel.decorate("(empty)", :bright_black) ]
741
+ else
742
+ limit = [ width, 20 ].max
743
+ # Say the colour decision out loud rather than letting tty-markdown
744
+ # sniff the terminal: it is the coloured path that trips the wrapping
745
+ # bug PARSE_WIDTH avoids, and a check that cannot turn colour on
746
+ # cannot see that bug at all.
747
+ mode = Ui.pastel.enabled? ? :always : :never
748
+ Ui.reflow(TTY::Markdown.parse(source, width: PARSE_WIDTH, color: mode).lines, limit)
749
+ end
750
+ rescue StandardError => e
751
+ # Name the failure. "IndexError" alone sent this one looking in the
752
+ # wrong place for a while.
753
+ [ Ui.pastel.decorate("(could not render markdown: #{e.class}: #{e.message})", :red) ]
754
+ end
755
+ end
756
+
757
+ # Cross-bundle: the hits come from every scoped bundle, ranked together.
758
+ #
759
+ # Results belong to `@searched`, not to `@query`, and only Enter moves one to
760
+ # the other. Searching per keystroke meant building a fresh index over every
761
+ # scoped bundle for each letter, and the view churning through answers to
762
+ # half-typed words; a timed pause fixed the cost but still left the moment
763
+ # of searching up to a guess about typing rhythm. Submitting is explicit
764
+ # instead, so the field edits freely and the results below hold still.
765
+ def search_hits
766
+ return [] if @searched.empty? # nothing submitted yet — nothing to ask
767
+
768
+ key = [ @searched.dup, workspace.scope, @search_mode ]
769
+ return @search_hits if @search_hits_key == key
770
+
771
+ @search_hits_key = key
772
+ @search_hits = workspace.search(@searched, mode: @search_mode)
773
+ end
774
+
775
+ # The three ways okf can be asked a question, cycled with `e`. Modes rather than
776
+ # engine names, because that is how okf routes: a query declares the capability
777
+ # it needs and the facade picks the engine that has it.
778
+ SEARCH_MODES = [
779
+ { id: :fuzzy, label: "fuzzy", note: "ranked, typo-tolerant — misses terms glued to symbols" },
780
+ { id: :text, label: "text", note: "raw substring, okf's default — finds $OKF_HOME and `minifts`" },
781
+ { id: :regexp, label: "regexp", note: "a pattern over the same raw text" }
782
+ ].freeze
783
+
784
+ def search_mode_entry
785
+ SEARCH_MODES.find { |mode| mode[:id] == @search_mode } || SEARCH_MODES.first
786
+ end
787
+
788
+ def search_mode_label
789
+ search_mode_entry[:label]
790
+ end
791
+
792
+ def search_mode_note
793
+ search_mode_entry[:note]
794
+ end
795
+
796
+ # Cycling re-asks the question rather than leaving the previous engine's answer
797
+ # sitting under a new label: the hits are keyed on the mode, so it reruns.
798
+ def cycle_search_mode
799
+ index = SEARCH_MODES.index { |mode| mode[:id] == @search_mode }.to_i
800
+ @search_mode = SEARCH_MODES[(index + 1) % SEARCH_MODES.length][:id]
801
+ @cursor = 0
802
+ @message = "search: #{search_mode_label} — #{search_mode_note}"
803
+ end
804
+
805
+ # Does the search field have focus — i.e. do printable keys type into it?
806
+ def editing_query?
807
+ @view == :search && @search_focus == :field
808
+ end
809
+
810
+ def focus_field
811
+ @search_focus = :field
812
+ end
813
+
814
+ # Stop editing, stay where you are.
815
+ #
816
+ # Unsearched edits are dropped, not run: Esc is how you abandon a query you
817
+ # started typing, and running it would be the opposite of stopping. Reverting
818
+ # to the query the results actually answer also means the field and the list
819
+ # below it always agree once the field is released — there is no lingering
820
+ # "these results are for something else" state to explain.
821
+ def leave_field
822
+ @query = @searched.dup
823
+ @search_focus = :results
824
+ end
825
+
826
+ # Is there typing the results have not caught up with yet?
827
+ def search_pending?
828
+ @view == :search && @searched != @query
829
+ end
830
+
831
+ # Adopt the typed query and let the next #search_hits do the work. Called
832
+ # from the loop after the pause, and directly on Enter.
833
+ def commit_search
834
+ return false unless @searched != @query
835
+
836
+ @searched = @query.dup
837
+ @cursor = 0
838
+ @scroll = 0
839
+ true
840
+ end
841
+
842
+ # ── input ────────────────────────────────────────────────────────────────
843
+
844
+ def handle(key)
845
+ return handle_prompt(key) if @prompt
846
+
847
+ @message = nil
848
+ # Whether the *previous* key armed the quit, and the disarm for this one.
849
+ # Above the mode handlers on purpose: typing `q` into a filter or a query
850
+ # has to cancel the arming too, or the chord leaks across a text field.
851
+ armed = @quit_armed
852
+ @quit_armed = false
853
+ # The picker is the innermost mode, so it owns the keyboard outright —
854
+ # including the digits, which is the whole reason it is a mode and not a
855
+ # pane. `1` picks a link while it is open; everywhere else it is view one.
856
+ return handle_follow(key) if @following
857
+ return handle_find(key) if @finding
858
+ return handle_filter(key) if @filtering
859
+ # Only while the field has focus do printable keys mean query text. Once
860
+ # Esc leaves it, the search view answers the same keys as everywhere else.
861
+ return handle_query(key) if @view == :search && @search_focus == :field
862
+
863
+ if KEY_VIEWS.key?(key)
864
+ switch(KEY_VIEWS[key])
865
+ return
866
+ end
867
+
868
+ case key
869
+ when "q" then armed ? (@running = false) : arm_quit
870
+ # Ctrl-C stays single. It is the escape hatch, and an escape hatch that
871
+ # needs confirming is not one.
872
+ when CTRL_C then @running = false
873
+ when "?" then switch(:help)
874
+ when "j", DOWN then move(1)
875
+ when "k", UP then move(-1)
876
+ when "g" then jump(:first)
877
+ when "G" then jump(:last)
878
+ when CTRL_D then move(10)
879
+ when CTRL_U then move(-10)
880
+ # A `when` matches whether or not its guard holds, so these must hand the
881
+ # key back rather than swallow it — `n` is rename in the bundles view.
882
+ when "n" then findable? ? step_match(1) : fallback(key)
883
+ when "N" then findable? ? step_match(-1) : fallback(key)
884
+ when "J" then @detail_scroll += 3
885
+ when "K" then @detail_scroll = [ @detail_scroll - 3, 0 ].max
886
+ when TAB then toggle_pane
887
+ when "f" then start_follow
888
+ when DELETE then back
889
+ when "/" then start_typing
890
+ when "i" then focus_field if @view == :search
891
+ when "e" then cycle_search_mode if @view == :search
892
+ when "s" then switch(:search)
893
+ when "r" then reload
894
+ when "\r", "\n" then handle_return
895
+ when ESCAPE then handle_escape
896
+ else fallback(key)
897
+ end
898
+ end
899
+
900
+ def fallback(key)
901
+ handle_bundle_key(key) if @view == :bundles
902
+ end
903
+
904
+ def running?
905
+ @running
906
+ end
907
+
908
+ # `q` quit on the first press, so one stray keystroke ended the session with
909
+ # nothing to undo it. It arms instead, and says so — a chord nobody is told
910
+ # about is just a key that stopped working.
911
+ def arm_quit
912
+ @quit_armed = true
913
+ @message = "press q again to quit"
914
+ end
915
+
916
+ # Enter, once the field no longer has focus: open whatever is selected.
917
+ def handle_return
918
+ case @view
919
+ when :search then open_hit
920
+ when :browse then escalate_to_search if filter_found_nothing?
921
+ when :graph then activate_graph_row
922
+ when :bundles then filter_found_nothing? ? escalate_to_search : open_registry_row
923
+ end
924
+ end
925
+
926
+ # The keys that only mean something over the registry list.
927
+ def handle_bundle_key(key)
928
+ case @pane
929
+ when :members then member_key(key)
930
+ when :groups then group_key(key)
931
+ else bundle_key(key)
932
+ end
933
+ end
934
+
935
+ # The bundles pane. `+` is the one that reaches across: the bundle under this
936
+ # cursor joins the group selected in the pane below, which is two visible rows
937
+ # rather than an invisible set.
938
+ def bundle_key(key)
939
+ case key
940
+ when " " then toggle_scope
941
+ when "A" then workspace.scope_all && @search_hits_key = nil
942
+ when "N" then workspace.scope_none && @search_hits_key = nil
943
+ when "d" then set_default
944
+ when "a" then ask(:add, "directory to register:", free_text: true)
945
+ when "n" then ask_rename
946
+ when "x" then ask_remove
947
+ when "c" then ask_group
948
+ when "+" then add_selected_to_group
949
+ # Removal lives in the pane that lists the members, where each one has a row
950
+ # of its own. Saying so beats going silent: a key that quietly stopped working
951
+ # is indistinguishable from a broken one.
952
+ when "-" then @message = "- removes a member: Tab to the groups, Tab again into them"
953
+ end
954
+ end
955
+
956
+ # The groups pane. `n` and `x` are the same keys they are for a bundle, on the
957
+ # slug this pane has selected — okf's rename and del span a group and cascade.
958
+ def group_key(key)
959
+ case key
960
+ when "n" then ask_rename
961
+ when "x" then ask_remove
962
+ when "c" then ask_group
963
+ when "+" then @message = "+ adds from the bundles pane — Tab back, point at one, then +"
964
+ when "-" then @message = "- removes a member: Tab into them, or point at the bundle and press -"
965
+ end
966
+ end
967
+
968
+ def member_key(key)
969
+ case key
970
+ when "-" then ask_member_removal
971
+ end
972
+ end
973
+
974
+ # `c` names the bundles now in scope. The scope *is* the selection in this
975
+ # view — `◉` says so on every row — so a group is made by toggling what you
976
+ # want and giving it a name, rather than by typing a member list a second time.
977
+ def ask_group
978
+ scoped = workspace.scope
979
+ return (@message = "nothing in scope to name — space toggles a bundle in") if scoped.empty?
980
+
981
+ ask(:group, "name for a group of #{scoped.length} scoped #{scoped.length == 1 ? "bundle" : "bundles"}:",
982
+ free_text: true)
983
+ end
984
+
985
+ # `+` and `-` on a group row: the cursor names the group, the scope names the
986
+ # bundles. No prompt, because neither half is ambiguous — which is the reason
987
+ # to spend the scope on this rather than ask for a typed list.
988
+ # `+` from the bundles pane: the bundle under this cursor joins the group the
989
+ # groups pane has selected. Two rows, both on screen, neither of them the search
990
+ # scope — which is what the earlier cuts of this key got wrong twice.
991
+ #
992
+ # Additive and reversible, so it does not ask.
993
+ def add_selected_to_group
994
+ entry = selected_entry
995
+ group = selected_group
996
+ return (@message = "no bundle here to add") if entry.nil?
997
+ return (@message = "no group to add it to — c names the bundles in scope as one") if group.nil?
998
+ return (@message = "@#{group.slug} already names @#{entry.slug}") if group.members.include?(entry.slug)
999
+
1000
+ apply_group_edit_keeping_focus do
1001
+ keeping_group_scoped(group.slug) { workspace.add_to_group(group.slug, [ entry.slug ]) }
1002
+ end
1003
+ end
1004
+
1005
+ # A group that *is* the scope in force stays in force across an edit to it.
1006
+ # Without this, `+` on a bundle left the ◉ on its row hollow and quietly emptied
1007
+ # the one on the group row too: the group had grown, the scope had not, so set
1008
+ # equality stopped holding and a search still covered the old set. The report was
1009
+ # the visible half — "it does not select the bundle" — and this was the cause.
1010
+ #
1011
+ # Only when it was in force beforehand. Re-scoping a group nobody had scoped
1012
+ # would make an edit to it silently replace the reader's own selection.
1013
+ def keeping_group_scoped(slug)
1014
+ group = workspace.group(slug)
1015
+ in_force = !group.nil? && group_in_scope?(group)
1016
+ message = yield
1017
+ workspace.scope_group(slug) if in_force && workspace.group(slug)
1018
+ message
1019
+ end
1020
+
1021
+ # Removing acts on the member row under the cursor — one member, the one being
1022
+ # pointed at. It still asks, because okf deletes a group whose last member
1023
+ # leaves, so this key can destroy the group rather than trim it, and the question
1024
+ # says which of the two is about to happen.
1025
+ def ask_member_removal
1026
+ group = selected_group
1027
+ return (@message = "Tab into a group's members to remove one") if group.nil?
1028
+
1029
+ member = selected_member
1030
+ return (@message = "@#{group.slug} has no members to remove") if member.nil?
1031
+
1032
+ ask_member_gone(group, member)
1033
+ end
1034
+
1035
+ # One question for both panes that can ask it, so the consequence is named the
1036
+ # same way whichever row the reader was pointing at.
1037
+ def ask_member_gone(group, member)
1038
+ label =
1039
+ if group.members.length == 1
1040
+ "remove @#{member} — its last, so @#{group.slug} goes too. remove? (y/n)"
1041
+ else
1042
+ "remove @#{member} from @#{group.slug}? (y/n)"
1043
+ end
1044
+
1045
+ ask(:ungroup, label, free_text: false, subject: [ group.slug, member ].join(MEMBER_REF))
1046
+ end
1047
+
1048
+ # The cursor stays where it was in both panes: a write should not move the
1049
+ # reader, and with two selections there are two to preserve.
1050
+ def apply_group_edit_keeping_focus
1051
+ bundle = selected_entry&.slug
1052
+ group = selected_group&.slug
1053
+ @message = yield
1054
+ @search_hits_key = nil
1055
+ invalidate
1056
+ clamp_cursor
1057
+ focus(bundle) if bundle
1058
+ focus_group(group) if group
1059
+ @message
1060
+ end
1061
+
1062
+ # Keep the cursor on the group that was just edited, rather than on whatever
1063
+ # ends up at that index after the reload — the same reason #set_default follows
1064
+ # its bundle instead of its position.
1065
+ def focus_group(slug)
1066
+ index = visible_groups.index { |group| group.slug == slug }
1067
+ @group_cursor = index if index
1068
+ @message
1069
+ end
1070
+
1071
+ # Find-in-document. Live rather than submitted: this only scans the lines
1072
+ # already rendered, so it costs nothing to follow every keystroke, and
1073
+ # watching the body jump as you type is the whole point.
1074
+ def handle_find(key)
1075
+ case key
1076
+ when "\r", "\n" then @finding = false
1077
+ when ESCAPE then clear_find
1078
+ when DELETE, "\b" then (@find.chop! || @find) && jump_to_match(0)
1079
+ when CTRL_C then @running = false
1080
+ else
1081
+ return unless printable?(key)
1082
+
1083
+ @find << key
1084
+ jump_to_match(0)
1085
+ end
1086
+ end
1087
+
1088
+ # ── following a link ─────────────────────────────────────────────────────
1089
+ #
1090
+ # The links of the document in the detail pane, in reading order. Both kinds
1091
+ # of document have them: a concept body, and a reserved file — and it is the
1092
+ # reserved ones that matter most, since an index.md is a list of links by
1093
+ # design (§6) and the log is a list of what changed where.
1094
+
1095
+ def follow_links
1096
+ return [] unless model
1097
+
1098
+ entry = selected_browse_entry
1099
+ return [] if entry.nil?
1100
+
1101
+ path = entry[:kind] == :reserved ? entry[:path] : entry[:row][:path]
1102
+ model.links_for(path.to_s)
1103
+ end
1104
+
1105
+ def following?
1106
+ @following
1107
+ end
1108
+
1109
+ def start_follow
1110
+ return unless @view == :browse
1111
+
1112
+ if follow_links.empty?
1113
+ @message = "no links in this document"
1114
+ return
1115
+ end
1116
+
1117
+ @following = true
1118
+ @follow_cursor = 0
1119
+ @follow_scroll = 0
1120
+ # The picker draws where the body does, so the pane it belongs to has to
1121
+ # be the one showing.
1122
+ @pane = :detail
1123
+ end
1124
+
1125
+ def handle_follow(key)
1126
+ links = follow_links
1127
+
1128
+ case key
1129
+ when ESCAPE, "f" then @following = false
1130
+ when CTRL_C then @running = false
1131
+ when "\r", "\n" then follow_selected(links)
1132
+ when "j", DOWN then @follow_cursor = [ @follow_cursor + 1, links.length - 1 ].min
1133
+ when "k", UP then @follow_cursor = [ @follow_cursor - 1, 0 ].max
1134
+ when "g" then @follow_cursor = 0
1135
+ when "G" then @follow_cursor = [ links.length - 1, 0 ].max
1136
+ when /\A[1-9]\z/
1137
+ index = key.to_i - 1
1138
+ return if index >= links.length
1139
+
1140
+ @follow_cursor = index
1141
+ follow_selected(links)
1142
+ end
1143
+ end
1144
+
1145
+ def follow_selected(links)
1146
+ link = links[@follow_cursor]
1147
+ return if link.nil?
1148
+
1149
+ case link[:kind]
1150
+ when :concept
1151
+ @following = false
1152
+ open_concept(link[:id])
1153
+ @message = "opened #{link[:id]}"
1154
+ when :reserved
1155
+ @following = false
1156
+ open_reserved(link[:target])
1157
+ @message = "opened #{link[:target]}"
1158
+ else
1159
+ # Not an error: a link with nothing at the end of it is knowledge that
1160
+ # has not been written yet — okf's own position, and lint's job to
1161
+ # report. The picker says so and stays open, because a dead link means
1162
+ # the reader has not finished choosing, and closing would make them
1163
+ # press `f` again to find that out.
1164
+ @message = "#{link[:target]} is not in this bundle yet"
1165
+ end
1166
+ end
1167
+
1168
+ # The window over the picker, mirroring #window — its own offset, because
1169
+ # the list pane's scroll belongs to a different list.
1170
+ def follow_window(count, height)
1171
+ height = [ height, 1 ].max
1172
+ @follow_scroll = [ @follow_scroll, @follow_cursor - height + 1 ].max
1173
+ @follow_scroll = [ @follow_scroll, @follow_cursor ].min
1174
+ @follow_scroll = [ [ @follow_scroll, count - height ].min, 0 ].max
1175
+ end
1176
+
1177
+ # ── the way back ─────────────────────────────────────────────────────────
1178
+ #
1179
+ # A jump nobody can undo is a jump nobody makes twice. Pushed inside the two
1180
+ # openers, which means following a link, opening a search hit and leaving the
1181
+ # graph for a concept are all reversible by the same key.
1182
+
1183
+ MAX_TRAIL = 32
1184
+
1185
+ def trail_depth
1186
+ @trail.length
1187
+ end
1188
+
1189
+ def push_trail
1190
+ @trail << { slug: workspace.active_slug, view: @view, pane: @pane, cursor: @cursor,
1191
+ scroll: @scroll, detail_scroll: @detail_scroll, filter: @filter.dup }
1192
+ @trail.shift while @trail.length > MAX_TRAIL
1193
+ end
1194
+
1195
+ def back
1196
+ spot = @trail.pop
1197
+ return if spot.nil?
1198
+
1199
+ # activate resets every offset, so the remembered ones are restored after
1200
+ # it rather than before — otherwise the bundle switch quietly wipes them.
1201
+ activate(spot[:slug]) if spot[:slug] != workspace.active_slug
1202
+ @following = false
1203
+ @view = spot[:view]
1204
+ @pane = spot[:pane]
1205
+ @filter = spot[:filter].dup
1206
+ @list_entries_key = nil
1207
+ @cursor = spot[:cursor]
1208
+ @scroll = spot[:scroll]
1209
+ @detail_scroll = spot[:detail_scroll]
1210
+ @message = "back"
1211
+ end
1212
+
1213
+ def clear_find
1214
+ @finding = false
1215
+ @find = +""
1216
+ @find_index = 0
1217
+ @find_jump = false
1218
+ end
1219
+
1220
+ def jump_to_match(index)
1221
+ @find_index = index
1222
+ @find_jump = true
1223
+ end
1224
+
1225
+ def step_match(delta)
1226
+ return unless findable? && !@find.empty?
1227
+
1228
+ @find_index += delta
1229
+ @find_jump = true
1230
+ end
1231
+
1232
+ # The lines of a rendered body carrying the term. Matched on the visible
1233
+ # text, so a word split by colour still counts.
1234
+ def find_matches(body)
1235
+ return [] if @find.empty?
1236
+
1237
+ needle = @find.downcase
1238
+ body.each_index.select { |index| body[index].to_s.gsub(Ui::ANSI, "").downcase.include?(needle) }
1239
+ end
1240
+
1241
+ # The scroll offset for a body, honouring a pending jump. The match lands a
1242
+ # couple of lines down rather than flush at the top, so it arrives with the
1243
+ # context above it.
1244
+ def detail_offset(count, visible, matches)
1245
+ if @find_jump && !matches.empty?
1246
+ @find_index %= matches.length
1247
+ @detail_scroll = [ matches[@find_index] - 2, 0 ].max
1248
+ @find_jump = false
1249
+ end
1250
+
1251
+ # Remembered so the status line can report the find without needing the
1252
+ # body, which only the detail pane has.
1253
+ @find_total = matches.length
1254
+ detail_scroll_for(count, visible)
1255
+ end
1256
+
1257
+ # Same jump, against the offset the one-long-page views scroll on.
1258
+ # The rows the current page last rendered. Only a scrolling page keeps this,
1259
+ # and only so a check can assert against the same list on screen.
1260
+ def remember_page(rows)
1261
+ @last_page_rows = rows
1262
+ end
1263
+
1264
+ def content_offset(count, visible, matches, key = :content)
1265
+ if @find_jump && !matches.empty?
1266
+ @find_index %= matches.length
1267
+ set_scroll(key, [ matches[@find_index] - 2, 0 ].max)
1268
+ @find_jump = false
1269
+ end
1270
+
1271
+ @find_total = matches.length
1272
+ content_scroll_for(count, visible, key)
1273
+ end
1274
+
1275
+ def find_status
1276
+ return nil if @find.empty? || !findable?
1277
+ return "no line matches “#{@find}”" if @find_total.to_i.zero?
1278
+
1279
+ "match #{(@find_index % @find_total) + 1} of #{@find_total} for “#{@find}” · n/N steps"
1280
+ end
1281
+
1282
+ def handle_filter(key)
1283
+ case key
1284
+ # Enter accepts the filter — or, when it matched nothing, takes the term
1285
+ # to the search view instead. Widening is the natural next move from an
1286
+ # empty list, so it costs the same keystroke rather than a new one.
1287
+ when "\r", "\n" then return filter_found_nothing? ? escalate_to_search : (@filtering = false)
1288
+ when ESCAPE then (@filtering = false) || clear_filter
1289
+ when DELETE, "\b" then @filter.chop!
1290
+ when CTRL_C then @running = false
1291
+ else @filter << key if printable?(key)
1292
+ end
1293
+ reset_cursor
1294
+ end
1295
+
1296
+ def handle_query(key)
1297
+ case key
1298
+ when CTRL_C then return @running = false
1299
+ # Esc stops editing and nothing else. It used to clear the query and then,
1300
+ # on a second press, leave for the bundles view — but wanting to stop
1301
+ # typing is not wanting to leave: the results are right there, and the
1302
+ # arrows should reach them without the view changing underfoot.
1303
+ when ESCAPE then return leave_field
1304
+ when CTRL_U then return @query.clear # clear the line, as in a shell
1305
+ # Enter means "search" while the query has unsearched edits, and "open the
1306
+ # selected hit" once it does not. No mode flag decides this — whether the
1307
+ # typed query and the searched one agree is the whole state, and the
1308
+ # footer says which of the two the next Enter will do.
1309
+ when "\r", "\n" then return commit_search ? nil : open_hit
1310
+ when DELETE, "\b" then @query.chop!
1311
+ when DOWN then return move(1)
1312
+ when UP then return move(-1)
1313
+ when TAB then return switch(:bundles)
1314
+ else @query << key if printable?(key)
1315
+ end
1316
+ @cursor = 0
1317
+ @scroll = 0
1318
+ end
1319
+
1320
+ # A prompt takes either a line of text or a single confirming key.
1321
+ def handle_prompt(key)
1322
+ unless @prompt.free_text?
1323
+ pending = @prompt
1324
+ @prompt = nil
1325
+ @message = key.downcase == "y" ? resolve(pending) : "cancelled"
1326
+ return
1327
+ end
1328
+
1329
+ case key
1330
+ when ESCAPE, CTRL_C then (@prompt = nil) || (@message = "cancelled")
1331
+ when "\r", "\n"
1332
+ pending = @prompt
1333
+ @prompt = nil
1334
+ @message = resolve(pending)
1335
+ when DELETE, "\b" then @prompt.buffer.chop!
1336
+ else @prompt.buffer << key if printable?(key)
1337
+ end
1338
+ end
1339
+
1340
+ # A facet row narrows the graph; a concept row leaves for it. Selecting the
1341
+ # facet already in force clears it, so the same key toggles.
1342
+ def activate_graph_row
1343
+ entry = graph_selected
1344
+ return if entry.nil?
1345
+
1346
+ case entry[:kind]
1347
+ when :facet
1348
+ if facet_active?(entry[:field], entry[:value])
1349
+ @graph_facet = nil
1350
+ @message = "cleared the facet"
1351
+ else
1352
+ @graph_facet = { field: entry[:field], value: entry[:value] }
1353
+ @message = "graph narrowed to #{entry[:field]} #{entry[:value]}"
1354
+ end
1355
+ @cursor = graph_selectable.first.to_i
1356
+ when :concept
1357
+ open_concept(entry[:id])
1358
+ @message = "opened #{entry[:id]}"
1359
+ end
1360
+ end
1361
+
1362
+ # ── registry config ──────────────────────────────────────────────────────
1363
+
1364
+ # Carry out an answered prompt. Every registry write goes through here.
1365
+ def resolve(pending)
1366
+ message =
1367
+ case pending.kind
1368
+ when :add then resolve_add(pending)
1369
+ when :rename then resolve_rename(pending)
1370
+ when :remove then workspace.remove(pending.subject)
1371
+ when :group then workspace.create_group(pending.buffer, workspace.scope)
1372
+ when :ungroup then resolve_ungroup(pending)
1373
+ end
1374
+
1375
+ invalidate
1376
+ clamp_cursor
1377
+ message
1378
+ end
1379
+
1380
+ # Both halves come from the prompt rather than from the cursor: a write that
1381
+ # re-read the pane would act on whatever is selected *now*, not on what the
1382
+ # question named.
1383
+ def resolve_ungroup(pending)
1384
+ slug, member = pending.subject.split(MEMBER_REF, 2)
1385
+ message = keeping_group_scoped(slug) { workspace.remove_from_group(slug, [ member ]) }
1386
+ @member_cursor = 0
1387
+ # Nothing left to point at, so step back out rather than hold focus in a pane
1388
+ # describing a group that is gone. #clamp_cursor catches the same case for a
1389
+ # group emptied any other way; this is the one that knows *which* group.
1390
+ #
1391
+ # Only from the members, though: the same question can now be asked from the
1392
+ # bundles pane, and answering it is no reason to move that reader elsewhere.
1393
+ @pane = :groups if @pane == :members && workspace.group(slug).nil?
1394
+ message
1395
+ end
1396
+
1397
+ # A newly registered bundle is one the user just expressed interest in, so
1398
+ # it joins the scope and the cursor moves to it.
1399
+ def resolve_add(pending)
1400
+ before = workspace.entries.map(&:slug)
1401
+ message = workspace.add(pending.buffer)
1402
+
1403
+ added = workspace.entries.find { |entry| !before.include?(entry.slug) }
1404
+ if added
1405
+ # A reload keeps the scope that was there before, which cannot mention a
1406
+ # bundle that did not exist yet — so put the new one in explicitly.
1407
+ workspace.toggle_scope(added.slug) unless workspace.scoped?(added.slug)
1408
+ focus(added.slug)
1409
+ end
1410
+ message
1411
+ end
1412
+
1413
+ # Scope membership survives a rename. It is keyed by slug, but what the user
1414
+ # put in scope is the *bundle*, and its directory is what stays the same
1415
+ # across a rename — so reconcile on the directory, or the bundle silently
1416
+ # drops out of search the moment it is renamed.
1417
+ def resolve_rename(pending)
1418
+ dir = workspace.entry(pending.subject)&.dir
1419
+ message = workspace.rename(pending.subject, pending.buffer)
1420
+
1421
+ renamed = workspace.entries.find { |entry| entry.dir == dir }
1422
+ if renamed
1423
+ workspace.toggle_scope(renamed.slug) unless workspace.scoped?(renamed.slug)
1424
+ focus(renamed.slug)
1425
+ end
1426
+ message
1427
+ end
1428
+
1429
+ def ask(kind, label, free_text:, subject: nil)
1430
+ @prompt = Prompt.new(kind: kind, label: label, buffer: +"", subject: subject, free_text: free_text)
1431
+ end
1432
+
1433
+ # Both span a group, because okf's own `rename` and `del` do: one rename
1434
+ # cascades across every member list, and one `del` cascade-drops the slug and
1435
+ # deletes any group it empties. Nothing here has to know that — it just has to
1436
+ # pass the slug the cursor is on rather than assuming it is a bundle's.
1437
+ def ask_rename
1438
+ slug = selected_slug
1439
+ return if slug.nil?
1440
+
1441
+ ask(:rename, "rename @#{slug} to:", free_text: true, subject: slug)
1442
+ end
1443
+
1444
+ def ask_remove
1445
+ slug = selected_slug
1446
+ return if slug.nil?
1447
+
1448
+ what = detailing_group? ? "group @#{slug}" : "@#{slug}"
1449
+ ask(:remove, "remove #{what} from the registry? (y/n)", free_text: false, subject: slug)
1450
+ end
1451
+
1452
+ # The slug the focused pane has selected — a group's when the groups or member
1453
+ # pane holds focus, a bundle's otherwise.
1454
+ def selected_slug
1455
+ detailing_group? ? selected_group&.slug : selected_entry&.slug
1456
+ end
1457
+
1458
+ def set_default
1459
+ entry = selected_entry
1460
+ return if entry.nil?
1461
+
1462
+ @message = workspace.make_default(entry.slug)
1463
+ # Making a bundle the default moves it to the front, so the list reorders
1464
+ # under the cursor. Follow the bundle rather than the position, or the
1465
+ # selection silently lands on a different one.
1466
+ focus(entry.slug)
1467
+ end
1468
+
1469
+ def toggle_scope
1470
+ entry = selected_entry
1471
+ return if entry.nil?
1472
+
1473
+ workspace.toggle_scope(entry.slug)
1474
+ @search_hits_key = nil
1475
+ end
1476
+
1477
+ def focus(slug)
1478
+ index = visible_entries.index { |entry| entry.slug == slug }
1479
+ @cursor = index if index
1480
+ end
1481
+
1482
+ # ── switching bundles ────────────────────────────────────────────────────
1483
+
1484
+ # Enter in the bundles view: which pane has focus decides what it means.
1485
+ def open_registry_row
1486
+ return open_bundle unless detailing_group?
1487
+
1488
+ group = selected_group
1489
+ return if group.nil?
1490
+
1491
+ @message = workspace.scope_group(group.slug)
1492
+ @search_hits_key = nil
1493
+ end
1494
+
1495
+ # Enter on a bundle: make it the active one and go read it.
1496
+ def open_bundle
1497
+ entry = selected_entry
1498
+ return if entry.nil?
1499
+
1500
+ unless entry.loaded?
1501
+ @message = "@#{entry.slug} cannot be read: #{entry.error}"
1502
+ return
1503
+ end
1504
+
1505
+ activate(entry.slug)
1506
+ switch(:browse)
1507
+ @message = "@#{entry.slug} — #{model.concept_count} concepts"
1508
+ end
1509
+
1510
+ def activate(slug)
1511
+ return false unless workspace.switch(slug)
1512
+
1513
+ # The browse list and every scroll offset belong to the bundle that was
1514
+ # active; none of them mean anything against the new one.
1515
+ invalidate
1516
+ @filter = +""
1517
+ @graph_facet = nil
1518
+ @scroll = 0
1519
+ @detail_scroll = 0
1520
+ @content_scroll = 0
1521
+ @health_scroll = 0
1522
+ true
1523
+ end
1524
+
1525
+ def invalidate
1526
+ @list_entries_key = nil
1527
+ @search_hits_key = nil
1528
+ end
1529
+
1530
+ def printable?(key)
1531
+ key.length == 1 && key.ord >= 32 && key.ord < 127
1532
+ end
1533
+
1534
+ # A filter that is looking for something and finding nothing — the moment
1535
+ # where offering the wider search is worth the line it costs.
1536
+ #
1537
+ # The registry counts too. A filter there looks through a dozen slugs and the
1538
+ # groups beside them, which is a narrow thing to be typing: a term matching none
1539
+ # of them is far more likely a question about what the bundles *say* than about
1540
+ # what one is called. Same key, same escalation.
1541
+ #
1542
+ # Both panes have to be empty, not just the focused one. A filter matching a
1543
+ # group and no bundle has found something — the first cut of this read only the
1544
+ # bundles pane, and swallowed the Enter that accepts such a filter.
1545
+ def filter_found_nothing?
1546
+ return false if @filter.empty?
1547
+
1548
+ case @view
1549
+ when :browse then selectable_indices.empty?
1550
+ when :bundles then visible_entries.empty? && visible_groups.empty?
1551
+ else false
1552
+ end
1553
+ end
1554
+
1555
+ # What the escalation is actually worth, which is not the same in both cases:
1556
+ # the browse filter matches titles, ids, types and tags in *this* bundle,
1557
+ # while search reads bodies too and spans every bundle. With one bundle open
1558
+ # the win is the full text; with several it is also the reach.
1559
+ def escalation_offer
1560
+ return nil unless filter_found_nothing?
1561
+
1562
+ if workspace.entries.length > 1
1563
+ "search all #{workspace.entries.length} bundles"
1564
+ else
1565
+ "run a full-text search"
1566
+ end
1567
+ end
1568
+
1569
+ # Carry the filter term over to the search view and run it. The scope widens
1570
+ # to every bundle, because "not in this one" is the whole reason to escalate
1571
+ # — and the header keeps saying so, so the widening is visible and undoable.
1572
+ def escalate_to_search
1573
+ term = @filter.dup
1574
+ return if term.empty?
1575
+
1576
+ @filtering = false
1577
+ @filter = +""
1578
+ workspace.scope_all
1579
+
1580
+ switch(:search)
1581
+ @query = term
1582
+ commit_search
1583
+ @message = "searched #{workspace.scope.length} bundles for “#{term}”"
1584
+ end
1585
+
1586
+ # One key, three fields — whichever the focus makes it. The list pane and
1587
+ # the body pane are different things to look through, so `/` in each looks
1588
+ # through the one under the cursor rather than always the list.
1589
+ def start_typing
1590
+ return focus_field if @view == :search
1591
+ return start_find if findable?
1592
+
1593
+ start_filter
1594
+ end
1595
+
1596
+ # Is the cursor in a rendered document rather than a list? Two kinds, because
1597
+ # they scroll on different offsets: a concept body in the browse detail pane,
1598
+ # and the one-long-page views. Both are things to read, so both take a find.
1599
+ def reading_body?
1600
+ @view == :browse && @pane == :detail
1601
+ end
1602
+
1603
+ def reading_page?
1604
+ CONTENT_VIEWS.include?(@view)
1605
+ end
1606
+
1607
+ def findable?
1608
+ reading_body? || reading_page?
1609
+ end
1610
+
1611
+ def start_find
1612
+ @finding = true
1613
+ @find = +""
1614
+ @find_index = 0
1615
+ end
1616
+
1617
+ def start_filter
1618
+ return unless FILTERABLE_VIEWS.include?(@view)
1619
+
1620
+ @filtering = true
1621
+ @filter = +""
1622
+ end
1623
+
1624
+ # Esc undoes the narrowing in force, innermost first: a graph facet before
1625
+ # the filter, so one key backs out of both without leaving the view.
1626
+ def toggle_pane
1627
+ return cycle_bundles_pane if @view == :bundles
1628
+
1629
+ @pane = @pane == :list ? :detail : :list
1630
+ # The two graph panes hold different lists, so a cursor carried across
1631
+ # would point at an arbitrary row — or at a heading, where it vanishes.
1632
+ @cursor = graph_selectable.first.to_i if @view == :graph
1633
+ end
1634
+
1635
+ # bundles → groups → members → bundles, skipping what is not there: an empty
1636
+ # registry has no groups pane to reach, and a group with no members has no member
1637
+ # pane. Skipping rather than stopping, so Tab never appears to do nothing.
1638
+ def cycle_bundles_pane
1639
+ order = [ :bundles ]
1640
+ order << :groups unless visible_groups.empty?
1641
+ order << :members unless selected_group.nil? || group_members.empty?
1642
+
1643
+ return @message = "nothing else to step into — no groups here yet" if order.length == 1
1644
+
1645
+ @pane = order[(order.index(@pane) || 0) + 1] || order.first
1646
+ @member_cursor = 0 if @pane == :members
1647
+ @pane
1648
+ end
1649
+
1650
+ def handle_escape
1651
+ return if @view == :search
1652
+
1653
+ # Esc ends the innermost thing first, and a submitted find is still one of
1654
+ # them: Enter only releases the field, it does not end the find — the term
1655
+ # stays lit and n/N still step through it. Falling through to the list's
1656
+ # Esc from there resets the cursor and throws the reader back to the first
1657
+ # file, which is the one thing a find must never cost.
1658
+ if findable? && !@find.empty?
1659
+ clear_find
1660
+ return
1661
+ end
1662
+
1663
+ if @view == :graph && @graph_facet
1664
+ @graph_facet = nil
1665
+ @cursor = graph_selectable.first.to_i
1666
+ @message = "cleared the facet"
1667
+ return
1668
+ end
1669
+
1670
+ # Each pane is a layer, and all of them are innermore than the filter.
1671
+ if @view == :bundles && @pane == :members
1672
+ @pane = :groups
1673
+ @member_cursor = 0
1674
+ return
1675
+ end
1676
+
1677
+ if @view == :bundles && @pane == :groups
1678
+ @pane = :bundles
1679
+ return
1680
+ end
1681
+
1682
+ clear_filter
1683
+ end
1684
+
1685
+ def clear_filter
1686
+ @filter = +""
1687
+ reset_cursor
1688
+ end
1689
+
1690
+ def switch(target)
1691
+ # Entering search does *not* grab the field. A view that swallows every
1692
+ # printable key the moment you arrive turns "3" into query text and takes
1693
+ # the number keys away from navigation. Typing starts on `/` here, the
1694
+ # same key that starts the filter in browse.
1695
+ @search_focus = :results if target == :search
1696
+ @view = target
1697
+ # The filter described the view being left, not the one being entered.
1698
+ @filtering = false
1699
+ @filter = +""
1700
+ @graph_facet = nil
1701
+ reset_cursor
1702
+ @pane = target == :bundles ? :bundles : :list if %i[browse bundles].include?(target)
1703
+ end
1704
+
1705
+ def reset_cursor
1706
+ @pane = :bundles if @view == :bundles
1707
+ @group_cursor = 0
1708
+ @member_cursor = 0
1709
+ @cursor = 0
1710
+ @scroll = 0
1711
+ @detail_scroll = 0
1712
+ @content_scroll = 0
1713
+ @health_scroll = 0
1714
+ @pane = :list if @view == :health
1715
+ @cursor = selectable_indices.first.to_i if @view == :browse
1716
+ @cursor = active_index if @view == :bundles
1717
+ @cursor = graph_selectable.first.to_i if @view == :graph
1718
+ end
1719
+
1720
+ # Opening the bundles view puts the cursor on the bundle currently active,
1721
+ # which is the one the user is most likely reasoning about.
1722
+ def active_index
1723
+ visible_entries.index { |entry| entry.slug == workspace.active_slug } || 0
1724
+ end
1725
+
1726
+ def items_length
1727
+ case @view
1728
+ when :bundles then visible_entries.length
1729
+ when :search then search_hits.length
1730
+ else 0
1731
+ end
1732
+ end
1733
+
1734
+ def clamp_cursor
1735
+ count = items_length
1736
+ @cursor = count.zero? ? 0 : [ [ @cursor, count - 1 ].min, 0 ].max
1737
+ return unless @view == :bundles
1738
+
1739
+ # The other two panes are rebuilt by the same writes, so they need clamping
1740
+ # for the same reason — a group removed under the cursor leaves it past the end.
1741
+ groups = visible_groups.length
1742
+ @group_cursor = groups.zero? ? 0 : [ [ @group_cursor, groups - 1 ].min, 0 ].max
1743
+ members = group_members.length
1744
+ @member_cursor = members.zero? ? 0 : [ [ @member_cursor, members - 1 ].min, 0 ].max
1745
+ # A pane with nothing in it cannot hold focus.
1746
+ @pane = :bundles if (@pane == :groups && groups.zero?) || (@pane == :members && members.zero?)
1747
+ end
1748
+
1749
+ def step(position, delta, length)
1750
+ [ [ position + delta, 0 ].max, [ length - 1, 0 ].max ].min
1751
+ end
1752
+
1753
+ # Move the selection, skipping the directory headings so the cursor only ever
1754
+ # lands on something selectable.
1755
+ def move(delta)
1756
+ # health, graph and help are one long page with nothing to select, so the
1757
+ # keys scroll them instead of moving a cursor.
1758
+ if CONTENT_VIEWS.include?(@view)
1759
+ key = content_scroll_key
1760
+ set_scroll(key, [ scroll_for(key) + delta, 0 ].max)
1761
+ return
1762
+ end
1763
+
1764
+ if @view == :graph
1765
+ indices = graph_selectable
1766
+ return if indices.empty?
1767
+
1768
+ position = indices.index(@cursor) || 0
1769
+ @cursor = indices[[ [ position + delta, 0 ].max, indices.length - 1 ].min]
1770
+ return
1771
+ end
1772
+
1773
+ if @view == :bundles
1774
+ case @pane
1775
+ when :members then @member_cursor = step(@member_cursor, delta, group_members.length)
1776
+ when :groups then @group_cursor = step(@group_cursor, delta, visible_groups.length)
1777
+ else @cursor = step(@cursor, delta, visible_entries.length)
1778
+ end
1779
+ return
1780
+ end
1781
+
1782
+ if @view == :browse && @pane == :detail
1783
+ @detail_scroll = [ @detail_scroll + delta, 0 ].max
1784
+ return
1785
+ end
1786
+
1787
+ if @view == :search
1788
+ return if search_hits.empty?
1789
+
1790
+ @cursor = [ [ @cursor + delta, 0 ].max, search_hits.length - 1 ].min
1791
+ return
1792
+ end
1793
+
1794
+ indices = selectable_indices
1795
+ return if indices.empty?
1796
+
1797
+ position = indices.index(@cursor) || 0
1798
+ position = [ [ position + delta, 0 ].max, indices.length - 1 ].min
1799
+ @cursor = indices[position]
1800
+ @detail_scroll = 0
1801
+ end
1802
+
1803
+ def jump(where)
1804
+ if CONTENT_VIEWS.include?(@view)
1805
+ # `G` on a scrolling page: a deliberately large offset, clamped to the
1806
+ # real end by content_scroll_for once the view reports its length.
1807
+ set_scroll(content_scroll_key, where == :first ? 0 : 1_000_000)
1808
+ return
1809
+ end
1810
+
1811
+ if @view == :graph
1812
+ indices = graph_selectable
1813
+ @cursor = (where == :first ? indices.first : indices.last).to_i
1814
+ return
1815
+ end
1816
+
1817
+ if @view == :bundles
1818
+ length =
1819
+ case @pane
1820
+ when :members then group_members.length
1821
+ when :groups then visible_groups.length
1822
+ else visible_entries.length
1823
+ end
1824
+ last = [ length - 1, 0 ].max
1825
+ position = where == :first ? 0 : last
1826
+
1827
+ case @pane
1828
+ when :members then @member_cursor = position
1829
+ when :groups then @group_cursor = position
1830
+ else @cursor = position
1831
+ end
1832
+ return
1833
+ end
1834
+
1835
+ if @view == :search
1836
+ @cursor = where == :first ? 0 : [ items_length - 1, 0 ].max
1837
+ return
1838
+ end
1839
+
1840
+ indices = selectable_indices
1841
+ return if indices.empty?
1842
+
1843
+ @cursor = where == :first ? indices.first : indices.last
1844
+ @detail_scroll = 0
1845
+ end
1846
+
1847
+ # Enter on a search hit. Across bundles the hit may belong to one that is not
1848
+ # active, so switching to it is part of opening — otherwise browse would
1849
+ # select an id in the wrong bundle, or in none.
1850
+ def open_hit
1851
+ hit = search_hits[@cursor]
1852
+ return if hit.nil?
1853
+
1854
+ slug = hit[:slug] || workspace.active_slug
1855
+ switched = slug != workspace.active_slug
1856
+ activate(slug) if switched
1857
+
1858
+ open_concept(hit[:id])
1859
+ @message = switched ? "opened @#{slug} — #{hit[:id]}" : "opened #{hit[:id]}"
1860
+ end
1861
+
1862
+ # Land in browse with `id` selected and its body showing. Shared by opening
1863
+ # a search hit, following a concept out of the graph, and following a link
1864
+ # out of a document.
1865
+ def open_concept(id)
1866
+ push_trail
1867
+ @view = :browse
1868
+ @pane = :detail
1869
+ @filtering = false
1870
+ @filter = +""
1871
+ @detail_scroll = 0
1872
+ @list_entries_key = nil
1873
+
1874
+ index = list_entries.index { |entry| entry[:kind] == :concept && entry[:row][:id] == id }
1875
+ @cursor = index || selectable_indices.first.to_i
1876
+ @scroll = 0
1877
+ end
1878
+
1879
+ # The reserved-file twin of open_concept. An index.md is a bundle's way in
1880
+ # (§6), so a link that points at one has to land the same way a concept does.
1881
+ def open_reserved(path)
1882
+ push_trail
1883
+ @view = :browse
1884
+ @pane = :detail
1885
+ @filtering = false
1886
+ @filter = +""
1887
+ @detail_scroll = 0
1888
+ @list_entries_key = nil
1889
+
1890
+ index = list_entries.index { |entry| entry[:kind] == :reserved && entry[:path] == path }
1891
+ @cursor = index || selectable_indices.first.to_i
1892
+ @scroll = 0
1893
+ end
1894
+
1895
+ def reload
1896
+ workspace.reload
1897
+ @body_cache = {}
1898
+ # The trail holds ids and offsets from before the reload; a bundle that
1899
+ # changed on disk may no longer have them.
1900
+ @trail = []
1901
+ invalidate
1902
+ reset_cursor
1903
+ @message = "reloaded #{workspace.entries.length} bundles"
1904
+ rescue StandardError => e
1905
+ @message = "reload failed: #{e.message}"
1906
+ end
1907
+ end
1908
+ end