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.
- checksums.yaml +7 -0
- data/.okf/decisions/index.md +12 -0
- data/.okf/decisions/invents-no-analysis.md +53 -0
- data/.okf/decisions/no-version-ceilings.md +69 -0
- data/.okf/decisions/okf-capability-drift.md +120 -0
- data/.okf/decisions/one-door-the-plugin-seam.md +131 -0
- data/.okf/decisions/registry-write-boundary.md +175 -0
- data/.okf/decisions/ruby-floor.md +59 -0
- data/.okf/decisions/search-facade-coupling.md +146 -0
- data/.okf/decisions/undeclared-width-dependency.md +73 -0
- data/.okf/index.md +28 -0
- data/.okf/interaction/cross-bundle-scope.md +61 -0
- data/.okf/interaction/deferred-search.md +49 -0
- data/.okf/interaction/esc-peels-one-layer.md +70 -0
- data/.okf/interaction/filter-escalates-to-search.md +57 -0
- data/.okf/interaction/following-links.md +82 -0
- data/.okf/interaction/index.md +12 -0
- data/.okf/interaction/key-routing.md +84 -0
- data/.okf/interaction/which-registry.md +85 -0
- data/.okf/log.md +38 -0
- data/.okf/rendering/ansi-aware-width.md +74 -0
- data/.okf/rendering/index.md +8 -0
- data/.okf/rendering/markdown-rendering-trap.md +63 -0
- data/.okf/rendering/status-vocabulary.md +45 -0
- data/.okf/rendering/whole-frame-painting.md +52 -0
- data/.okf/testing/ci-matrix.md +80 -0
- data/.okf/testing/headless-frames.md +74 -0
- data/.okf/testing/index.md +8 -0
- data/.okf/testing/pty-test.md +73 -0
- data/CHANGELOG.md +239 -0
- data/LICENSE.txt +201 -0
- data/NOTICE +10 -0
- data/README.md +194 -0
- data/lib/okf/plugin.rb +63 -0
- data/lib/okf/tui/app.rb +1908 -0
- data/lib/okf/tui/cli.rb +154 -0
- data/lib/okf/tui/model.rb +410 -0
- data/lib/okf/tui/refs.rb +63 -0
- data/lib/okf/tui/ui.rb +308 -0
- data/lib/okf/tui/version.rb +7 -0
- data/lib/okf/tui/views.rb +1648 -0
- data/lib/okf/tui/workspace.rb +527 -0
- data/lib/okf/tui.rb +76 -0
- 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
|