zaniah 0.6.2 → 0.6.4
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 +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +9 -0
- data/docs/adr/012-productivity-components.md +25 -0
- data/docs/components.md +19 -2
- data/docs/images.md +18 -0
- data/docs/layout.md +46 -0
- data/docs/native.md +9 -0
- data/docs/text.md +22 -0
- data/docs/tui.md +1 -1
- data/lib/zaniah/image.rb +22 -5
- data/lib/zaniah/input/keymap.rb +12 -0
- data/lib/zaniah/jpeg.rb +431 -0
- data/lib/zaniah/list/height_index.rb +3 -2
- data/lib/zaniah/platform/headless/window.rb +8 -0
- data/lib/zaniah/platform/linux/wayland_window.rb +14 -0
- data/lib/zaniah/platform/linux/window.rb +12 -0
- data/lib/zaniah/platform/mac/window.rb +15 -0
- data/lib/zaniah/platform/windows/window.rb +15 -0
- data/lib/zaniah/ui/charts.rb +330 -18
- data/lib/zaniah/ui/editors.rb +303 -13
- data/lib/zaniah/ui/grid.rb +492 -0
- data/lib/zaniah/ui/rich_text_surface.rb +302 -0
- data/lib/zaniah/ui/terminal_view.rb +22 -0
- data/lib/zaniah/ui.rb +2 -0
- data/lib/zaniah/version.rb +1 -1
- data/lib/zaniah.rb +1 -0
- data/sig/elements.rbs +2 -0
- data/sig/native.rbs +7 -0
- data/sig/platform.rbs +2 -0
- data/sig/ui.rbs +67 -2
- data/sig/zaniah.rbs +14 -0
- metadata +15 -4
data/lib/zaniah/ui/editors.rb
CHANGED
|
@@ -34,34 +34,324 @@ module Zaniah
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
class RichText < Component
|
|
37
|
-
|
|
37
|
+
Span = Data.define(:start, :finish, :style)
|
|
38
|
+
STYLE_KEYS = %i[bold italic size color font link].freeze
|
|
39
|
+
LIST_STYLES = %i[none bullet ordered].freeze
|
|
38
40
|
|
|
39
|
-
|
|
41
|
+
attr_reader :buffer, :selection, :paragraph_styles
|
|
42
|
+
|
|
43
|
+
def initialize(runs, selectable: true, editable: false)
|
|
40
44
|
super()
|
|
41
|
-
|
|
45
|
+
text, @spans = normalize_runs(runs)
|
|
46
|
+
@buffer = TextBuffer.new(text)
|
|
47
|
+
@paragraph_styles = Array.new(text.count("\n") + 1) { {} }
|
|
48
|
+
@selection, @selectable, @editable = TextSelection.new(0), !!selectable, !!editable
|
|
42
49
|
end
|
|
43
50
|
|
|
44
|
-
def
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
def text = @buffer.to_s
|
|
52
|
+
|
|
53
|
+
def runs
|
|
54
|
+
slices = []
|
|
55
|
+
offset = 0
|
|
56
|
+
@spans.each do |span|
|
|
57
|
+
if offset < span.start
|
|
58
|
+
slices << {text: text.byteslice(offset...span.start)}
|
|
59
|
+
end
|
|
60
|
+
slices << {text: text.byteslice(span.start...span.finish), **span.style}
|
|
61
|
+
offset = span.finish
|
|
62
|
+
end
|
|
63
|
+
slices << {text: text.byteslice(offset..)} if offset < text.bytesize
|
|
64
|
+
slices.freeze
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def spans = @spans
|
|
68
|
+
def editable? = @editable
|
|
69
|
+
def on_change(&block) = (@on_change = block; self)
|
|
70
|
+
def on_select(&block) = (@on_select = block; self)
|
|
71
|
+
|
|
72
|
+
def selection=(value)
|
|
73
|
+
raise ArgumentError, "expected a TextSelection" unless value.is_a?(TextSelection)
|
|
74
|
+
checked_offset(value.anchor)
|
|
75
|
+
checked_offset(value.head)
|
|
76
|
+
@selection = value
|
|
77
|
+
@on_select&.call(value, self)
|
|
78
|
+
@cx&.window&.request_frame
|
|
79
|
+
value
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def apply(range, style)
|
|
83
|
+
first, finish = checked_range(range)
|
|
84
|
+
changes = normalize_style(style)
|
|
85
|
+
return self if first == finish || changes.empty?
|
|
86
|
+
boundaries = [0, text.bytesize, first, finish, *@spans.flat_map { |span| [span.start, span.finish] }].uniq.sort
|
|
87
|
+
@spans = merge_spans(boundaries.each_cons(2).filter_map do |from, to|
|
|
88
|
+
next if from == to
|
|
89
|
+
current = style_for_range(from)
|
|
90
|
+
updated = current.dup
|
|
91
|
+
changes.each { |key, value| value.nil? ? updated.delete(key) : updated[key] = value } if from >= first && to <= finish
|
|
92
|
+
Span.new(from, to, updated.freeze) unless updated.empty?
|
|
93
|
+
end)
|
|
94
|
+
changed
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def insert(offset, value, style: nil)
|
|
98
|
+
offset = checked_offset(offset)
|
|
99
|
+
validate_text(value)
|
|
100
|
+
return self if value.empty?
|
|
101
|
+
inherited = style ? normalize_style(style) : style_at(offset)
|
|
102
|
+
size, newlines = value.bytesize, value.count("\n")
|
|
103
|
+
@buffer.insert(offset, value)
|
|
104
|
+
@spans = merge_spans(@spans.flat_map do |span|
|
|
105
|
+
if span.finish <= offset
|
|
106
|
+
[span]
|
|
107
|
+
elsif span.start >= offset
|
|
108
|
+
[Span.new(span.start + size, span.finish + size, span.style)]
|
|
109
|
+
else
|
|
110
|
+
[Span.new(span.start, offset, span.style), Span.new(offset + size, span.finish + size, span.style)]
|
|
111
|
+
end
|
|
112
|
+
end + (inherited.empty? ? [] : [Span.new(offset, offset + size, inherited.freeze)]))
|
|
113
|
+
if newlines.positive?
|
|
114
|
+
line = @buffer.line_at(offset)
|
|
115
|
+
@paragraph_styles.insert(line + 1, *Array.new(newlines) { @paragraph_styles[line].dup.freeze })
|
|
116
|
+
end
|
|
117
|
+
sync_paragraph_styles
|
|
118
|
+
@selection = TextSelection.new(offset + size)
|
|
119
|
+
changed
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def delete(range)
|
|
123
|
+
first, finish = checked_range(range)
|
|
124
|
+
return self if first == finish
|
|
125
|
+
old = text
|
|
126
|
+
first_line = @buffer.line_at(first)
|
|
127
|
+
removed_lines = old.byteslice(first...finish).count("\n")
|
|
128
|
+
@buffer.delete(first...finish)
|
|
129
|
+
delta = finish - first
|
|
130
|
+
@spans = merge_spans(@spans.filter_map do |span|
|
|
131
|
+
from = map_offset(span.start, first, finish, delta)
|
|
132
|
+
to = map_offset(span.finish, first, finish, delta)
|
|
133
|
+
Span.new(from, to, span.style) if to > from
|
|
50
134
|
end)
|
|
135
|
+
@paragraph_styles.slice!(first_line + 1, removed_lines) if removed_lines.positive?
|
|
136
|
+
sync_paragraph_styles
|
|
137
|
+
@selection = TextSelection.new(first)
|
|
138
|
+
changed
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def replace(range, value, style: nil)
|
|
142
|
+
first, finish = checked_range(range)
|
|
143
|
+
validate_text(value)
|
|
144
|
+
return delete(first...finish) if value.empty?
|
|
145
|
+
inherited = style ? normalize_style(style) : style_at(first)
|
|
146
|
+
first_line, last_line = @buffer.line_at(first), @buffer.line_at(finish)
|
|
147
|
+
before_styles = @paragraph_styles.take(first_line)
|
|
148
|
+
paragraph_style = @paragraph_styles.fetch(first_line, {}).dup.freeze
|
|
149
|
+
after_styles = @paragraph_styles.drop(last_line + 1)
|
|
150
|
+
@buffer.replace(first...finish, value)
|
|
151
|
+
delta = finish - first
|
|
152
|
+
size = value.bytesize
|
|
153
|
+
replacement_spans = @spans.flat_map do |span|
|
|
154
|
+
if finish == first
|
|
155
|
+
if span.finish <= first
|
|
156
|
+
[span]
|
|
157
|
+
elsif span.start >= first
|
|
158
|
+
[Span.new(span.start + size, span.finish + size, span.style)]
|
|
159
|
+
else
|
|
160
|
+
[Span.new(span.start, first, span.style), Span.new(first + size, span.finish + size, span.style)]
|
|
161
|
+
end
|
|
162
|
+
elsif span.finish <= first
|
|
163
|
+
[span]
|
|
164
|
+
elsif span.start >= finish
|
|
165
|
+
[Span.new(span.start - delta + size, span.finish - delta + size, span.style)]
|
|
166
|
+
else
|
|
167
|
+
pieces = []
|
|
168
|
+
pieces << Span.new(span.start, first, span.style) if span.start < first
|
|
169
|
+
pieces << Span.new(first + size, span.finish - delta + size, span.style) if span.finish > finish
|
|
170
|
+
pieces
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
replacement_spans << Span.new(first, first + size, inherited.freeze) unless inherited.empty?
|
|
174
|
+
@spans = merge_spans(replacement_spans)
|
|
175
|
+
replacement_paragraphs = Array.new(value.count("\n") + 1) { paragraph_style }
|
|
176
|
+
@paragraph_styles = before_styles + replacement_paragraphs + after_styles
|
|
177
|
+
sync_paragraph_styles
|
|
178
|
+
@selection = TextSelection.new(first + size)
|
|
179
|
+
changed
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def paragraph_style(range, align: nil, list: nil, level: nil)
|
|
183
|
+
first, finish = checked_range(range)
|
|
184
|
+
raise ArgumentError, "align must be start, center, end, or justify" if align && !%i[start center end justify].include?(align)
|
|
185
|
+
raise ArgumentError, "list must be none, bullet, or ordered" if list && !LIST_STYLES.include?(list)
|
|
186
|
+
raise ArgumentError, "level must be a nonnegative integer" if level && (!level.is_a?(Integer) || level.negative?)
|
|
187
|
+
first_line, last_line = @buffer.line_at(first), @buffer.line_at(finish)
|
|
188
|
+
last_line = [last_line, @paragraph_styles.length - 1].min
|
|
189
|
+
values = {align: align, list: list, level: level}.compact
|
|
190
|
+
(first_line..last_line).each { |index| @paragraph_styles[index] = @paragraph_styles[index].merge(values).freeze }
|
|
191
|
+
@on_change&.call(text, self)
|
|
192
|
+
@cx&.window&.request_frame
|
|
193
|
+
self
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def build(cx) = (@cx = cx; RichTextSurface.new(self))
|
|
197
|
+
def tui_cells(*) = text
|
|
198
|
+
def accessibility_node(_cx) = node(@editable ? :textbox : :text, label: "Rich text", value: text,
|
|
199
|
+
states: {multiline: true, readonly: !@editable}, actions: @editable ? %i[focus set_value] : [])
|
|
200
|
+
|
|
201
|
+
def display_text
|
|
202
|
+
return text unless @buffer.composition
|
|
203
|
+
range = @selection.range
|
|
204
|
+
text.byteslice(0...range.begin) + @buffer.composition.text + text.byteslice(range.end..)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def text_action(action)
|
|
208
|
+
head = @selection.head
|
|
209
|
+
case action
|
|
210
|
+
when :select_all then self.selection = TextSelection.new(0, text.bytesize)
|
|
211
|
+
when :move_left then self.selection = TextSelection.new(@selection.collapsed? ? Unicode.previous_boundary(text, head) : @selection.range.begin)
|
|
212
|
+
when :move_right then self.selection = TextSelection.new(@selection.collapsed? ? Unicode.next_boundary(text, head) : @selection.range.end)
|
|
213
|
+
when :select_left then self.selection = TextSelection.new(@selection.anchor, Unicode.previous_boundary(text, head))
|
|
214
|
+
when :select_right then self.selection = TextSelection.new(@selection.anchor, Unicode.next_boundary(text, head))
|
|
215
|
+
when :line_start, :select_line_start then move_line_edge(action, :start)
|
|
216
|
+
when :line_end, :select_line_end then move_line_edge(action, :end)
|
|
217
|
+
when :delete_backward then delete_backward
|
|
218
|
+
when :delete_forward then delete_forward
|
|
219
|
+
when :insert_newline then replace_selection("\n") if @editable
|
|
220
|
+
else return false
|
|
221
|
+
end
|
|
222
|
+
true
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def input(event)
|
|
226
|
+
case event
|
|
227
|
+
when Input::TextInput
|
|
228
|
+
return false unless @editable
|
|
229
|
+
replace_selection(event.text)
|
|
230
|
+
when Input::Composition
|
|
231
|
+
return false unless @editable
|
|
232
|
+
@buffer.set_composition(event.text, selection: event.selection)
|
|
233
|
+
else return false
|
|
234
|
+
end
|
|
235
|
+
true
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def changed
|
|
239
|
+
@on_change&.call(text, self)
|
|
240
|
+
@cx&.window&.request_frame
|
|
241
|
+
self
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def style_at(offset)
|
|
245
|
+
span = @spans.find { |item| offset >= item.start && offset < item.finish } ||
|
|
246
|
+
@spans.reverse.find { |item| item.finish == offset }
|
|
247
|
+
span ? span.style.dup : {}
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def style_for_range(offset)
|
|
251
|
+
@spans.find { |span| offset >= span.start && offset < span.finish }&.style&.dup || {}
|
|
51
252
|
end
|
|
52
253
|
|
|
53
|
-
def
|
|
54
|
-
|
|
254
|
+
def paragraph_style_at(offset)
|
|
255
|
+
@paragraph_styles.fetch(@buffer.line_at([offset, text.bytesize].min), {})
|
|
256
|
+
end
|
|
55
257
|
|
|
56
258
|
private
|
|
57
259
|
|
|
58
260
|
def normalize_runs(value)
|
|
59
|
-
|
|
261
|
+
text, spans = +"", []
|
|
262
|
+
Array(value).each do |run|
|
|
60
263
|
run = {text: run} unless run.is_a?(Hash)
|
|
61
264
|
raise ArgumentError, "rich text runs require text" unless run.key?(:text)
|
|
62
|
-
|
|
265
|
+
part = run[:text].to_s.encode(Encoding::UTF_8)
|
|
266
|
+
style = normalize_style(run.reject { |key, _| key.to_sym == :text })
|
|
267
|
+
first = text.bytesize
|
|
268
|
+
text << part
|
|
269
|
+
spans << Span.new(first, text.bytesize, style.freeze) unless part.empty? || style.empty?
|
|
270
|
+
end
|
|
271
|
+
[text, merge_spans(spans)]
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def normalize_style(value)
|
|
275
|
+
raise ArgumentError, "rich text style must be a Hash" unless value.is_a?(Hash)
|
|
276
|
+
style = value.to_h { |key, item| [key.to_sym, item] }
|
|
277
|
+
unknown = style.keys - STYLE_KEYS
|
|
278
|
+
raise ArgumentError, "unsupported rich text style: #{unknown.join(', ')}" unless unknown.empty?
|
|
279
|
+
if style[:size] && (!style[:size].is_a?(Numeric) || !style[:size].finite? || !style[:size].positive?)
|
|
280
|
+
raise ArgumentError, "font size must be finite and positive"
|
|
281
|
+
end
|
|
282
|
+
style
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def merge_spans(spans)
|
|
286
|
+
spans.sort_by(&:start).each_with_object([]) do |span, result|
|
|
287
|
+
next if span.start == span.finish
|
|
288
|
+
previous = result.last
|
|
289
|
+
if previous && previous.finish == span.start && previous.style == span.style
|
|
290
|
+
result[-1] = Span.new(previous.start, span.finish, span.style)
|
|
291
|
+
else
|
|
292
|
+
result << span
|
|
293
|
+
end
|
|
63
294
|
end.freeze
|
|
64
295
|
end
|
|
296
|
+
|
|
297
|
+
def checked_range(range)
|
|
298
|
+
raise ArgumentError, "expected a Range" unless range.is_a?(Range)
|
|
299
|
+
first, finish = Integer(range.begin), Integer(range.end)
|
|
300
|
+
finish += 1 unless range.exclude_end?
|
|
301
|
+
checked_offset(first)
|
|
302
|
+
checked_offset(finish)
|
|
303
|
+
raise ArgumentError, "range is reversed" if finish < first
|
|
304
|
+
[first, finish]
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def checked_offset(offset)
|
|
308
|
+
raise ArgumentError, "offset is outside text" unless offset.is_a?(Integer) && offset.between?(0, text.bytesize)
|
|
309
|
+
raise ArgumentError, "offset splits a grapheme cluster" unless Unicode.grapheme_boundary?(text, offset)
|
|
310
|
+
offset
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def validate_text(value)
|
|
314
|
+
raise ArgumentError, "text must be valid UTF-8" unless value.is_a?(String) && value.encoding == Encoding::UTF_8 && value.valid_encoding?
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def map_offset(offset, first, finish, delta)
|
|
318
|
+
return offset if offset <= first
|
|
319
|
+
return offset - delta if offset >= finish
|
|
320
|
+
first
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def sync_paragraph_styles
|
|
324
|
+
length = text.count("\n") + 1
|
|
325
|
+
@paragraph_styles = @paragraph_styles.take(length)
|
|
326
|
+
@paragraph_styles << {}.freeze while @paragraph_styles.length < length
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def replace_selection(value)
|
|
330
|
+
range = @selection.range
|
|
331
|
+
if range.begin == range.end
|
|
332
|
+
insert(range.begin, value)
|
|
333
|
+
else
|
|
334
|
+
replace(range, value)
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def delete_backward
|
|
339
|
+
range = @selection.collapsed? ? Unicode.previous_boundary(text, @selection.head)...@selection.head : @selection.range
|
|
340
|
+
delete(range)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def delete_forward
|
|
344
|
+
range = @selection.collapsed? ? @selection.head...Unicode.next_boundary(text, @selection.head) : @selection.range
|
|
345
|
+
delete(range)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def move_line_edge(action, edge)
|
|
349
|
+
line = @buffer.line_at(@selection.head)
|
|
350
|
+
start = @buffer.offset_at(line, 0)
|
|
351
|
+
finish = text.index("\n", start) || text.bytesize
|
|
352
|
+
target = edge == :start ? start : finish
|
|
353
|
+
self.selection = action.to_s.start_with?("select") ? TextSelection.new(@selection.anchor, target) : TextSelection.new(target)
|
|
354
|
+
end
|
|
65
355
|
end
|
|
66
356
|
end
|
|
67
357
|
end
|