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
data/lib/okf/tui/ui.rb ADDED
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "tty-box"
5
+
6
+ begin
7
+ require "unicode/display_width"
8
+ rescue LoadError # rubocop:disable Lint/SuppressedException
9
+ end
10
+
11
+ module OKF::TUI
12
+ # Layout primitives. Everything the views draw goes through here, because the
13
+ # one thing that breaks a composed terminal UI is a line whose *display* width
14
+ # disagrees with its String#length — which is exactly what happens the moment
15
+ # colour is involved. So width is always measured on the ANSI-stripped text,
16
+ # and colour is only ever applied to a segment already clipped to fit.
17
+ module Ui
18
+ ANSI = /\e\[[0-9;]*[a-zA-Z]/.freeze
19
+
20
+ # Pastel disables colour when stdout is not a terminal, which is right for a
21
+ # pipe but means a captured frame exercises none of the ANSI paths the
22
+ # layout depends on. FORCE_COLOR=1 turns it back on so those can be checked.
23
+ PASTEL = Pastel.new(enabled: ENV["FORCE_COLOR"] ? true : nil)
24
+
25
+ module_function
26
+
27
+ def pastel
28
+ PASTEL
29
+ end
30
+
31
+ # Display columns a string occupies, ignoring colour escapes.
32
+ def width(string)
33
+ plain = string.to_s.gsub(ANSI, "")
34
+
35
+ if defined?(Unicode::DisplayWidth)
36
+ Unicode::DisplayWidth.of(plain)
37
+ else
38
+ plain.length
39
+ end
40
+ end
41
+
42
+ # Clip a *plain* string to at most `limit` columns, ellipsizing when it does
43
+ # not fit. Never called on coloured text — see Line.
44
+ def clip(string, limit)
45
+ string = string.to_s.tr("\t", " ").delete("\n")
46
+ return "" if limit <= 0
47
+ return string if width(string) <= limit
48
+ return "…" if limit == 1
49
+
50
+ out = +""
51
+ string.each_char do |char|
52
+ break if width(out) + width(char) > limit - 1
53
+
54
+ out << char
55
+ end
56
+ "#{out}…"
57
+ end
58
+
59
+ # Clip a string that already carries colour. Escape sequences cost no
60
+ # columns and are copied through, so the result keeps its styling and is cut
61
+ # only on visible characters — a plain clip would slice an escape in half and
62
+ # leave the rest of the screen wearing whatever colour it opened.
63
+ def clip_ansi(string, limit)
64
+ return clip(string, limit) unless string.match?(ANSI)
65
+
66
+ out = +""
67
+ spent = 0
68
+ scanner = string.to_s.scan(/\e\[[0-9;]*[a-zA-Z]|./m)
69
+
70
+ scanner.each do |token|
71
+ if token.start_with?("\e")
72
+ out << token
73
+ next
74
+ end
75
+
76
+ break if spent + width(token) > limit
77
+
78
+ out << token
79
+ spent += width(token)
80
+ end
81
+
82
+ reset_if_styled(out)
83
+ end
84
+
85
+ # Box-drawing glyphs, i.e. a rendered table or code fence. Re-flowing one of
86
+ # those destroys the alignment that carries its meaning, so such a row is
87
+ # clipped instead of wrapped.
88
+ TABULAR = /[┌┬┐├┼┤└┴┘─│┃━╭╮╰╯]/.freeze
89
+
90
+ # Word-wrap a line that may already carry colour, into rows of at most
91
+ # `limit` columns. tty-markdown keeps the source's own hard line breaks
92
+ # rather than reflowing to the width it is given, so bodies authored at 80
93
+ # columns have to be re-wrapped here to fit a narrower pane.
94
+ #
95
+ # Escapes cost no columns and ride along with the word they precede; the
96
+ # style open at a break is re-opened on the next row so a colour spanning a
97
+ # wrap does not stop halfway.
98
+ def wrap_ansi(string, limit)
99
+ line = string.to_s.chomp
100
+ return [ clip_ansi(line, limit) ] if limit <= 0 || line.match?(TABULAR)
101
+ return [ line ] if width(line) <= limit
102
+
103
+ indent = " " * [ line[/\A */].length, [ limit - 8, 0 ].max ].min
104
+ rows = []
105
+ current = +""
106
+ spent = 0
107
+ style = nil
108
+
109
+ words(line).each do |word|
110
+ visible = width(word[:text])
111
+ next if visible.zero? && word[:escapes].empty?
112
+
113
+ if spent.positive? && spent + 1 + visible > limit
114
+ rows << reset_if_styled(current)
115
+ current = +"#{indent}#{style}"
116
+ spent = width(indent)
117
+ elsif spent > width(indent)
118
+ current << " "
119
+ spent += 1
120
+ end
121
+
122
+ style = word[:escapes].last if word[:escapes].any? { |code| code != "\e[0m" }
123
+ current << word[:escapes].join << word[:text]
124
+ spent += visible
125
+ end
126
+
127
+ rows << reset_if_styled(current) unless current.strip.empty?
128
+ rows.empty? ? [ "" ] : rows
129
+ end
130
+
131
+ # Split a line into words, each carrying the escape sequences that preceded
132
+ # it so styling survives the re-flow.
133
+ def words(line)
134
+ out = []
135
+ pending = []
136
+ current = +""
137
+
138
+ line.scan(/\e\[[0-9;]*[a-zA-Z]|\s+|[^\s\e]+/) do |token|
139
+ if token.start_with?("\e")
140
+ current.empty? ? pending << token : (out << { escapes: pending, text: current }; pending = [ token ]; current = +"")
141
+ elsif token.strip.empty?
142
+ unless current.empty?
143
+ out << { escapes: pending, text: current }
144
+ pending = []
145
+ current = +""
146
+ end
147
+ else
148
+ current << token
149
+ end
150
+ end
151
+
152
+ out << { escapes: pending, text: current } unless current.empty?
153
+ out
154
+ end
155
+
156
+ # A line that opens a list item — a boundary reflow must not cross.
157
+ LIST_START = /\A\s*(?:[•▪◦*+-]|\d+[.)])\s/.freeze
158
+
159
+ # Re-flow rendered markdown to `limit` columns.
160
+ #
161
+ # Wrapping alone is not enough: the bodies are authored at ~80 columns and
162
+ # tty-markdown keeps those hard breaks, so wrapping each line in isolation
163
+ # leaves a short orphan after every one it splits. Consecutive prose lines
164
+ # are therefore joined back into a paragraph and wrapped as a unit. Blank
165
+ # lines, tables and list items end a paragraph, which is what keeps headings
166
+ # and structure from being swallowed into the prose beneath them.
167
+ def reflow(lines, limit)
168
+ out = []
169
+ paragraph = []
170
+
171
+ flush = lambda do
172
+ next if paragraph.empty?
173
+
174
+ indent = paragraph.first[/\A */]
175
+ out.concat(wrap_ansi(indent + paragraph.map(&:strip).join(" "), limit))
176
+ paragraph = []
177
+ end
178
+
179
+ lines.each do |line|
180
+ line = line.chomp
181
+ plain = line.gsub(ANSI, "")
182
+
183
+ if plain.strip.empty? || plain.match?(TABULAR) || plain.match?(LIST_START)
184
+ flush.call
185
+ out.concat(wrap_ansi(line, limit))
186
+ else
187
+ paragraph << line
188
+ end
189
+ end
190
+
191
+ flush.call
192
+ out
193
+ end
194
+
195
+ # Close a row that opened a colour, so the style cannot bleed into the pane
196
+ # beside it. A row carrying no escapes needs no reset — and withholding it
197
+ # there keeps uncoloured output byte-clean, which is what makes a captured
198
+ # frame worth diffing.
199
+ def reset_if_styled(row)
200
+ row.match?(ANSI) ? "#{row}\e[0m" : row
201
+ end
202
+
203
+ def blank_line(limit)
204
+ " " * [ limit, 0 ].max
205
+ end
206
+
207
+ # A single output row assembled segment by segment, each optionally styled.
208
+ # The builder tracks how many columns it has spent, so a segment that would
209
+ # overflow is clipped before it is coloured and the finished row always
210
+ # measures exactly `limit` columns.
211
+ class Line
212
+ def initialize(limit)
213
+ @limit = [ limit, 0 ].max
214
+ @spent = 0
215
+ @buffer = +""
216
+ end
217
+
218
+ # Append `text`, styled with zero or more Pastel colour names.
219
+ def add(text, *styles)
220
+ room = @limit - @spent
221
+ return self if room <= 0
222
+
223
+ piece = Ui.clip(text, room)
224
+ return self if piece.empty?
225
+
226
+ @spent += Ui.width(piece)
227
+ @buffer << (styles.empty? ? piece : Ui.pastel.decorate(piece, *styles))
228
+ self
229
+ end
230
+
231
+ # Append `count` spaces (no-op past the edge).
232
+ def space(count = 1)
233
+ add(" " * count)
234
+ end
235
+
236
+ # Pad the rest of the row out to `limit` columns.
237
+ def to_s
238
+ @buffer + (" " * (@limit - @spent))
239
+ end
240
+
241
+ def empty?
242
+ @spent.zero?
243
+ end
244
+ end
245
+
246
+ def line(limit)
247
+ row = Line.new(limit)
248
+ yield row if block_given?
249
+ row.to_s
250
+ end
251
+
252
+ # Force a list of rows to exactly `height` rows of exactly `width` columns —
253
+ # the invariant every pane must satisfy before it can be joined to another.
254
+ # Both directions matter: a short row leaves the pane beside it smeared
255
+ # across the gap, and a long one wraps onto the next terminal row and pushes
256
+ # the whole frame down.
257
+ def fit_block(rows, width:, height:)
258
+ rows = rows.first(height)
259
+ rows += [ blank_line(width) ] * (height - rows.length)
260
+
261
+ rows.map do |row|
262
+ spent = Ui.width(row)
263
+ if spent < width
264
+ row + (" " * (width - spent))
265
+ elsif spent > width
266
+ clip_ansi(row, width)
267
+ else
268
+ row
269
+ end
270
+ end
271
+ end
272
+
273
+ # Join panes side by side, row for row. Each pane must already be a
274
+ # rectangle (see fit_block), which is what makes this a plain zip.
275
+ def hjoin(*panes)
276
+ height = panes.map(&:length).max.to_i
277
+ Array.new(height) do |index|
278
+ panes.map { |pane| pane[index].to_s }.join
279
+ end
280
+ end
281
+
282
+ # A framed pane. TTY::Box draws the border; we hand it content that is
283
+ # already clipped to the inner width so its own padding never has to guess.
284
+ def box(rows, width:, height:, title: nil, active: false)
285
+ inner_width = width - 2
286
+ inner_height = height - 2
287
+ body = fit_block(rows, width: inner_width, height: inner_height)
288
+
289
+ border_fg = active ? :cyan : :bright_black
290
+ titles = title ? { top_left: title_label(title, active) } : {}
291
+
292
+ frame = TTY::Box.frame(
293
+ width: width,
294
+ height: height,
295
+ border: { type: active ? :thick : :light },
296
+ style: { border: { fg: border_fg } },
297
+ title: titles
298
+ ) { body.join("\n") }
299
+
300
+ frame.lines.map(&:chomp)
301
+ end
302
+
303
+ def title_label(title, active)
304
+ label = " #{title} "
305
+ active ? pastel.decorate(label, :black, :on_cyan, :bold) : pastel.decorate(label, :bright_white, :bold)
306
+ end
307
+ end
308
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ module TUI
5
+ VERSION = "1.0.0"
6
+ end
7
+ end