Class: OKF::TUI::App

Inherits:
Object
  • Object
show all
Defined in:
lib/okf/tui/app.rb

Overview

The interactive loop: hold the state, read a key, mutate the state, repaint.

Painting is whole-frame. Every view returns a rectangle of rows already padded to the terminal width, so a repaint is "cursor home, print N rows" — each row overwrites the one beneath it exactly, which is what keeps the screen from flickering without any diffing machinery.

Two axes of state, and keeping them apart is what makes the views simple: the active bundle (what browse, health and graph are about) and the scope (which bundles a search covers). Only the bundles view moves the first; only the scope keys move the second.

Defined Under Namespace

Classes: Prompt

Constant Summary collapse

CTRL_C =

Control keys, named so the source carries no invisible bytes.

"\u0003"
CTRL_D =
"\u0004"
CTRL_U =
"\u0015"
DELETE =
"\u007F"
MEMBER_REF =

Joins a group slug to one of its members inside a prompt's subject. A NUL can appear in neither, which is why okf uses the same byte to key its own corpus.

"\u0000"
ESCAPE =
"\e"
TAB =
"\t"
UP =
"\e[A"
DOWN =
"\e[B"
SINGLE_BUNDLE_VIEWS =

The views that are about one bundle, and so have nothing to show when none can be read.

%i[browse health graph].freeze
CONTENT_VIEWS =

The views that are a single scrolling page rather than a selectable list.

%i[health help].freeze
TABS =

Six, and help stays last where a reader expects it. There was a seventh — an index map beside browse — and it went because browse answers the same question with a key the reader already has: /index lists every way in, one per directory, and opens the authored file. What the map could show and browse cannot is real but narrow (the tree indented as it nests, /· for a directory that authored no index.md, okf's synthesized listing for one, and the per-directory type/tag rollups), and it did not earn a permanent tab.

[
  [ :bundles, "bundles" ],
  [ :browse, "browse" ],
  [ :search, "search" ],
  [ :graph, "graph" ],
  [ :health, "health" ],
  [ :help, "help" ]
].freeze
KEY_VIEWS =
{ "1" => :bundles, "2" => :browse, "3" => :search,
"4" => :graph, "5" => :health, "6" => :help }.freeze
FILTERABLE_VIEWS =

The views with a list to narrow. / starts typing in each of them; the filter belongs to the view, so switching away drops it rather than carrying a bundle filter into the concept list.

%i[bundles browse graph].freeze
PANES =

── the bundles view's three panes ───────────────────────────────────────

bundles (top left), groups (bottom left), and the detail of whichever of the two has focus (right). Tab cycles; each pane keeps its own cursor.

It was one interleaved list under a GROUPS heading, and the shape caused three separate problems. The heading was a row nothing could select, so the cursor had to skip it. A long registry pushed the groups below the fold, so they were not reliably on screen at all. And with only one selection at a time, adding a bundle to a group had nothing to name the bundle with — which is why + reached for the search scope, and why it was the key that kept being wrong.

Two selections make + direct: the bundle under the cursor joins the group that is selected. Nothing about searching is involved.

%i[bundles groups members].freeze
PARSE_WIDTH =

Wide enough that tty-markdown never wraps a line itself.

Its wrapper (the strings gem) miscounts ANSI escapes and raises IndexError from String#insert on coloured input — 50 of 192 concept/width combinations in the okf bundle alone, and only in a real terminal, since a pipe turns the colour off. Handing it a width it can never reach sidesteps the wrapping entirely, and Ui.reflow does the job instead: it was already re-wrapping this output anyway, because tty-markdown otherwise keeps the source line breaks. Tables come out at their natural width and are clipped to the pane exactly as before.

10_000
SEARCH_MODES =

The three ways okf can be asked a question, cycled with e. Modes rather than engine names, because that is how okf routes: a query declares the capability it needs and the facade picks the engine that has it.

[
  { id: :fuzzy, label: "fuzzy", note: "ranked, typo-tolerant — misses terms glued to symbols" },
  { id: :text, label: "text", note: "raw substring, okf's default — finds $OKF_HOME and `minifts`" },
  { id: :regexp, label: "regexp", note: "a pattern over the same raw text" }
].freeze
MAX_TRAIL =

── the way back ─────────────────────────────────────────────────────────

A jump nobody can undo is a jump nobody makes twice. Pushed inside the two openers, which means following a link, opening a search hit and leaving the graph for a concept are all reversible by the same key.

32

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(dirs: [], ref_slugs: {}, home: nil, cwd: nil, output: $stdout) ⇒ App

Returns a new instance of App.



87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/okf/tui/app.rb', line 87

def initialize(dirs: [], ref_slugs: {}, home: nil, cwd: nil, output: $stdout)
  @output = output
  @workspace = Workspace.new(dirs: dirs, ref_slugs: ref_slugs, home: home, cwd: cwd)
  # With one readable bundle there is nothing to pick between, so open on it
  # directly. Otherwise start on the list — including when nothing is
  # readable, where the list is the only place the problem can be seen.
  @view = @workspace.entries.length == 1 && @workspace.model ? :browse : :bundles
  @pane = :list
  @cursor = 0
  @filter = +""
  @query = +""
  @searched = +"" # the query the current results belong to; see #search_hits
  @graph_facet = nil
  @search_focus = :results
  @search_mode = :fuzzy
  @find = +"" # find-in-document, when the detail pane has focus
  @finding = false
  @find_index = 0
  @find_jump = false
  @filtering = false
  @following = false # the link picker, over the document in the detail pane
  @follow_cursor = 0
  @follow_scroll = 0
  @group_cursor = 0  # the groups pane
  @group_scroll = 0
  @member_cursor = 0 # the member pane
  @trail = [] # where the reader was before each jump
  @prompt = nil
  @scroll = 0
  @detail_scroll = 0
  @content_scroll = 0
  # health's summary pane, which scrolls apart from its findings
  @health_scroll = 0
  @body_cache = {}
  @message = nil
  @running = true
  @quit_armed = false
  @cursor_control = TTY::Cursor
  @reader = TTY::Reader.new(interrupt: :exit, track_history: false)
  reset_cursor
end

Instance Attribute Details

#cursorObject (readonly)

── graph view state ─────────────────────────────────────────────────────

The graph is two selectable lists rather than a page: facets on the left (a type or a tag), concepts on the right. Tab moves between them, and what Enter means follows from which kind of row the cursor is on — narrow by this facet, or go read this concept.



501
502
503
# File 'lib/okf/tui/app.rb', line 501

def cursor
  @cursor
end

#filterObject (readonly)

── graph view state ─────────────────────────────────────────────────────

The graph is two selectable lists rather than a page: facets on the left (a type or a tag), concepts on the right. Tab moves between them, and what Enter means follows from which kind of row the cursor is on — narrow by this facet, or go read this concept.



501
502
503
# File 'lib/okf/tui/app.rb', line 501

def filter
  @filter
end

#findObject (readonly)

── graph view state ─────────────────────────────────────────────────────

The graph is two selectable lists rather than a page: facets on the left (a type or a tag), concepts on the right. Tab moves between them, and what Enter means follows from which kind of row the cursor is on — narrow by this facet, or go read this concept.



501
502
503
# File 'lib/okf/tui/app.rb', line 501

def find
  @find
end

#find_indexObject (readonly)

── graph view state ─────────────────────────────────────────────────────

The graph is two selectable lists rather than a page: facets on the left (a type or a tag), concepts on the right. Tab moves between them, and what Enter means follows from which kind of row the cursor is on — narrow by this facet, or go read this concept.



501
502
503
# File 'lib/okf/tui/app.rb', line 501

def find_index
  @find_index
end

#follow_cursorObject (readonly)

── graph view state ─────────────────────────────────────────────────────

The graph is two selectable lists rather than a page: facets on the left (a type or a tag), concepts on the right. Tab moves between them, and what Enter means follows from which kind of row the cursor is on — narrow by this facet, or go read this concept.



501
502
503
# File 'lib/okf/tui/app.rb', line 501

def follow_cursor
  @follow_cursor
end

#graph_facetObject (readonly)

── graph view state ─────────────────────────────────────────────────────

The graph is two selectable lists rather than a page: facets on the left (a type or a tag), concepts on the right. Tab moves between them, and what Enter means follows from which kind of row the cursor is on — narrow by this facet, or go read this concept.



501
502
503
# File 'lib/okf/tui/app.rb', line 501

def graph_facet
  @graph_facet
end

#group_cursorObject (readonly)

── graph view state ─────────────────────────────────────────────────────

The graph is two selectable lists rather than a page: facets on the left (a type or a tag), concepts on the right. Tab moves between them, and what Enter means follows from which kind of row the cursor is on — narrow by this facet, or go read this concept.



501
502
503
# File 'lib/okf/tui/app.rb', line 501

def group_cursor
  @group_cursor
end

#member_cursorObject (readonly)

── graph view state ─────────────────────────────────────────────────────

The graph is two selectable lists rather than a page: facets on the left (a type or a tag), concepts on the right. Tab moves between them, and what Enter means follows from which kind of row the cursor is on — narrow by this facet, or go read this concept.



501
502
503
# File 'lib/okf/tui/app.rb', line 501

def member_cursor
  @member_cursor
end

#paneObject (readonly)

── graph view state ─────────────────────────────────────────────────────

The graph is two selectable lists rather than a page: facets on the left (a type or a tag), concepts on the right. Tab moves between them, and what Enter means follows from which kind of row the cursor is on — narrow by this facet, or go read this concept.



501
502
503
# File 'lib/okf/tui/app.rb', line 501

def pane
  @pane
end

#promptObject (readonly)

── graph view state ─────────────────────────────────────────────────────

The graph is two selectable lists rather than a page: facets on the left (a type or a tag), concepts on the right. Tab moves between them, and what Enter means follows from which kind of row the cursor is on — narrow by this facet, or go read this concept.



501
502
503
# File 'lib/okf/tui/app.rb', line 501

def prompt
  @prompt
end

#queryObject (readonly)

── graph view state ─────────────────────────────────────────────────────

The graph is two selectable lists rather than a page: facets on the left (a type or a tag), concepts on the right. Tab moves between them, and what Enter means follows from which kind of row the cursor is on — narrow by this facet, or go read this concept.



501
502
503
# File 'lib/okf/tui/app.rb', line 501

def query
  @query
end

#search_modeObject (readonly)

── graph view state ─────────────────────────────────────────────────────

The graph is two selectable lists rather than a page: facets on the left (a type or a tag), concepts on the right. Tab moves between them, and what Enter means follows from which kind of row the cursor is on — narrow by this facet, or go read this concept.



501
502
503
# File 'lib/okf/tui/app.rb', line 501

def search_mode
  @search_mode
end

#searchedObject (readonly)

── graph view state ─────────────────────────────────────────────────────

The graph is two selectable lists rather than a page: facets on the left (a type or a tag), concepts on the right. Tab moves between them, and what Enter means follows from which kind of row the cursor is on — narrow by this facet, or go read this concept.



501
502
503
# File 'lib/okf/tui/app.rb', line 501

def searched
  @searched
end

#viewObject (readonly)

── graph view state ─────────────────────────────────────────────────────

The graph is two selectable lists rather than a page: facets on the left (a type or a tag), concepts on the right. Tab moves between them, and what Enter means follows from which kind of row the cursor is on — narrow by this facet, or go read this concept.



501
502
503
# File 'lib/okf/tui/app.rb', line 501

def view
  @view
end

#workspaceObject (readonly)

── graph view state ─────────────────────────────────────────────────────

The graph is two selectable lists rather than a page: facets on the left (a type or a tag), concepts on the right. Tab moves between them, and what Enter means follows from which kind of row the cursor is on — narrow by this facet, or go read this concept.



501
502
503
# File 'lib/okf/tui/app.rb', line 501

def workspace
  @workspace
end

Instance Method Details

#activate(slug) ⇒ Object



1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
# File 'lib/okf/tui/app.rb', line 1510

def activate(slug)
  return false unless workspace.switch(slug)

  # The browse list and every scroll offset belong to the bundle that was
  # active; none of them mean anything against the new one.
  invalidate
  @filter = +""
  @graph_facet = nil
  @scroll = 0
  @detail_scroll = 0
  @content_scroll = 0
  @health_scroll = 0
  true
end

#activate_graph_rowObject

A facet row narrows the graph; a concept row leaves for it. Selecting the facet already in force clears it, so the same key toggles.



1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
# File 'lib/okf/tui/app.rb', line 1342

def activate_graph_row
  entry = graph_selected
  return if entry.nil?

  case entry[:kind]
  when :facet
    if facet_active?(entry[:field], entry[:value])
      @graph_facet = nil
      @message = "cleared the facet"
    else
      @graph_facet = { field: entry[:field], value: entry[:value] }
      @message = "graph narrowed to #{entry[:field]} #{entry[:value]}"
    end
    @cursor = graph_selectable.first.to_i
  when :concept
    open_concept(entry[:id])
    @message = "opened #{entry[:id]}"
  end
end

#active_indexObject

Opening the bundles view puts the cursor on the bundle currently active, which is the one the user is most likely reasoning about.



1722
1723
1724
# File 'lib/okf/tui/app.rb', line 1722

def active_index
  visible_entries.index { |entry| entry.slug == workspace.active_slug } || 0
end

#add_selected_to_groupObject

+ and - on a group row: the cursor names the group, the scope names the bundles. No prompt, because neither half is ambiguous — which is the reason to spend the scope on this rather than ask for a typed list. + from the bundles pane: the bundle under this cursor joins the group the groups pane has selected. Two rows, both on screen, neither of them the search scope — which is what the earlier cuts of this key got wrong twice.

Additive and reversible, so it does not ask.



993
994
995
996
997
998
999
1000
1001
1002
1003
# File 'lib/okf/tui/app.rb', line 993

def add_selected_to_group
  entry = selected_entry
  group = selected_group
  return (@message = "no bundle here to add") if entry.nil?
  return (@message = "no group to add it to — c names the bundles in scope as one") if group.nil?
  return (@message = "@#{group.slug} already names @#{entry.slug}") if group.members.include?(entry.slug)

  apply_group_edit_keeping_focus do
    keeping_group_scoped(group.slug) { workspace.add_to_group(group.slug, [ entry.slug ]) }
  end
end

#apply_group_edit_keeping_focusObject

The cursor stays where it was in both panes: a write should not move the reader, and with two selections there are two to preserve.



1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
# File 'lib/okf/tui/app.rb', line 1050

def apply_group_edit_keeping_focus
  bundle = selected_entry&.slug
  group = selected_group&.slug
  @message = yield
  @search_hits_key = nil
  invalidate
  clamp_cursor
  focus(bundle) if bundle
  focus_group(group) if group
  @message
end

#arm_quitObject

q quit on the first press, so one stray keystroke ended the session with nothing to undo it. It arms instead, and says so — a chord nobody is told about is just a key that stopped working.



911
912
913
914
# File 'lib/okf/tui/app.rb', line 911

def arm_quit
  @quit_armed = true
  @message = "press q again to quit"
end

#ask(kind, label, free_text:, subject: nil) ⇒ Object



1429
1430
1431
# File 'lib/okf/tui/app.rb', line 1429

def ask(kind, label, free_text:, subject: nil)
  @prompt = Prompt.new(kind: kind, label: label, buffer: +"", subject: subject, free_text: free_text)
end

#ask_groupObject

c names the bundles now in scope. The scope is the selection in this view — says so on every row — so a group is made by toggling what you want and giving it a name, rather than by typing a member list a second time.



977
978
979
980
981
982
983
# File 'lib/okf/tui/app.rb', line 977

def ask_group
  scoped = workspace.scope
  return (@message = "nothing in scope to name — space toggles a bundle in") if scoped.empty?

  ask(:group, "name for a group of #{scoped.length} scoped #{scoped.length == 1 ? "bundle" : "bundles"}:",
    free_text: true)
end

#ask_member_gone(group, member) ⇒ Object

One question for both panes that can ask it, so the consequence is named the same way whichever row the reader was pointing at.



1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
# File 'lib/okf/tui/app.rb', line 1037

def ask_member_gone(group, member)
  label =
    if group.members.length == 1
      "remove @#{member} — its last, so @#{group.slug} goes too. remove? (y/n)"
    else
      "remove @#{member} from @#{group.slug}? (y/n)"
    end

  ask(:ungroup, label, free_text: false, subject: [ group.slug, member ].join(MEMBER_REF))
end

#ask_member_removalObject

Removing acts on the member row under the cursor — one member, the one being pointed at. It still asks, because okf deletes a group whose last member leaves, so this key can destroy the group rather than trim it, and the question says which of the two is about to happen.



1025
1026
1027
1028
1029
1030
1031
1032
1033
# File 'lib/okf/tui/app.rb', line 1025

def ask_member_removal
  group = selected_group
  return (@message = "Tab into a group's members to remove one") if group.nil?

  member = selected_member
  return (@message = "@#{group.slug} has no members to remove") if member.nil?

  ask_member_gone(group, member)
end

#ask_removeObject



1444
1445
1446
1447
1448
1449
1450
# File 'lib/okf/tui/app.rb', line 1444

def ask_remove
  slug = selected_slug
  return if slug.nil?

  what = detailing_group? ? "group @#{slug}" : "@#{slug}"
  ask(:remove, "remove #{what} from the registry? (y/n)", free_text: false, subject: slug)
end

#ask_renameObject

Both span a group, because okf's own rename and del do: one rename cascades across every member list, and one del cascade-drops the slug and deletes any group it empties. Nothing here has to know that — it just has to pass the slug the cursor is on rather than assuming it is a bundle's.



1437
1438
1439
1440
1441
1442
# File 'lib/okf/tui/app.rb', line 1437

def ask_rename
  slug = selected_slug
  return if slug.nil?

  ask(:rename, "rename @#{slug} to:", free_text: true, subject: slug)
end

#backObject



1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
# File 'lib/okf/tui/app.rb', line 1195

def back
  spot = @trail.pop
  return if spot.nil?

  # activate resets every offset, so the remembered ones are restored after
  # it rather than before — otherwise the bundle switch quietly wipes them.
  activate(spot[:slug]) if spot[:slug] != workspace.active_slug
  @following = false
  @view = spot[:view]
  @pane = spot[:pane]
  @filter = spot[:filter].dup
  @list_entries_key = nil
  @cursor = spot[:cursor]
  @scroll = spot[:scroll]
  @detail_scroll = spot[:detail_scroll]
  @message = "back"
end

#browse_groupsObject

The bundle as it sits on disk: each directory in turn, its index.md first, then its log.md, then its concepts — and the root before anything nested, since that is the order someone reads a bundle in (§6 makes index.md the way in). Sorting by path puts every subfolder directly under its parent.



452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
# File 'lib/okf/tui/app.rb', line 452

def browse_groups
  return {} unless model

  groups = Hash.new { |hash, dir| hash[dir] = { reserved: [], concepts: [] } }

  model.reserved.each do |entry|
    next unless matches_filter?(entry.path)

    groups[File.dirname(entry.path)][:reserved] << entry.path
  end

  filtered_rows.each do |row|
    groups[File.dirname(row[:path])][:concepts] << row
  end

  groups.each_value { |group| group[:reserved].sort_by! { |path| File.basename(path) } }
  groups.sort_by { |dir, _| dir == "." ? [ 0, "" ] : [ 1, dir ] }
end

#bundle_key(key) ⇒ Object

The bundles pane. + is the one that reaches across: the bundle under this cursor joins the group selected in the pane below, which is two visible rows rather than an invisible set.



938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
# File 'lib/okf/tui/app.rb', line 938

def bundle_key(key)
  case key
  when " " then toggle_scope
  when "A" then workspace.scope_all && @search_hits_key = nil
  when "N" then workspace.scope_none && @search_hits_key = nil
  when "d" then set_default
  when "a" then ask(:add, "directory to register:", free_text: true)
  when "n" then ask_rename
  when "x" then ask_remove
  when "c" then ask_group
  when "+" then add_selected_to_group
  # Removal lives in the pane that lists the members, where each one has a row
  # of its own. Saying so beats going silent: a key that quietly stopped working
  # is indistinguishable from a broken one.
  when "-" then @message = "- removes a member: Tab to the groups, Tab again into them"
  end
end

#bundles_pane?Boolean

Returns:

  • (Boolean)


324
325
326
# File 'lib/okf/tui/app.rb', line 324

def bundles_pane?
  @view == :bundles && @pane == :bundles
end

#clamp_cursorObject



1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
# File 'lib/okf/tui/app.rb', line 1734

def clamp_cursor
  count = items_length
  @cursor = count.zero? ? 0 : [ [ @cursor, count - 1 ].min, 0 ].max
  return unless @view == :bundles

  # The other two panes are rebuilt by the same writes, so they need clamping
  # for the same reason — a group removed under the cursor leaves it past the end.
  groups = visible_groups.length
  @group_cursor = groups.zero? ? 0 : [ [ @group_cursor, groups - 1 ].min, 0 ].max
  members = group_members.length
  @member_cursor = members.zero? ? 0 : [ [ @member_cursor, members - 1 ].min, 0 ].max
  # A pane with nothing in it cannot hold focus.
  @pane = :bundles if (@pane == :groups && groups.zero?) || (@pane == :members && members.zero?)
end

#clear_filterObject



1685
1686
1687
1688
# File 'lib/okf/tui/app.rb', line 1685

def clear_filter
  @filter = +""
  reset_cursor
end

#clear_findObject



1213
1214
1215
1216
1217
1218
# File 'lib/okf/tui/app.rb', line 1213

def clear_find
  @finding = false
  @find = +""
  @find_index = 0
  @find_jump = false
end

#commit_searchObject

Adopt the typed query and let the next #search_hits do the work. Called from the loop after the pause, and directly on Enter.



833
834
835
836
837
838
839
840
# File 'lib/okf/tui/app.rb', line 833

def commit_search
  return false unless @searched != @query

  @searched = @query.dup
  @cursor = 0
  @scroll = 0
  true
end

#content_offset(count, visible, matches, key = :content) ⇒ Object



1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
# File 'lib/okf/tui/app.rb', line 1264

def content_offset(count, visible, matches, key = :content)
  if @find_jump && !matches.empty?
    @find_index %= matches.length
    set_scroll(key, [ matches[@find_index] - 2, 0 ].max)
    @find_jump = false
  end

  @find_total = matches.length
  content_scroll_for(count, visible, key)
end

#content_scroll_for(count, visible, key = :content) ⇒ Object

The offset for the views that are one long page rather than a list — health, graph and help. Clamped here, against the row count the view actually produced, so the state can never point past the end.



693
694
695
696
# File 'lib/okf/tui/app.rb', line 693

def content_scroll_for(count, visible, key = :content)
  max = [ count - visible, 0 ].max
  set_scroll(key, [ [ scroll_for(key), max ].min, 0 ].max)
end

#content_scroll_keyObject

Which of the two the keys move: the one with focus.



710
711
712
# File 'lib/okf/tui/app.rb', line 710

def content_scroll_key
  @view == :health && @pane == :detail ? :health : :content
end

#cycle_bundles_paneObject

bundles → groups → members → bundles, skipping what is not there: an empty registry has no groups pane to reach, and a group with no members has no member pane. Skipping rather than stopping, so Tab never appears to do nothing.



1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
# File 'lib/okf/tui/app.rb', line 1638

def cycle_bundles_pane
  order = [ :bundles ]
  order << :groups unless visible_groups.empty?
  order << :members unless selected_group.nil? || group_members.empty?

  return @message = "nothing else to step into — no groups here yet" if order.length == 1

  @pane = order[(order.index(@pane) || 0) + 1] || order.first
  @member_cursor = 0 if @pane == :members
  @pane
end

#cycle_search_modeObject

Cycling re-asks the question rather than leaving the previous engine's answer sitting under a new label: the hits are keyed on the mode, so it reruns.



798
799
800
801
802
803
# File 'lib/okf/tui/app.rb', line 798

def cycle_search_mode
  index = SEARCH_MODES.index { |mode| mode[:id] == @search_mode }.to_i
  @search_mode = SEARCH_MODES[(index + 1) % SEARCH_MODES.length][:id]
  @cursor = 0
  @message = "search: #{search_mode_label}#{search_mode_note}"
end

#degree_entries(subset, field, colour) ⇒ Object



643
644
645
646
647
648
649
650
651
# File 'lib/okf/tui/app.rb', line 643

def degree_entries(subset, field, colour)
  top = subset.sort_by { |row| -row[field].to_i }.first(8)
  peak = top.map { |row| row[field].to_i }.max
  pad = top.map { |row| Ui.width(row[:id].to_s) }.max

  top.map do |row|
    { kind: :concept, id: row[:id], count: row[field].to_i, peak: peak, pad: pad, colour: colour }
  end
end

#detail_offset(count, visible, matches) ⇒ Object

The scroll offset for a body, honouring a pending jump. The match lands a couple of lines down rather than flush at the top, so it arrives with the context above it.



1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
# File 'lib/okf/tui/app.rb', line 1244

def detail_offset(count, visible, matches)
  if @find_jump && !matches.empty?
    @find_index %= matches.length
    @detail_scroll = [ matches[@find_index] - 2, 0 ].max
    @find_jump = false
  end

  # Remembered so the status line can report the find without needing the
  # body, which only the detail pane has.
  @find_total = matches.length
  detail_scroll_for(count, visible)
end

#detail_scroll_for(count, visible) ⇒ Object



685
686
687
688
# File 'lib/okf/tui/app.rb', line 685

def detail_scroll_for(count, visible)
  max = [ count - visible, 0 ].max
  @detail_scroll = [ [ @detail_scroll, max ].min, 0 ].max
end

#detailing_group?Boolean

Which of the two left panes the detail pane is describing. The members pane is a view of a group, so it counts as the groups side.

Returns:

  • (Boolean)


338
339
340
# File 'lib/okf/tui/app.rb', line 338

def detailing_group?
  @view == :bundles && %i[groups members].include?(@pane) && !selected_group.nil?
end

#editing_query?Boolean

Does the search field have focus — i.e. do printable keys type into it?

Returns:

  • (Boolean)


806
807
808
# File 'lib/okf/tui/app.rb', line 806

def editing_query?
  @view == :search && @search_focus == :field
end

#escalate_to_searchObject

Carry the filter term over to the search view and run it. The scope widens to every bundle, because "not in this one" is the whole reason to escalate — and the header keeps saying so, so the widening is visible and undoable.



1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
# File 'lib/okf/tui/app.rb', line 1572

def escalate_to_search
  term = @filter.dup
  return if term.empty?

  @filtering = false
  @filter = +""
  workspace.scope_all

  switch(:search)
  @query = term
  commit_search
  @message = "searched #{workspace.scope.length} bundles for “#{term}"
end

#escalation_offerObject

What the escalation is actually worth, which is not the same in both cases: the browse filter matches titles, ids, types and tags in this bundle, while search reads bodies too and spans every bundle. With one bundle open the win is the full text; with several it is also the reach.



1559
1560
1561
1562
1563
1564
1565
1566
1567
# File 'lib/okf/tui/app.rb', line 1559

def escalation_offer
  return nil unless filter_found_nothing?

  if workspace.entries.length > 1
    "search all #{workspace.entries.length} bundles"
  else
    "run a full-text search"
  end
end

#facet_active?(field, value) ⇒ Boolean

Returns:

  • (Boolean)


538
539
540
# File 'lib/okf/tui/app.rb', line 538

def facet_active?(field, value)
  @graph_facet && @graph_facet[:field] == field && @graph_facet[:value] == value
end

#facet_admits?(row, field, value) ⇒ Boolean

A type and a tag match exactly; a dir names itself and everything beneath it, which is okf's --dir rule (see Model.under_dir?). Narrowing to platform therefore reaches platform/services/api, exactly as okf catalog --dir platform does — a dir facet that matched only its own level would be the first-path-segment rollup --area was, which okf deprecated for losing every level below it.

Returns:

  • (Boolean)


522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
# File 'lib/okf/tui/app.rb', line 522

def facet_admits?(row, field, value)
  case field
  when :type then Model.type_label(row[:type]) == value
  when :dir then Model.under_dir?(row[:dir], value)
  # §5.4 and §5.3, narrowed exactly as `--status` and `--trust` narrow —
  # okf's own predicate, so an absent status reads `stable` here the way it
  # reads `stable` there, and a tier folds both spellings. Trust also gates on
  # what the row is willing to claim, so the facet covers exactly the rows
  # wearing a tier: counting rows this screen shows no tier for would narrow
  # to more concepts than the count promised.
  when :status then OKF::Bundle::RowFilter.matches?(row, status: value)
  when :trust then Model.shows_trust?(row) && OKF::Bundle::RowFilter.matches?(row, trust: value)
  else Array(row[:tags]).map(&:to_s).include?(value)
  end
end

#facet_rows(pairs, field) ⇒ Object

Each block of bars is scaled to its own tallest, and its labels padded to its own longest, so the two sections read independently.



608
609
610
611
612
613
614
615
# File 'lib/okf/tui/app.rb', line 608

def facet_rows(pairs, field)
  peak = pairs.map { |_, count| count }.max
  pad = pairs.map { |value, _| Ui.width(value.to_s) }.max

  pairs.map do |value, count|
    { kind: :facet, field: field, value: value, count: count, peak: peak, pad: pad }
  end
end

#faceted_rowsObject

Concepts the facet admits. Selecting Capability does not recompute link degree — the counts stay the bundle's real ones — it narrows which concepts the lists are drawn from.



507
508
509
510
511
512
513
514
# File 'lib/okf/tui/app.rb', line 507

def faceted_rows
  return [] unless model
  return model.rows if @graph_facet.nil?

  field = @graph_facet[:field]
  value = @graph_facet[:value]
  model.rows.select { |row| facet_admits?(row, field, value) }
end

#fallback(key) ⇒ Object



900
901
902
# File 'lib/okf/tui/app.rb', line 900

def fallback(key)
  handle_bundle_key(key) if @view == :bundles
end

#filter_found_nothing?Boolean

A filter that is looking for something and finding nothing — the moment where offering the wider search is worth the line it costs.

The registry counts too. A filter there looks through a dozen slugs and the groups beside them, which is a narrow thing to be typing: a term matching none of them is far more likely a question about what the bundles say than about what one is called. Same key, same escalation.

Both panes have to be empty, not just the focused one. A filter matching a group and no bundle has found something — the first cut of this read only the bundles pane, and swallowed the Enter that accepts such a filter.

Returns:

  • (Boolean)


1545
1546
1547
1548
1549
1550
1551
1552
1553
# File 'lib/okf/tui/app.rb', line 1545

def filter_found_nothing?
  return false if @filter.empty?

  case @view
  when :browse then selectable_indices.empty?
  when :bundles then visible_entries.empty? && visible_groups.empty?
  else false
  end
end

#filtered_rowsObject

Rows surviving the filter, in catalog order.



414
415
416
417
418
419
420
421
422
423
# File 'lib/okf/tui/app.rb', line 414

def filtered_rows
  return [] unless model
  return model.rows if @filter.empty?

  needle = @filter.downcase
  model.rows.select do |row|
    [ row[:id], row[:title], row[:type], Array(row[:tags]).join(" ") ]
      .join(" ").downcase.include?(needle)
  end
end

#find_matches(body) ⇒ Object

The lines of a rendered body carrying the term. Matched on the visible text, so a word split by colour still counts.



1234
1235
1236
1237
1238
1239
# File 'lib/okf/tui/app.rb', line 1234

def find_matches(body)
  return [] if @find.empty?

  needle = @find.downcase
  body.each_index.select { |index| body[index].to_s.gsub(Ui::ANSI, "").downcase.include?(needle) }
end

#find_statusObject



1275
1276
1277
1278
1279
1280
# File 'lib/okf/tui/app.rb', line 1275

def find_status
  return nil if @find.empty? || !findable?
  return "no line matches “#{@find}" if @find_total.to_i.zero?

  "match #{(@find_index % @find_total) + 1} of #{@find_total} for “#{@find}” · n/N steps"
end

#findable?Boolean

Returns:

  • (Boolean)


1607
1608
1609
# File 'lib/okf/tui/app.rb', line 1607

def findable?
  reading_body? || reading_page?
end

#focus(slug) ⇒ Object



1477
1478
1479
1480
# File 'lib/okf/tui/app.rb', line 1477

def focus(slug)
  index = visible_entries.index { |entry| entry.slug == slug }
  @cursor = index if index
end

#focus_fieldObject



810
811
812
# File 'lib/okf/tui/app.rb', line 810

def focus_field
  @search_focus = :field
end

#focus_group(slug) ⇒ Object

Keep the cursor on the group that was just edited, rather than on whatever ends up at that index after the reload — the same reason #set_default follows its bundle instead of its position.



1065
1066
1067
1068
1069
# File 'lib/okf/tui/app.rb', line 1065

def focus_group(slug)
  index = visible_groups.index { |group| group.slug == slug }
  @group_cursor = index if index
  @message
end

── following a link ─────────────────────────────────────────────────────

The links of the document in the detail pane, in reading order. Both kinds of document have them: a concept body, and a reserved file — and it is the reserved ones that matter most, since an index.md is a list of links by design (§6) and the log is a list of what changed where.



1095
1096
1097
1098
1099
1100
1101
1102
1103
# File 'lib/okf/tui/app.rb', line 1095

def follow_links
  return [] unless model

  entry = selected_browse_entry
  return [] if entry.nil?

  path = entry[:kind] == :reserved ? entry[:path] : entry[:row][:path]
  model.links_for(path.to_s)
end

#follow_selected(links) ⇒ Object



1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
# File 'lib/okf/tui/app.rb', line 1145

def follow_selected(links)
  link = links[@follow_cursor]
  return if link.nil?

  case link[:kind]
  when :concept
    @following = false
    open_concept(link[:id])
    @message = "opened #{link[:id]}"
  when :reserved
    @following = false
    open_reserved(link[:target])
    @message = "opened #{link[:target]}"
  else
    # Not an error: a link with nothing at the end of it is knowledge that
    # has not been written yet — okf's own position, and lint's job to
    # report. The picker says so and stays open, because a dead link means
    # the reader has not finished choosing, and closing would make them
    # press `f` again to find that out.
    @message = "#{link[:target]} is not in this bundle yet"
  end
end

#follow_window(count, height) ⇒ Object

The window over the picker, mirroring #window — its own offset, because the list pane's scroll belongs to a different list.



1170
1171
1172
1173
1174
1175
# File 'lib/okf/tui/app.rb', line 1170

def follow_window(count, height)
  height = [ height, 1 ].max
  @follow_scroll = [ @follow_scroll, @follow_cursor - height + 1 ].max
  @follow_scroll = [ @follow_scroll, @follow_cursor ].min
  @follow_scroll = [ [ @follow_scroll, count - height ].min, 0 ].max
end

#following?Boolean

Returns:

  • (Boolean)


1105
1106
1107
# File 'lib/okf/tui/app.rb', line 1105

def following?
  @following
end

#graph_concept_entriesObject

Right pane: the hubs and orphans among those concepts.



618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
# File 'lib/okf/tui/app.rb', line 618

def graph_concept_entries
  return [] unless model

  subset = Views.narrow_rows(faceted_rows, @filter)
  entries = []

  entries << { kind: :heading, label: "most linked-to", colour: :blue }
  entries.concat(degree_entries(subset, :links_in, :blue))
  entries << { kind: :blank }
  entries << { kind: :heading, label: "most linked-from", colour: :green }
  entries.concat(degree_entries(subset, :links_out, :green))
  entries << { kind: :blank }

  orphans = subset.map { |row| row[:id] } & model.orphan_ids
  entries << { kind: :heading, label: "orphans (no edges either way)", colour: orphans.empty? ? :green : :yellow }
  if orphans.empty?
    entries << { kind: :note, label: "every concept is reachable", colour: :green }
  else
    pad = orphans.first(6).map { |id| Ui.width(id.to_s) }.max
    orphans.first(6).each { |id| entries << { kind: :concept, id: id, count: 0, peak: 1, pad: pad, colour: :yellow } }
  end

  entries
end

#graph_entriesObject



653
654
655
# File 'lib/okf/tui/app.rb', line 653

def graph_entries
  @pane == :detail ? graph_concept_entries : graph_facet_entries
end

#graph_facet_entriesObject

Left pane: the type and tag tallies, counted within the facet in force.



543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
# File 'lib/okf/tui/app.rb', line 543

def graph_facet_entries
  return [] unless model

  subset = faceted_rows
  types = Views.narrow(model.types_of(subset), @filter)
  tags = Views.narrow(model.tags_of(subset), @filter).first(12)
  dirs = Views.narrow(model.dirs_of(subset), @filter)

  entries = [ { kind: :heading, label: "types", colour: :cyan } ]
  entries.concat(facet_rows(types, :type))
  entries << { kind: :blank }
  entries << { kind: :heading, label: "tags", colour: :magenta }
  entries.concat(facet_rows(tags, :tag))

  # Only where the bundle actually nests. The test is a directory with a
  # separator in it, not a count of directories: `conformant/` has two
  # (datasets, tables) and is still one level deep, where every dir facet says
  # exactly what a top-level rollup would — and a rollup is what okf deprecated
  # `--area` for being. okf takes the same view on its graph page, which offers
  # a bundle that does not nest no depth control at all.
  if dirs.any? { |dir, _count| dir.include?("/") }
    entries << { kind: :blank }
    entries << { kind: :heading, label: "dirs", colour: :yellow }
    entries.concat(facet_rows(dirs, :dir))
  end

  entries.concat(provenance_facets(subset))
  entries
end

#graph_selectableObject



657
658
659
# File 'lib/okf/tui/app.rb', line 657

def graph_selectable
  graph_entries.each_index.select { |index| %i[facet concept].include?(graph_entries[index][:kind]) }
end

#graph_selectedObject



661
662
663
# File 'lib/okf/tui/app.rb', line 661

def graph_selected
  graph_entries[@cursor]
end

#group_in_scope?(group) ⇒ Boolean

Returns:

  • (Boolean)


405
406
407
408
409
# File 'lib/okf/tui/app.rb', line 405

def group_in_scope?(group)
  return false if group.bundles.empty?

  workspace.scope.sort == group.bundles.sort
end

#group_key(key) ⇒ Object

The groups pane. n and x are the same keys they are for a bundle, on the slug this pane has selected — okf's rename and del span a group and cascade.



958
959
960
961
962
963
964
965
966
# File 'lib/okf/tui/app.rb', line 958

def group_key(key)
  case key
  when "n" then ask_rename
  when "x" then ask_remove
  when "c" then ask_group
  when "+" then @message = "+ adds from the bundles pane — Tab back, point at one, then +"
  when "-" then @message = "- removes a member: Tab into them, or point at the bundle and press -"
  end
end

#group_membersObject

── the member pane ──────────────────────────────────────────────────────

A group's members are listed in the detail pane, so Tab moves into them and - removes the one under the cursor. That is the same Tab the browse view has always used, and it replaced a - on the group row that acted on the intersection of the scope and the member list — a set with no row on screen, which a reader had to compute to predict what the key would do.

Adding stays on the group row, because the bundles to add are the ones marked in the list beside it. Removing is the direction with something to point at.



380
381
382
# File 'lib/okf/tui/app.rb', line 380

def group_members
  selected_group&.members || []
end

#group_window(count, height) ⇒ Object

The groups pane scrolls independently of the bundles pane, so it needs its own offset: sharing @scroll would make paging one list drag the other.



678
679
680
681
682
683
# File 'lib/okf/tui/app.rb', line 678

def group_window(count, height)
  height = [ height, 1 ].max
  @group_scroll = [ @group_scroll.to_i, @group_cursor - height + 1 ].max
  @group_scroll = [ @group_scroll, @group_cursor ].min
  @group_scroll = [ [ @group_scroll, count - height ].min, 0 ].max
end

#groups_pane?Boolean

Returns:

  • (Boolean)


328
329
330
# File 'lib/okf/tui/app.rb', line 328

def groups_pane?
  @view == :bundles && @pane == :groups
end

#handle(key) ⇒ Object

── input ────────────────────────────────────────────────────────────────



844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
# File 'lib/okf/tui/app.rb', line 844

def handle(key)
  return handle_prompt(key) if @prompt

  @message = nil
  # Whether the *previous* key armed the quit, and the disarm for this one.
  # Above the mode handlers on purpose: typing `q` into a filter or a query
  # has to cancel the arming too, or the chord leaks across a text field.
  armed = @quit_armed
  @quit_armed = false
  # The picker is the innermost mode, so it owns the keyboard outright —
  # including the digits, which is the whole reason it is a mode and not a
  # pane. `1` picks a link while it is open; everywhere else it is view one.
  return handle_follow(key) if @following
  return handle_find(key) if @finding
  return handle_filter(key) if @filtering
  # Only while the field has focus do printable keys mean query text. Once
  # Esc leaves it, the search view answers the same keys as everywhere else.
  return handle_query(key) if @view == :search && @search_focus == :field

  if KEY_VIEWS.key?(key)
    switch(KEY_VIEWS[key])
    return
  end

  case key
  when "q" then armed ? (@running = false) : arm_quit
  # Ctrl-C stays single. It is the escape hatch, and an escape hatch that
  # needs confirming is not one.
  when CTRL_C then @running = false
  when "?" then switch(:help)
  when "j", DOWN then move(1)
  when "k", UP then move(-1)
  when "g" then jump(:first)
  when "G" then jump(:last)
  when CTRL_D then move(10)
  when CTRL_U then move(-10)
  # A `when` matches whether or not its guard holds, so these must hand the
  # key back rather than swallow it — `n` is rename in the bundles view.
  when "n" then findable? ? step_match(1) : fallback(key)
  when "N" then findable? ? step_match(-1) : fallback(key)
  when "J" then @detail_scroll += 3
  when "K" then @detail_scroll = [ @detail_scroll - 3, 0 ].max
  when TAB then toggle_pane
  when "f" then start_follow
  when DELETE then back
  when "/" then start_typing
  when "i" then focus_field if @view == :search
  when "e" then cycle_search_mode if @view == :search
  when "s" then switch(:search)
  when "r" then reload
  when "\r", "\n" then handle_return
  when ESCAPE then handle_escape
  else fallback(key)
  end
end

#handle_bundle_key(key) ⇒ Object

The keys that only mean something over the registry list.



927
928
929
930
931
932
933
# File 'lib/okf/tui/app.rb', line 927

def handle_bundle_key(key)
  case @pane
  when :members then member_key(key)
  when :groups then group_key(key)
  else bundle_key(key)
  end
end

#handle_escapeObject



1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
# File 'lib/okf/tui/app.rb', line 1650

def handle_escape
  return if @view == :search

  # Esc ends the innermost thing first, and a submitted find is still one of
  # them: Enter only releases the field, it does not end the find — the term
  # stays lit and n/N still step through it. Falling through to the list's
  # Esc from there resets the cursor and throws the reader back to the first
  # file, which is the one thing a find must never cost.
  if findable? && !@find.empty?
    clear_find
    return
  end

  if @view == :graph && @graph_facet
    @graph_facet = nil
    @cursor = graph_selectable.first.to_i
    @message = "cleared the facet"
    return
  end

  # Each pane is a layer, and all of them are innermore than the filter.
  if @view == :bundles && @pane == :members
    @pane = :groups
    @member_cursor = 0
    return
  end

  if @view == :bundles && @pane == :groups
    @pane = :bundles
    return
  end

  clear_filter
end

#handle_filter(key) ⇒ Object



1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
# File 'lib/okf/tui/app.rb', line 1282

def handle_filter(key)
  case key
  # Enter accepts the filter — or, when it matched nothing, takes the term
  # to the search view instead. Widening is the natural next move from an
  # empty list, so it costs the same keystroke rather than a new one.
  when "\r", "\n" then return filter_found_nothing? ? escalate_to_search : (@filtering = false)
  when ESCAPE then (@filtering = false) || clear_filter
  when DELETE, "\b" then @filter.chop!
  when CTRL_C then @running = false
  else @filter << key if printable?(key)
  end
  reset_cursor
end

#handle_find(key) ⇒ Object

Find-in-document. Live rather than submitted: this only scans the lines already rendered, so it costs nothing to follow every keystroke, and watching the body jump as you type is the whole point.



1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
# File 'lib/okf/tui/app.rb', line 1074

def handle_find(key)
  case key
  when "\r", "\n" then @finding = false
  when ESCAPE then clear_find
  when DELETE, "\b" then (@find.chop! || @find) && jump_to_match(0)
  when CTRL_C then @running = false
  else
    return unless printable?(key)

    @find << key
    jump_to_match(0)
  end
end

#handle_follow(key) ⇒ Object



1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
# File 'lib/okf/tui/app.rb', line 1125

def handle_follow(key)
  links = follow_links

  case key
  when ESCAPE, "f" then @following = false
  when CTRL_C then @running = false
  when "\r", "\n" then follow_selected(links)
  when "j", DOWN then @follow_cursor = [ @follow_cursor + 1, links.length - 1 ].min
  when "k", UP then @follow_cursor = [ @follow_cursor - 1, 0 ].max
  when "g" then @follow_cursor = 0
  when "G" then @follow_cursor = [ links.length - 1, 0 ].max
  when /\A[1-9]\z/
    index = key.to_i - 1
    return if index >= links.length

    @follow_cursor = index
    follow_selected(links)
  end
end

#handle_prompt(key) ⇒ Object

A prompt takes either a line of text or a single confirming key.



1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
# File 'lib/okf/tui/app.rb', line 1321

def handle_prompt(key)
  unless @prompt.free_text?
    pending = @prompt
    @prompt = nil
    @message = key.downcase == "y" ? resolve(pending) : "cancelled"
    return
  end

  case key
  when ESCAPE, CTRL_C then (@prompt = nil) || (@message = "cancelled")
  when "\r", "\n"
    pending = @prompt
    @prompt = nil
    @message = resolve(pending)
  when DELETE, "\b" then @prompt.buffer.chop!
  else @prompt.buffer << key if printable?(key)
  end
end

#handle_query(key) ⇒ Object



1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
# File 'lib/okf/tui/app.rb', line 1296

def handle_query(key)
  case key
  when CTRL_C then return @running = false
  # Esc stops editing and nothing else. It used to clear the query and then,
  # on a second press, leave for the bundles view — but wanting to stop
  # typing is not wanting to leave: the results are right there, and the
  # arrows should reach them without the view changing underfoot.
  when ESCAPE then return leave_field
  when CTRL_U then return @query.clear # clear the line, as in a shell
  # Enter means "search" while the query has unsearched edits, and "open the
  # selected hit" once it does not. No mode flag decides this — whether the
  # typed query and the searched one agree is the whole state, and the
  # footer says which of the two the next Enter will do.
  when "\r", "\n" then return commit_search ? nil : open_hit
  when DELETE, "\b" then @query.chop!
  when DOWN then return move(1)
  when UP then return move(-1)
  when TAB then return switch(:bundles)
  else @query << key if printable?(key)
  end
  @cursor = 0
  @scroll = 0
end

#handle_returnObject

Enter, once the field no longer has focus: open whatever is selected.



917
918
919
920
921
922
923
924
# File 'lib/okf/tui/app.rb', line 917

def handle_return
  case @view
  when :search then open_hit
  when :browse then escalate_to_search if filter_found_nothing?
  when :graph then activate_graph_row
  when :bundles then filter_found_nothing? ? escalate_to_search : open_registry_row
  end
end

#invalidateObject



1525
1526
1527
1528
# File 'lib/okf/tui/app.rb', line 1525

def invalidate
  @list_entries_key = nil
  @search_hits_key = nil
end

#items_lengthObject



1726
1727
1728
1729
1730
1731
1732
# File 'lib/okf/tui/app.rb', line 1726

def items_length
  case @view
  when :bundles then visible_entries.length
  when :search then search_hits.length
  else 0
  end
end

#jump(where) ⇒ Object



1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
# File 'lib/okf/tui/app.rb', line 1803

def jump(where)
  if CONTENT_VIEWS.include?(@view)
    # `G` on a scrolling page: a deliberately large offset, clamped to the
    # real end by content_scroll_for once the view reports its length.
    set_scroll(content_scroll_key, where == :first ? 0 : 1_000_000)
    return
  end

  if @view == :graph
    indices = graph_selectable
    @cursor = (where == :first ? indices.first : indices.last).to_i
    return
  end

  if @view == :bundles
    length =
      case @pane
      when :members then group_members.length
      when :groups then visible_groups.length
      else visible_entries.length
      end
    last = [ length - 1, 0 ].max
    position = where == :first ? 0 : last

    case @pane
    when :members then @member_cursor = position
    when :groups then @group_cursor = position
    else @cursor = position
    end
    return
  end

  if @view == :search
    @cursor = where == :first ? 0 : [ items_length - 1, 0 ].max
    return
  end

  indices = selectable_indices
  return if indices.empty?

  @cursor = where == :first ? indices.first : indices.last
  @detail_scroll = 0
end

#jump_to_match(index) ⇒ Object



1220
1221
1222
1223
# File 'lib/okf/tui/app.rb', line 1220

def jump_to_match(index)
  @find_index = index
  @find_jump = true
end

#keeping_group_scoped(slug) ⇒ Object

A group that is the scope in force stays in force across an edit to it. Without this, + on a bundle left the ◉ on its row hollow and quietly emptied the one on the group row too: the group had grown, the scope had not, so set equality stopped holding and a search still covered the old set. The report was the visible half — "it does not select the bundle" — and this was the cause.

Only when it was in force beforehand. Re-scoping a group nobody had scoped would make an edit to it silently replace the reader's own selection.



1013
1014
1015
1016
1017
1018
1019
# File 'lib/okf/tui/app.rb', line 1013

def keeping_group_scoped(slug)
  group = workspace.group(slug)
  in_force = !group.nil? && group_in_scope?(group)
  message = yield
  workspace.scope_group(slug) if in_force && workspace.group(slug)
  message
end

#leave_fieldObject

Stop editing, stay where you are.

Unsearched edits are dropped, not run: Esc is how you abandon a query you started typing, and running it would be the opposite of stopping. Reverting to the query the results actually answer also means the field and the list below it always agree once the field is released — there is no lingering "these results are for something else" state to explain.



821
822
823
824
# File 'lib/okf/tui/app.rb', line 821

def leave_field
  @query = @searched.dup
  @search_focus = :results
end

#list_entriesObject

The browse list as a flat sequence of directory headings and concept rows. The headings are entries too, so windowing and the cursor share one index space — but only concept entries are selectable, which move enforces.



428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
# File 'lib/okf/tui/app.rb', line 428

def list_entries
  @list_entries_key ||= nil
  # `dup` matters: @filter is mutated in place as the user types, so keying
  # on the object itself would compare a key against its own later state and
  # the cache would never invalidate. The active bundle is part of the key
  # too, or switching bundles would keep showing the previous ones list.
  key = [ @filter.dup, workspace.active_slug ]
  return @list_entries if @list_entries_key == key

  entries = []
  browse_groups.each do |dir, group|
    entries << { kind: :dir, label: dir == "." ? "(root)" : dir, count: group[:concepts].length }
    group[:reserved].each { |path| entries << { kind: :reserved, path: path } }
    group[:concepts].each { |row| entries << { kind: :concept, row: row } }
  end

  @list_entries_key = key
  @list_entries = entries
end

#matches_filter?(text) ⇒ Boolean

Returns:

  • (Boolean)


471
472
473
# File 'lib/okf/tui/app.rb', line 471

def matches_filter?(text)
  @filter.empty? || text.to_s.downcase.include?(@filter.downcase)
end

#member_group(slug) ⇒ Object

Whether the scope in force is this group's set — set equality rather than overlap. A group's ◉ says "a search right now covers exactly these", so toggling one member off has to clear it; treating a superset as a match would leave two groups both claiming to be in force. The group under the groups cursor, when this bundle is one of its members — which is what decides whether the detail pane has a + to offer. Direct members only, deliberately: a bundle reached through a nested group is in the set a search covers but not in the list + would change.



400
401
402
403
# File 'lib/okf/tui/app.rb', line 400

def member_group(slug)
  group = selected_group
  group if group&.members&.include?(slug)
end

#member_key(key) ⇒ Object



968
969
970
971
972
# File 'lib/okf/tui/app.rb', line 968

def member_key(key)
  case key
  when "-" then ask_member_removal
  end
end

#member_pane?Boolean

Returns:

  • (Boolean)


332
333
334
# File 'lib/okf/tui/app.rb', line 332

def member_pane?
  @view == :bundles && @pane == :members && !selected_group.nil?
end

#message_row(width) ⇒ Object



182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/okf/tui/app.rb', line 182

def message_row(width)
  Ui.line(width) do |row|
    if @prompt
      row.add(" #{@prompt.label} ", :black, :on_yellow, :bold)
      row.add(" #{@prompt.buffer}", :bright_white, :bold)
      row.add("", :yellow, :bold) if @prompt.free_text?
    elsif @finding
      row.add(" find: ", :black, :on_yellow, :bold)
      row.add(" #{@find}", :bright_white, :bold)
      row.add("", :yellow, :bold)
    elsif @filtering
      row.add(" filter: ", :black, :on_yellow, :bold)
      row.add(" #{@filter}", :bright_white, :bold)
      row.add("", :yellow, :bold)
    elsif find_status
      row.add("  #{find_status}", @find_total.to_i.zero? ? :yellow : :bright_black)
    elsif @message
      # Flash, in the strict sense: #handle clears it on the very next key, so
      # this line is on screen only until the user does anything at all. That is
      # what makes a strong mark right here rather than shouting — it cannot
      # become permanent noise, and :bright_black had made the one line that
      # reports what just happened the quietest thing on the screen.
      #
      # Cyan, not the yellow the prompt, find and filter lines wear. This row has
      # two states — it is *asking* you something, or it is *telling* you
      # something — and one colour each keeps them apart without reading a word.
      row.add(" #{@message} ", :black, :on_cyan, :bold)
    end
  end
end

#modelObject

The active bundle. Nil only when nothing in the workspace could be read.



130
131
132
# File 'lib/okf/tui/app.rb', line 130

def model
  workspace.model
end

#move(delta) ⇒ Object

Move the selection, skipping the directory headings so the cursor only ever lands on something selectable.



1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
# File 'lib/okf/tui/app.rb', line 1755

def move(delta)
  # health, graph and help are one long page with nothing to select, so the
  # keys scroll them instead of moving a cursor.
  if CONTENT_VIEWS.include?(@view)
    key = content_scroll_key
    set_scroll(key, [ scroll_for(key) + delta, 0 ].max)
    return
  end

  if @view == :graph
    indices = graph_selectable
    return if indices.empty?

    position = indices.index(@cursor) || 0
    @cursor = indices[[ [ position + delta, 0 ].max, indices.length - 1 ].min]
    return
  end

  if @view == :bundles
    case @pane
    when :members then @member_cursor = step(@member_cursor, delta, group_members.length)
    when :groups then @group_cursor = step(@group_cursor, delta, visible_groups.length)
    else @cursor = step(@cursor, delta, visible_entries.length)
    end
    return
  end

  if @view == :browse && @pane == :detail
    @detail_scroll = [ @detail_scroll + delta, 0 ].max
    return
  end

  if @view == :search
    return if search_hits.empty?

    @cursor = [ [ @cursor + delta, 0 ].max, search_hits.length - 1 ].min
    return
  end

  indices = selectable_indices
  return if indices.empty?

  position = indices.index(@cursor) || 0
  position = [ [ position + delta, 0 ].max, indices.length - 1 ].min
  @cursor = indices[position]
  @detail_scroll = 0
end

#open_bundleObject

Enter on a bundle: make it the active one and go read it.



1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
# File 'lib/okf/tui/app.rb', line 1496

def open_bundle
  entry = selected_entry
  return if entry.nil?

  unless entry.loaded?
    @message = "@#{entry.slug} cannot be read: #{entry.error}"
    return
  end

  activate(entry.slug)
  switch(:browse)
  @message = "@#{entry.slug}#{model.concept_count} concepts"
end

#open_concept(id) ⇒ Object

Land in browse with id selected and its body showing. Shared by opening a search hit, following a concept out of the graph, and following a link out of a document.



1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
# File 'lib/okf/tui/app.rb', line 1865

def open_concept(id)
  push_trail
  @view = :browse
  @pane = :detail
  @filtering = false
  @filter = +""
  @detail_scroll = 0
  @list_entries_key = nil

  index = list_entries.index { |entry| entry[:kind] == :concept && entry[:row][:id] == id }
  @cursor = index || selectable_indices.first.to_i
  @scroll = 0
end

#open_hitObject

Enter on a search hit. Across bundles the hit may belong to one that is not active, so switching to it is part of opening — otherwise browse would select an id in the wrong bundle, or in none.



1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
# File 'lib/okf/tui/app.rb', line 1850

def open_hit
  hit = search_hits[@cursor]
  return if hit.nil?

  slug = hit[:slug] || workspace.active_slug
  switched = slug != workspace.active_slug
  activate(slug) if switched

  open_concept(hit[:id])
  @message = switched ? "opened @#{slug}#{hit[:id]}" : "opened #{hit[:id]}"
end

#open_registry_rowObject

Enter in the bundles view: which pane has focus decides what it means.



1485
1486
1487
1488
1489
1490
1491
1492
1493
# File 'lib/okf/tui/app.rb', line 1485

def open_registry_row
  return open_bundle unless detailing_group?

  group = selected_group
  return if group.nil?

  @message = workspace.scope_group(group.slug)
  @search_hits_key = nil
end

#open_reserved(path) ⇒ Object

The reserved-file twin of open_concept. An index.md is a bundle's way in (§6), so a link that points at one has to land the same way a concept does.



1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
# File 'lib/okf/tui/app.rb', line 1881

def open_reserved(path)
  push_trail
  @view = :browse
  @pane = :detail
  @filtering = false
  @filter = +""
  @detail_scroll = 0
  @list_entries_key = nil

  index = list_entries.index { |entry| entry[:kind] == :reserved && entry[:path] == path }
  @cursor = index || selectable_indices.first.to_i
  @scroll = 0
end

#paintObject

── frame ────────────────────────────────────────────────────────────────



152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/okf/tui/app.rb', line 152

def paint
  width = TTY::Screen.width
  height = TTY::Screen.height

  header = Views.header(self, width)
  footer = [ Views.footer(self, width) ]
  body_height = [ height - header.length - footer.length - 1, 3 ].max

  # browse, health and graph are all about *a* bundle. With none readable
  # there is nothing for them to render, so the list stands in — it is the
  # one view that can still show what went wrong.
  view = model.nil? && SINGLE_BUNDLE_VIEWS.include?(@view) ? :bundles : @view

  body =
    case view
    when :bundles then Views.bundles(self, width, body_height)
    when :search then Views.search(self, width, body_height)
    when :health then Views.health(self, width, body_height)
    when :graph  then Views.graph(self, width, body_height)
    when :help   then Views.help(self, width, body_height)
    else Views.browse(self, width, body_height)
    end

  frame = header + Ui.fit_block(body, width: width, height: body_height) + [ message_row(width) ] + footer

  @output.print @cursor_control.move_to(0, 0)
  @output.print frame.first(height).join("\n")
  @output.flush
end

#printable?(key) ⇒ Boolean

Returns:

  • (Boolean)


1530
1531
1532
# File 'lib/okf/tui/app.rb', line 1530

def printable?(key)
  key.length == 1 && key.ord >= 32 && key.ord < 127
end

#provenance_facets(subset) ⇒ Object

§5's two facets, offered only where the bundle has something to say — the same rule the dir facet follows, and the same rule okf's own graph page applies to these two.

Status needs one declared value, because a lone stable row is what an undeclared status already means; the counts then use the effective value, so the group reads stable 22 · deprecated 1 rather than hiding the majority the one deprecated concept is measured against. Trust needs one tier okf is willing to claim, or every v0.1 bundle would offer a facet whose only row said "unverified" about a family it never adopted — and both the count and the narrowing read that same predicate, so the row and what it selects agree.



584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
# File 'lib/okf/tui/app.rb', line 584

def provenance_facets(subset)
  entries = []

  if subset.any? { |row| !row[:status].to_s.empty? }
    statuses = Views.narrow(model.statuses_of(subset), @filter)
    unless statuses.empty?
      entries << { kind: :blank }
      entries << { kind: :heading, label: "status", colour: :yellow }
      entries.concat(facet_rows(statuses, :status))
    end
  end

  tiers = Views.narrow(model.tiers_of(subset), @filter)
  unless tiers.empty?
    entries << { kind: :blank }
    entries << { kind: :heading, label: "trust", colour: :green }
    entries.concat(facet_rows(tiers, :trust))
  end

  entries
end

#push_trailObject



1189
1190
1191
1192
1193
# File 'lib/okf/tui/app.rb', line 1189

def push_trail
  @trail << { slug: workspace.active_slug, view: @view, pane: @pane, cursor: @cursor,
              scroll: @scroll, detail_scroll: @detail_scroll, filter: @filter.dup }
  @trail.shift while @trail.length > MAX_TRAIL
end

#reading_body?Boolean

Is the cursor in a rendered document rather than a list? Two kinds, because they scroll on different offsets: a concept body in the browse detail pane, and the one-long-page views. Both are things to read, so both take a find.

Returns:

  • (Boolean)


1599
1600
1601
# File 'lib/okf/tui/app.rb', line 1599

def reading_body?
  @view == :browse && @pane == :detail
end

#reading_page?Boolean

Returns:

  • (Boolean)


1603
1604
1605
# File 'lib/okf/tui/app.rb', line 1603

def reading_page?
  CONTENT_VIEWS.include?(@view)
end

#reloadObject



1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
# File 'lib/okf/tui/app.rb', line 1895

def reload
  workspace.reload
  @body_cache = {}
  # The trail holds ids and offsets from before the reload; a bundle that
  # changed on disk may no longer have them.
  @trail = []
  invalidate
  reset_cursor
  @message = "reloaded #{workspace.entries.length} bundles"
rescue StandardError => e
  @message = "reload failed: #{e.message}"
end

#remember_page(rows) ⇒ Object

Same jump, against the offset the one-long-page views scroll on. The rows the current page last rendered. Only a scrolling page keeps this, and only so a check can assert against the same list on screen.



1260
1261
1262
# File 'lib/okf/tui/app.rb', line 1260

def remember_page(rows)
  @last_page_rows = rows
end

#render_markdown(cache_key, source, width) ⇒ Object



737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
# File 'lib/okf/tui/app.rb', line 737

def render_markdown(cache_key, source, width)
  @body_cache[cache_key] ||= begin
    if source.to_s.strip.empty?
      [ Ui.pastel.decorate("(empty)", :bright_black) ]
    else
      limit = [ width, 20 ].max
      # Say the colour decision out loud rather than letting tty-markdown
      # sniff the terminal: it is the coloured path that trips the wrapping
      # bug PARSE_WIDTH avoids, and a check that cannot turn colour on
      # cannot see that bug at all.
      mode = Ui.pastel.enabled? ? :always : :never
      Ui.reflow(TTY::Markdown.parse(source, width: PARSE_WIDTH, color: mode).lines, limit)
    end
  rescue StandardError => e
    # Name the failure. "IndexError" alone sent this one looking in the
    # wrong place for a while.
    [ Ui.pastel.decorate("(could not render markdown: #{e.class}: #{e.message})", :red) ]
  end
end

#rendered_body(row, width) ⇒ Object

Concept bodies are rendered by tty-markdown once and cached — re-rendering on every keypress is what would make scrolling feel heavy. Keyed by bundle too, since ids are only unique within one.



729
730
731
# File 'lib/okf/tui/app.rb', line 729

def rendered_body(row, width)
  render_markdown([ workspace.active_slug, row[:id], width ], model.body_for(row), width)
end

#rendered_reserved(path, width) ⇒ Object



733
734
735
# File 'lib/okf/tui/app.rb', line 733

def rendered_reserved(path, width)
  render_markdown([ workspace.active_slug, :reserved, path, width ], model.reserved_text(path), width)
end

#reset_cursorObject



1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
# File 'lib/okf/tui/app.rb', line 1705

def reset_cursor
  @pane = :bundles if @view == :bundles
  @group_cursor = 0
  @member_cursor = 0
  @cursor = 0
  @scroll = 0
  @detail_scroll = 0
  @content_scroll = 0
  @health_scroll = 0
  @pane = :list if @view == :health
  @cursor = selectable_indices.first.to_i if @view == :browse
  @cursor = active_index if @view == :bundles
  @cursor = graph_selectable.first.to_i if @view == :graph
end

#resolve(pending) ⇒ Object

Carry out an answered prompt. Every registry write goes through here.



1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
# File 'lib/okf/tui/app.rb', line 1365

def resolve(pending)
  message =
    case pending.kind
    when :add then resolve_add(pending)
    when :rename then resolve_rename(pending)
    when :remove then workspace.remove(pending.subject)
    when :group then workspace.create_group(pending.buffer, workspace.scope)
    when :ungroup then resolve_ungroup(pending)
    end

  invalidate
  clamp_cursor
  message
end

#resolve_add(pending) ⇒ Object

A newly registered bundle is one the user just expressed interest in, so it joins the scope and the cursor moves to it.



1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
# File 'lib/okf/tui/app.rb', line 1399

def resolve_add(pending)
  before = workspace.entries.map(&:slug)
  message = workspace.add(pending.buffer)

  added = workspace.entries.find { |entry| !before.include?(entry.slug) }
  if added
    # A reload keeps the scope that was there before, which cannot mention a
    # bundle that did not exist yet — so put the new one in explicitly.
    workspace.toggle_scope(added.slug) unless workspace.scoped?(added.slug)
    focus(added.slug)
  end
  message
end

#resolve_rename(pending) ⇒ Object

Scope membership survives a rename. It is keyed by slug, but what the user put in scope is the bundle, and its directory is what stays the same across a rename — so reconcile on the directory, or the bundle silently drops out of search the moment it is renamed.



1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
# File 'lib/okf/tui/app.rb', line 1417

def resolve_rename(pending)
  dir = workspace.entry(pending.subject)&.dir
  message = workspace.rename(pending.subject, pending.buffer)

  renamed = workspace.entries.find { |entry| entry.dir == dir }
  if renamed
    workspace.toggle_scope(renamed.slug) unless workspace.scoped?(renamed.slug)
    focus(renamed.slug)
  end
  message
end

#resolve_ungroup(pending) ⇒ Object

Both halves come from the prompt rather than from the cursor: a write that re-read the pane would act on whatever is selected now, not on what the question named.



1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
# File 'lib/okf/tui/app.rb', line 1383

def resolve_ungroup(pending)
  slug, member = pending.subject.split(MEMBER_REF, 2)
  message = keeping_group_scoped(slug) { workspace.remove_from_group(slug, [ member ]) }
  @member_cursor = 0
  # Nothing left to point at, so step back out rather than hold focus in a pane
  # describing a group that is gone. #clamp_cursor catches the same case for a
  # group emptied any other way; this is the one that knows *which* group.
  #
  # Only from the members, though: the same question can now be asked from the
  # bundles pane, and answering it is no reason to move that reader elsewhere.
  @pane = :groups if @pane == :members && workspace.group(slug).nil?
  message
end

#runObject



134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/okf/tui/app.rb', line 134

def run
  @output.print @cursor_control.hide + @cursor_control.clear_screen
  paint
  loop do
    key = @reader.read_keypress(nonblock: false)
    break unless key

    handle(key)
    break unless @running

    paint
  end
ensure
  @output.print @cursor_control.show + @cursor_control.clear_screen + @cursor_control.move_to(0, 0)
end

#running?Boolean

Returns:

  • (Boolean)


904
905
906
# File 'lib/okf/tui/app.rb', line 904

def running?
  @running
end

#scoped?(slug) ⇒ Boolean

Returns:

  • (Boolean)


388
389
390
# File 'lib/okf/tui/app.rb', line 388

def scoped?(slug)
  workspace.scoped?(slug)
end

#scroll_for(key) ⇒ Object

Health is two pages side by side and each keeps its own place: the findings are unbounded and the summary is not, so one shared offset would drag the short pane to its end and hold it there while the long one scrolled.



701
702
703
# File 'lib/okf/tui/app.rb', line 701

def scroll_for(key)
  key == :health ? @health_scroll : @content_scroll
end

#search_hitsObject

Cross-bundle: the hits come from every scoped bundle, ranked together.

Results belong to @searched, not to @query, and only Enter moves one to the other. Searching per keystroke meant building a fresh index over every scoped bundle for each letter, and the view churning through answers to half-typed words; a timed pause fixed the cost but still left the moment of searching up to a guess about typing rhythm. Submitting is explicit instead, so the field edits freely and the results below hold still.



765
766
767
768
769
770
771
772
773
# File 'lib/okf/tui/app.rb', line 765

def search_hits
  return [] if @searched.empty? # nothing submitted yet — nothing to ask

  key = [ @searched.dup, workspace.scope, @search_mode ]
  return @search_hits if @search_hits_key == key

  @search_hits_key = key
  @search_hits = workspace.search(@searched, mode: @search_mode)
end

#search_mode_entryObject



784
785
786
# File 'lib/okf/tui/app.rb', line 784

def search_mode_entry
  SEARCH_MODES.find { |mode| mode[:id] == @search_mode } || SEARCH_MODES.first
end

#search_mode_labelObject



788
789
790
# File 'lib/okf/tui/app.rb', line 788

def search_mode_label
  search_mode_entry[:label]
end

#search_mode_noteObject



792
793
794
# File 'lib/okf/tui/app.rb', line 792

def search_mode_note
  search_mode_entry[:note]
end

#search_pending?Boolean

Is there typing the results have not caught up with yet?

Returns:

  • (Boolean)


827
828
829
# File 'lib/okf/tui/app.rb', line 827

def search_pending?
  @view == :search && @searched != @query
end

#selectable_indicesObject



475
476
477
# File 'lib/okf/tui/app.rb', line 475

def selectable_indices
  list_entries.each_index.select { |index| %i[concept reserved].include?(list_entries[index][:kind]) }
end

#selected_browse_entryObject



486
487
488
489
490
491
492
# File 'lib/okf/tui/app.rb', line 486

def selected_browse_entry
  entry = list_entries[@cursor]
  return entry if entry && %i[concept reserved].include?(entry[:kind])

  first = selectable_indices.first
  first ? list_entries[first] : nil
end

#selected_entryObject

Each pane indexes its own filtered list, so every action lands on the row the user is looking at.



362
363
364
# File 'lib/okf/tui/app.rb', line 362

def selected_entry
  visible_entries[@cursor]
end

#selected_groupObject



366
367
368
# File 'lib/okf/tui/app.rb', line 366

def selected_group
  visible_groups[@group_cursor]
end

#selected_memberObject



384
385
386
# File 'lib/okf/tui/app.rb', line 384

def selected_member
  group_members[@member_cursor]
end

#selected_rowObject

The row under the cursor, or the first one, when it is a concept. A reserved file has no catalog row — the detail pane asks for it separately.



481
482
483
484
# File 'lib/okf/tui/app.rb', line 481

def selected_row
  entry = selected_browse_entry
  entry && entry[:kind] == :concept ? entry[:row] : nil
end

#selected_slugObject

The slug the focused pane has selected — a group's when the groups or member pane holds focus, a bundle's otherwise.



1454
1455
1456
# File 'lib/okf/tui/app.rb', line 1454

def selected_slug
  detailing_group? ? selected_group&.slug : selected_entry&.slug
end

#set_defaultObject



1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
# File 'lib/okf/tui/app.rb', line 1458

def set_default
  entry = selected_entry
  return if entry.nil?

  @message = workspace.make_default(entry.slug)
  # Making a bundle the default moves it to the front, so the list reorders
  # under the cursor. Follow the bundle rather than the position, or the
  # selection silently lands on a different one.
  focus(entry.slug)
end

#set_scroll(key, value) ⇒ Object



705
706
707
# File 'lib/okf/tui/app.rb', line 705

def set_scroll(key, value)
  key == :health ? (@health_scroll = value) : (@content_scroll = value)
end

#start_filterObject



1617
1618
1619
1620
1621
1622
# File 'lib/okf/tui/app.rb', line 1617

def start_filter
  return unless FILTERABLE_VIEWS.include?(@view)

  @filtering = true
  @filter = +""
end

#start_findObject



1611
1612
1613
1614
1615
# File 'lib/okf/tui/app.rb', line 1611

def start_find
  @finding = true
  @find = +""
  @find_index = 0
end

#start_followObject



1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
# File 'lib/okf/tui/app.rb', line 1109

def start_follow
  return unless @view == :browse

  if follow_links.empty?
    @message = "no links in this document"
    return
  end

  @following = true
  @follow_cursor = 0
  @follow_scroll = 0
  # The picker draws where the body does, so the pane it belongs to has to
  # be the one showing.
  @pane = :detail
end

#start_typingObject

One key, three fields — whichever the focus makes it. The list pane and the body pane are different things to look through, so / in each looks through the one under the cursor rather than always the list.



1589
1590
1591
1592
1593
1594
# File 'lib/okf/tui/app.rb', line 1589

def start_typing
  return focus_field if @view == :search
  return start_find if findable?

  start_filter
end

#status_hintsObject



213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
# File 'lib/okf/tui/app.rb', line 213

def status_hints
  base =
    case @view
    when :bundles
      if filter_found_nothing?
        [ [ "", "search all bundles" ], [ "Esc", "clear filter" ] ]
      elsif @pane == :members
        [ [ "↑↓", "pick a member" ], [ "-", "remove it" ], [ "Esc", "back to the groups" ] ]
      elsif @pane == :groups
        [ [ "", "scope to it" ], [ "↑↓", "pick a group" ], [ "Tab", "its members" ],
          [ "n", "rename" ], [ "x", "delete" ], [ "Esc", "back to the bundles" ] ]
      else
        # The scope comes before the config keys, and `A`/`N` beside `space`. It
        # is one of the two axes this view is *about*, and the footer truncates:
        # at 80 columns everything from `d` rightward is already off screen, so
        # what the prefix names is the whole of what a narrow terminal teaches.
        # Reordered after "it is not clear how to control the scope" — which was
        # true, since neither `A` nor `N` appeared here at all.
        #
        #
        # `+` sits inside that prefix, and `a add` gave up the slot for it:
        # of the two keys a reader would have called "add", only one of them adds
        # to a group, and registering a directory is a once-per-bundle act that
        # the empty registry and view 6 both still teach.
        #
        # It does not name the group. The groups pane is right below with its own
        # cursor on the row, and spelling the slug out here read as a claim about
        # something the footer could not point at.
        [ [ "", "open" ], [ "space", "scope" ], [ "A/N", "all/none" ] ] +
          (selected_group ? [ [ "+", "join group" ] ] : []) +
          [ [ "/", "filter" ], [ "d", "default" ], [ "n", "rename" ], [ "x", "remove" ],
            [ "c", "group" ] ]
      end
    when :browse
      if @following
        [ [ "↑↓", "pick" ], [ "1-9", "jump straight to one" ], [ "", "follow" ],
          [ "Esc", "back to the body" ] ]
      elsif filter_found_nothing?
        [ [ "", "search all bundles" ], [ "Esc", "clear filter" ] ]
      elsif reading_body?
        # In the body, `/` looks through the document rather than the list.
        [ [ "↑↓ J/K", "scroll" ], [ "Tab", "pane" ], [ "/", "find in page" ], [ "f", "links" ] ] +
          (@find.empty? ? [] : [ [ "n/N", "next/prev match" ] ])
      else
        [ [ "↑↓", "move" ], [ "Tab", "pane" ], [ "J/K", "scroll body" ], [ "/", "filter" ], [ "f", "links" ] ]
      end
    when :search
      # Both Enter and the whole key set change with focus, so the hints do too.
      if editing_query?
        # `e` deliberately does not cycle the mode here — it is a letter, and
        # every letter belongs to the query while the field has focus. Esc
        # first, which the hint says.
        [ [ "type", "query" ], [ "", search_pending? ? "search" : "open" ],
          [ "Esc", "stop editing (then e changes the mode)" ] ]
      elsif @searched.empty?
        # Nothing searched yet, so there is nothing to navigate — the only
        # moves that matter are starting one, and choosing how it is asked.
        [ [ "/", "search" ], [ "e", search_mode_label ] ]
      else
        [ [ "↑↓", "results" ], [ "", "open" ], [ "/", "edit query" ], [ "e", search_mode_label ] ]
      end
    when :graph
      act = @pane == :detail ? "open concept" : "narrow by facet"
      hints = [ [ "↑↓", "move" ], [ "Tab", "pane" ], [ "", act ], [ "/", "filter" ] ]
      hints + (@graph_facet ? [ [ "Esc", "clear facet" ] ] : [])
    when :health
      # Two panes, each scrolling on its own, so Tab names the one the keys move.
      # A narrow terminal shows them one at a time and Tab swaps which — same key,
      # same meaning, a layout that stops pretending both fit.
      [ [ "↑↓", "scroll" ], [ "Tab", @pane == :detail ? "the findings" : "the standing" ],
        [ "/", "find" ] ] +
      (@find.empty? ? [] : [ [ "n/N", "next/prev match" ] ])
    else
      [ [ "↑↓", "scroll" ], [ "/", "find" ] ] +
      (@find.empty? ? [] : [ [ "n/N", "next/prev match" ] ])
    end
  # Only offered once there is somewhere to go back to, so the row does not
  # advertise a key that would do nothing.
  base += [ [ "", "back" ] ] unless @trail.empty?
  base + [ %w[1-6 views], %w[r reload], %w[qq quit] ]
end

#step(position, delta, length) ⇒ Object



1749
1750
1751
# File 'lib/okf/tui/app.rb', line 1749

def step(position, delta, length)
  [ [ position + delta, 0 ].max, [ length - 1, 0 ].max ].min
end

#step_match(delta) ⇒ Object



1225
1226
1227
1228
1229
1230
# File 'lib/okf/tui/app.rb', line 1225

def step_match(delta)
  return unless findable? && !@find.empty?

  @find_index += delta
  @find_jump = true
end

#switch(target) ⇒ Object



1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
# File 'lib/okf/tui/app.rb', line 1690

def switch(target)
  # Entering search does *not* grab the field. A view that swallows every
  # printable key the moment you arrive turns "3" into query text and takes
  # the number keys away from navigation. Typing starts on `/` here, the
  # same key that starts the filter in browse.
  @search_focus = :results if target == :search
  @view = target
  # The filter described the view being left, not the one being entered.
  @filtering = false
  @filter = +""
  @graph_facet = nil
  reset_cursor
  @pane = target == :bundles ? :bundles : :list if %i[browse bundles].include?(target)
end

#toggle_paneObject

Esc undoes the narrowing in force, innermost first: a graph facet before the filter, so one key backs out of both without leaving the view.



1626
1627
1628
1629
1630
1631
1632
1633
# File 'lib/okf/tui/app.rb', line 1626

def toggle_pane
  return cycle_bundles_pane if @view == :bundles

  @pane = @pane == :list ? :detail : :list
  # The two graph panes hold different lists, so a cursor carried across
  # would point at an arbitrary row — or at a heading, where it vanishes.
  @cursor = graph_selectable.first.to_i if @view == :graph
end

#toggle_scopeObject



1469
1470
1471
1472
1473
1474
1475
# File 'lib/okf/tui/app.rb', line 1469

def toggle_scope
  entry = selected_entry
  return if entry.nil?

  workspace.toggle_scope(entry.slug)
  @search_hits_key = nil
end

#trail_depthObject



1185
1186
1187
# File 'lib/okf/tui/app.rb', line 1185

def trail_depth
  @trail.length
end

#visible_entriesObject

The registry rows surviving the filter, matched on slug or path — the two things a row shows that a user would type.



299
300
301
302
303
304
305
306
# File 'lib/okf/tui/app.rb', line 299

def visible_entries
  return workspace.entries if @filter.empty?

  needle = @filter.downcase
  workspace.entries.select do |entry|
    "#{entry.slug} #{entry.dir}".downcase.include?(needle)
  end
end

#visible_groupsObject

Groups surviving the filter, matched on the group's own name, on its members as written, and on the bundles it resolves to.

All three, because the resolved set is what the filter is usually asking about — "which named scope covers @nested?" — and for a nested group that bundle appears nowhere in the member list. Matching only the members would answer @docs and hide @everything, which covers @nested just as much.



349
350
351
352
353
354
355
356
357
358
# File 'lib/okf/tui/app.rb', line 349

def visible_groups
  groups = workspace.groups
  return groups if @filter.empty?

  needle = @filter.downcase
  groups.select do |group|
    haystack = [ group.slug, group.members.join(" "), group.bundles.join(" ") ].join(" ")
    haystack.downcase.include?(needle)
  end
end

#window(count, height) ⇒ Object

The first visible index, scrolled just enough to keep the cursor on screen.



668
669
670
671
672
673
674
# File 'lib/okf/tui/app.rb', line 668

def window(count, height)
  height = [ height, 1 ].max
  @scroll = [ @scroll, @cursor - height + 1 ].max
  @scroll = [ @scroll, @cursor ].min
  @scroll = [ [ @scroll, count - height ].min, 0 ].max
  @scroll
end