hadar 0.1.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/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +234 -0
- data/assets/themes/dark.jsonc +11 -0
- data/assets/themes/minimal.jsonc +12 -0
- data/assets/themes/warm.jsonc +11 -0
- data/docs/adr/001-template-layouts.md +22 -0
- data/lib/hadar/application.rb +749 -0
- data/lib/hadar/command_palette.rb +57 -0
- data/lib/hadar/deck.rb +440 -0
- data/lib/hadar/deck_watcher.rb +33 -0
- data/lib/hadar/export/pdf.rb +150 -0
- data/lib/hadar/export/png_sequence.rb +63 -0
- data/lib/hadar/layout.rb +80 -0
- data/lib/hadar/presenter.rb +92 -0
- data/lib/hadar/renderer.rb +238 -0
- data/lib/hadar/rich_text_projection.rb +110 -0
- data/lib/hadar/rich_text_writeback.rb +377 -0
- data/lib/hadar/slide.rb +130 -0
- data/lib/hadar/slide_list.rb +74 -0
- data/lib/hadar/slot.rb +149 -0
- data/lib/hadar/theme.rb +85 -0
- data/lib/hadar/version.rb +5 -0
- data/lib/hadar.rb +31 -0
- data/sig/hadar.rbs +221 -0
- metadata +168 -0
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# APP-SPECIFIC: presenter commands and keymap remain local; Q6 found no shared three-app contract.
|
|
4
|
+
module Hadar
|
|
5
|
+
class Application
|
|
6
|
+
attr_reader :deck, :renderer, :presenter, :selected_index, :selected_slot_name, :slide_list, :keymap, :body_editor
|
|
7
|
+
|
|
8
|
+
def self.presenter_display(displays)
|
|
9
|
+
unless displays.is_a?(Array) && displays.all? { |display| display.is_a?(Zaniah::Platform::Display) }
|
|
10
|
+
raise TypeError, "displays must be an Array of Zaniah::Platform::Display values"
|
|
11
|
+
end
|
|
12
|
+
return if displays.length < 2
|
|
13
|
+
|
|
14
|
+
return displays[1] unless displays.any?(&:primary)
|
|
15
|
+
|
|
16
|
+
displays.find { |display| !display.primary } || displays[1]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.default_keymap(clock: Zaniah::MONOTONIC_CLOCK)
|
|
20
|
+
Zaniah::Input::Keymap.new(clock: clock)
|
|
21
|
+
.bind("right", :next_slide, context: "!in_text_field")
|
|
22
|
+
.bind("pagedown", :next_slide, context: "!in_text_field")
|
|
23
|
+
.bind("space", :next_slide, context: "!in_text_field")
|
|
24
|
+
.bind("left", :previous_slide, context: "!in_text_field")
|
|
25
|
+
.bind("pageup", :previous_slide, context: "!in_text_field")
|
|
26
|
+
.bind("home", :first_slide, context: "!in_text_field")
|
|
27
|
+
.bind("end", :last_slide, context: "!in_text_field")
|
|
28
|
+
.bind("p", :toggle_presentation, context: "!in_text_field")
|
|
29
|
+
.bind("f5", :toggle_presentation, context: "!in_text_field")
|
|
30
|
+
.bind("f11", :toggle_fullscreen)
|
|
31
|
+
.bind("ctrl-k", :toggle_command_palette)
|
|
32
|
+
.bind("cmd-k", :toggle_command_palette)
|
|
33
|
+
.bind("ctrl-s", :save)
|
|
34
|
+
.bind("cmd-s", :save)
|
|
35
|
+
.bind("esc", :escape)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(deck, renderer: Renderer.new, clock: Zaniah::MONOTONIC_CLOCK, watch: true, keymap: nil)
|
|
39
|
+
raise TypeError, "deck must be a Hadar::Deck" unless deck.is_a?(Deck)
|
|
40
|
+
raise TypeError, "watch must be true or false" unless watch == true || watch == false
|
|
41
|
+
unless keymap.nil? || keymap.is_a?(Zaniah::Input::Keymap)
|
|
42
|
+
raise TypeError, "keymap must be a Zaniah::Input::Keymap or nil"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
@deck, @renderer, @clock = deck, renderer, clock
|
|
46
|
+
@keymap = keymap || self.class.default_keymap(clock: clock)
|
|
47
|
+
@selected_index = deck.empty? ? nil : 0
|
|
48
|
+
@selected_slot_name = default_slot_name(deck.slide(@selected_index)) if @selected_index
|
|
49
|
+
@presenter = Presenter.new(deck, renderer: renderer, clock: clock)
|
|
50
|
+
@windows = []
|
|
51
|
+
@main_window = @presenter_window = nil
|
|
52
|
+
@owned_presenter_window = nil
|
|
53
|
+
@fullscreen = {main: false, presenter: false}
|
|
54
|
+
@transition_generation = {main: 0, presenter: 0}
|
|
55
|
+
@transition_initial_frame = {main: false, presenter: false}
|
|
56
|
+
@closed = false
|
|
57
|
+
@running = false
|
|
58
|
+
@command_palette = nil
|
|
59
|
+
@body_editor = nil
|
|
60
|
+
@body_editor_index = nil
|
|
61
|
+
@body_editor_error = nil
|
|
62
|
+
@table_selection = @table_draft = nil
|
|
63
|
+
@code_block_index = 0
|
|
64
|
+
@code_draft = nil
|
|
65
|
+
rebuild_slide_list
|
|
66
|
+
@watcher = deck.source_path && watch ? deck.watch(on_reload: ->(_updated) { deck_reloaded }) : nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def attach(main_window:, presenter_window: nil)
|
|
70
|
+
@main_window = main_window
|
|
71
|
+
attach_window(main_window) do |window|
|
|
72
|
+
main_view(width: window.content_size.width, height: window.content_size.height)
|
|
73
|
+
end
|
|
74
|
+
unless presenter_window
|
|
75
|
+
presenter_window = automatic_presenter_window(main_window)
|
|
76
|
+
@owned_presenter_window = presenter_window
|
|
77
|
+
end
|
|
78
|
+
@presenter_window = presenter_window
|
|
79
|
+
attach_window(presenter_window) do |_window|
|
|
80
|
+
transition_view(presenter.presenter_view, :presenter)
|
|
81
|
+
end if presenter_window
|
|
82
|
+
self
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def main_view(width:, height:)
|
|
86
|
+
preview = if presenter.started?
|
|
87
|
+
presenter.slide_view
|
|
88
|
+
elsif selected_index
|
|
89
|
+
renderer.build(deck.slide(selected_index))
|
|
90
|
+
else
|
|
91
|
+
Zaniah::UI::Label.new("No slides")
|
|
92
|
+
end
|
|
93
|
+
sidebar_width = [Float(width) * 0.24, 240.0].min
|
|
94
|
+
workspace = Zaniah::Div.new.flex_row.flex_1
|
|
95
|
+
.child(Zaniah::Div.new.flex_1.child(transition_view(preview, :main)))
|
|
96
|
+
unless presenter.started?
|
|
97
|
+
editor, error = slot_editor_for_selected_slide
|
|
98
|
+
editor_width = [[(Float(width) - sidebar_width) * 0.36, 420].min, 160].max
|
|
99
|
+
slot_title = selected_slot_name&.to_s&.capitalize || "No slots"
|
|
100
|
+
editor_pane = Zaniah::Div.new.w(editor_width).h_full.p(12).gap(8)
|
|
101
|
+
.style(flex_direction: :column, border: 1, border_color: deck.theme.colors.fetch("muted"))
|
|
102
|
+
editor_pane.child(Zaniah::UI::Label.new(slot_title, size: :sm))
|
|
103
|
+
editor_pane.child(slot_selector) if selected_index && deck.slide(selected_index).slots.any?
|
|
104
|
+
editor_pane.child(editor.w_full.flex_1) if editor
|
|
105
|
+
editor_pane.child(Zaniah::UI::Label.new("Editing unavailable: #{error}", tone: :muted)) if error
|
|
106
|
+
workspace.child(editor_pane)
|
|
107
|
+
end
|
|
108
|
+
view = Zaniah::Div.new.flex_row.w(width).h(height)
|
|
109
|
+
.child(slide_list.build(width: sidebar_width, height: height))
|
|
110
|
+
.child(workspace)
|
|
111
|
+
view.child(@command_palette) if @command_palette
|
|
112
|
+
view
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def command_palette = @command_palette
|
|
116
|
+
|
|
117
|
+
def export_png_sequence(directory, width: 1280, height: 720)
|
|
118
|
+
PNGSequence.write(deck, directory, renderer: renderer, width: width, height: height)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def save(path = nil, overwrite: false)
|
|
122
|
+
path ? deck.save(path, overwrite: overwrite) : deck.save(overwrite: overwrite)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def selected_slot
|
|
126
|
+
return unless selected_index && selected_slot_name
|
|
127
|
+
|
|
128
|
+
deck.slide(selected_index).slot(selected_slot_name)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def select_slot(name)
|
|
132
|
+
raise Error, "there is no selected slide" unless selected_index
|
|
133
|
+
raise TypeError, "slot name must be a String or Symbol" unless name.is_a?(String) || name.is_a?(Symbol)
|
|
134
|
+
|
|
135
|
+
slot_name = name.to_sym
|
|
136
|
+
slot = deck.slide(selected_index).slots.fetch(slot_name) { raise IndexError, "slot is not available on this slide: #{slot_name}" }
|
|
137
|
+
return slot if selected_slot_name == slot_name
|
|
138
|
+
|
|
139
|
+
@selected_slot_name = slot_name
|
|
140
|
+
reset_slot_editor
|
|
141
|
+
slot
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def select_table_cell(table:, row:, column:)
|
|
145
|
+
slot = selected_slot || raise(Error, "there is no selected slot")
|
|
146
|
+
rows = slot.table_rows(table: table)
|
|
147
|
+
rows.fetch(row).fetch(column)
|
|
148
|
+
@table_selection = [table, row, column]
|
|
149
|
+
@table_draft = nil
|
|
150
|
+
request_frames
|
|
151
|
+
@table_selection
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def select_code_block(index)
|
|
155
|
+
slot = selected_slot || raise(Error, "there is no selected slot")
|
|
156
|
+
block = slot.nodes.select { |node| node.type == :code_block }.fetch(index)
|
|
157
|
+
@code_block_index, @code_draft = index, nil
|
|
158
|
+
request_frames
|
|
159
|
+
block
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def insert_or_replace_selected_image(path)
|
|
163
|
+
slot = selected_slot || raise(Error, "there is no selected slot")
|
|
164
|
+
raise Error, "select the image slot first" unless slot.name == :image
|
|
165
|
+
|
|
166
|
+
updated = slot.empty? ? slot.insert_image(path) : slot.replace_image(path)
|
|
167
|
+
finish_slot_edit
|
|
168
|
+
updated
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def replace_selected_table_cell(value)
|
|
172
|
+
slot = selected_slot || raise(Error, "there is no selected slot")
|
|
173
|
+
table, row, column = @table_selection || default_table_selection(slot)
|
|
174
|
+
updated = slot.replace_table_cell(row: row, column: column, value: value, table: table)
|
|
175
|
+
finish_slot_edit
|
|
176
|
+
updated
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def replace_selected_code(text)
|
|
180
|
+
slot = selected_slot || raise(Error, "there is no selected slot")
|
|
181
|
+
updated = slot.replace_code(text, block: @code_block_index)
|
|
182
|
+
finish_slot_edit
|
|
183
|
+
updated
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def select_slide(index)
|
|
187
|
+
raise IndexError, "slide index is outside the deck" unless index.is_a?(Integer) && (0...deck.length).cover?(index)
|
|
188
|
+
|
|
189
|
+
slide = slide_list.select(index)
|
|
190
|
+
request_frames
|
|
191
|
+
slide
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def next_slide
|
|
195
|
+
navigate_to(selected_index + 1) if selected_index && selected_index + 1 < deck.length
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def previous_slide
|
|
199
|
+
navigate_to(selected_index - 1) if selected_index && selected_index.positive?
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def first_slide
|
|
203
|
+
navigate_to(0) unless deck.empty?
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def last_slide
|
|
207
|
+
navigate_to(deck.length - 1) unless deck.empty?
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def toggle_fullscreen(window: :presenter)
|
|
211
|
+
target = window_for(window)
|
|
212
|
+
raise Error, "#{window} window backend does not support fullscreen" unless target.respond_to?(:toggle_fullscreen)
|
|
213
|
+
|
|
214
|
+
target.toggle_fullscreen
|
|
215
|
+
@fullscreen[window] = !@fullscreen.fetch(window)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def fullscreen?(window: :presenter)
|
|
219
|
+
window_for(window)
|
|
220
|
+
@fullscreen.fetch(window)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def start_presentation(index: selected_index)
|
|
224
|
+
presenter.start(index: index)
|
|
225
|
+
if selected_index == index
|
|
226
|
+
begin_slide_transition
|
|
227
|
+
else
|
|
228
|
+
slide_list.select(index)
|
|
229
|
+
end
|
|
230
|
+
request_frames
|
|
231
|
+
presenter
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def stop_presentation
|
|
235
|
+
presenter.stop
|
|
236
|
+
begin_slide_transition
|
|
237
|
+
request_frames
|
|
238
|
+
presenter
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def tick
|
|
242
|
+
changed = @watcher&.poll(timeout: 0) || false
|
|
243
|
+
request_frames if changed || presenter.started?
|
|
244
|
+
changed
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def run
|
|
248
|
+
raise Error, "attach at least one window before running the application" if @windows.empty?
|
|
249
|
+
raise Error, "application is already running" if @running
|
|
250
|
+
|
|
251
|
+
@running = true
|
|
252
|
+
until @closed || !@running || @windows.all?(&:closed?)
|
|
253
|
+
tick
|
|
254
|
+
@windows.dup.each do |window|
|
|
255
|
+
window.tick unless window.closed?
|
|
256
|
+
close_owned_presenter_window if window.equal?(@main_window) && window.closed?
|
|
257
|
+
restore_reloaded_body_editor_focus(window) if window.equal?(@main_window)
|
|
258
|
+
end
|
|
259
|
+
sleep(0.016) unless @closed || !@running || @windows.all?(&:closed?)
|
|
260
|
+
end
|
|
261
|
+
self
|
|
262
|
+
ensure
|
|
263
|
+
@running = false
|
|
264
|
+
close if @windows.any? && @windows.all?(&:closed?)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def close
|
|
268
|
+
@closed = true
|
|
269
|
+
@watcher&.close
|
|
270
|
+
close_owned_presenter_window
|
|
271
|
+
@windows.clear
|
|
272
|
+
self
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def stop
|
|
276
|
+
@running = false
|
|
277
|
+
self
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
private
|
|
281
|
+
|
|
282
|
+
def automatic_presenter_window(main_window)
|
|
283
|
+
return unless main_window.respond_to?(:displays)
|
|
284
|
+
|
|
285
|
+
display = self.class.presenter_display(main_window.displays)
|
|
286
|
+
return unless display
|
|
287
|
+
|
|
288
|
+
backend, options = presenter_backend(main_window)
|
|
289
|
+
return unless backend
|
|
290
|
+
|
|
291
|
+
window = Zaniah::Platform.open_window(backend: backend, **options,
|
|
292
|
+
width: main_window.content_size.width, height: main_window.content_size.height,
|
|
293
|
+
scale_factor: main_window.scale_factor, title: "Hadar Presenter")
|
|
294
|
+
if window.respond_to?(:fullscreen_on)
|
|
295
|
+
raise Error, "could not place the presenter window on the secondary display" unless window.fullscreen_on(display)
|
|
296
|
+
@fullscreen[:presenter] = true
|
|
297
|
+
else
|
|
298
|
+
raise Error, "could not place the presenter window on the secondary display" unless window.move_to_display(display)
|
|
299
|
+
if window.respond_to?(:toggle_fullscreen)
|
|
300
|
+
window.toggle_fullscreen
|
|
301
|
+
@fullscreen[:presenter] = true
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
window
|
|
305
|
+
rescue StandardError
|
|
306
|
+
window&.close unless window&.closed?
|
|
307
|
+
raise
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def presenter_backend(window)
|
|
311
|
+
ancestors = window.class.ancestors.filter_map(&:name)
|
|
312
|
+
return [:mac, {}] if ancestors.include?("Zaniah::Platform::Mac::Window")
|
|
313
|
+
return [:linux, {display_server: :wayland}] if ancestors.include?("Zaniah::Platform::Linux::WaylandWindow")
|
|
314
|
+
return [:linux, {display_server: :x11}] if ancestors.include?("Zaniah::Platform::Linux::Window")
|
|
315
|
+
return [:windows, {}] if ancestors.include?("Zaniah::Platform::Windows::Window")
|
|
316
|
+
return [:headless, {}] if ancestors.include?("Zaniah::Platform::Headless::Window")
|
|
317
|
+
|
|
318
|
+
[nil, {}]
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def close_owned_presenter_window
|
|
322
|
+
window = @owned_presenter_window
|
|
323
|
+
return unless window
|
|
324
|
+
|
|
325
|
+
@owned_presenter_window = nil
|
|
326
|
+
window.close unless window.closed?
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def attach_window(window, &draw)
|
|
330
|
+
raise TypeError, "window must support drawing, input, ticking, closing, and frame requests" unless
|
|
331
|
+
%i[draw on_input tick closed? request_frame].all? { |method| window.respond_to?(method) }
|
|
332
|
+
|
|
333
|
+
@windows << window unless @windows.include?(window)
|
|
334
|
+
window.draw(&draw)
|
|
335
|
+
window.on_input { |event| handle_input(event, window) }
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def handle_input(event, window)
|
|
339
|
+
return unless event.is_a?(Zaniah::Input::KeyDown) && !event.is_held
|
|
340
|
+
|
|
341
|
+
key = Zaniah::Input::Keystroke.normalize(event.keystroke)
|
|
342
|
+
target = window.equal?(@presenter_window) ? :presenter : :main
|
|
343
|
+
action = keymap.dispatch(key, context: key_context(window))
|
|
344
|
+
return if action.nil? || action == :pending
|
|
345
|
+
|
|
346
|
+
case action
|
|
347
|
+
when :next_slide
|
|
348
|
+
next_slide
|
|
349
|
+
when :previous_slide
|
|
350
|
+
previous_slide
|
|
351
|
+
when :first_slide
|
|
352
|
+
first_slide
|
|
353
|
+
when :last_slide
|
|
354
|
+
last_slide
|
|
355
|
+
when :toggle_presentation
|
|
356
|
+
presenter.started? ? stop_presentation : start_presentation
|
|
357
|
+
when :toggle_fullscreen
|
|
358
|
+
fullscreen_window = target == :main && presenter.started? && @presenter_window ? :presenter : target
|
|
359
|
+
toggle_fullscreen(window: fullscreen_window)
|
|
360
|
+
when :toggle_command_palette
|
|
361
|
+
toggle_command_palette if target == :main
|
|
362
|
+
when :save
|
|
363
|
+
save
|
|
364
|
+
when :escape
|
|
365
|
+
if @command_palette&.open?
|
|
366
|
+
@command_palette.close
|
|
367
|
+
else
|
|
368
|
+
stop_presentation if presenter.started?
|
|
369
|
+
fullscreen_window = @presenter_window && @fullscreen[:presenter] ? :presenter : target
|
|
370
|
+
toggle_fullscreen(window: fullscreen_window) if fullscreen?(window: fullscreen_window)
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def toggle_command_palette
|
|
376
|
+
unless @command_palette
|
|
377
|
+
require_relative "command_palette"
|
|
378
|
+
@command_palette = CommandPalette.new(command_actions)
|
|
379
|
+
end
|
|
380
|
+
@command_palette.open(!@command_palette.open?)
|
|
381
|
+
request_frames
|
|
382
|
+
@command_palette
|
|
383
|
+
rescue LoadError => error
|
|
384
|
+
raise Error, "the command palette requires Spica: #{error.message}"
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def command_actions
|
|
388
|
+
{
|
|
389
|
+
"Next slide" => ->(*) { next_slide },
|
|
390
|
+
"Previous slide" => ->(*) { previous_slide },
|
|
391
|
+
"First slide" => ->(*) { first_slide },
|
|
392
|
+
"Last slide" => ->(*) { last_slide },
|
|
393
|
+
"Toggle presentation" => ->(*) { presenter.started? ? stop_presentation : start_presentation },
|
|
394
|
+
"Toggle fullscreen" => ->(*) { toggle_fullscreen(window: :main) },
|
|
395
|
+
"Save" => ->(*) { save },
|
|
396
|
+
"Export PNG sequence…" => ->(*) { prompt_png_sequence_directory }
|
|
397
|
+
}
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def prompt_png_sequence_directory
|
|
401
|
+
unless @main_window.respond_to?(:prompt_for_paths)
|
|
402
|
+
raise Error, "this window backend cannot choose a directory; call export_png_sequence(directory)"
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
directory = @main_window.prompt_for_paths(directories: true).first
|
|
406
|
+
export_png_sequence(directory) if directory && !directory.empty?
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def slot_editor_for_selected_slide
|
|
410
|
+
return [nil, nil] unless selected_index
|
|
411
|
+
unless selected_slot_name
|
|
412
|
+
@body_editor_error ||= "this slide has no editable slots"
|
|
413
|
+
return [nil, @body_editor_error]
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
slot = selected_slot
|
|
417
|
+
if slot.name == :image
|
|
418
|
+
pending_body_editor_focus(:clear) if @pending_body_editor_reload && @pending_body_editor_reload[:slot] == slot.name
|
|
419
|
+
[image_slot_editor(slot), @body_editor_error]
|
|
420
|
+
elsif slot.nodes.any? { |node| %i[table code_block].include?(node.type) }
|
|
421
|
+
pending_body_editor_focus(:clear) if @pending_body_editor_reload && @pending_body_editor_reload[:slot] == slot.name
|
|
422
|
+
panels = []
|
|
423
|
+
panels << table_slot_editor(slot) if slot.nodes.any? { |node| node.type == :table }
|
|
424
|
+
panels << code_slot_editor(slot) if slot.nodes.any? { |node| node.type == :code_block }
|
|
425
|
+
[Zaniah::Div.new.flex_col.gap(12).children(panels), @body_editor_error]
|
|
426
|
+
else
|
|
427
|
+
rich_text_slot_editor(slot)
|
|
428
|
+
end
|
|
429
|
+
rescue Error, ArgumentError, IndexError, TypeError => error
|
|
430
|
+
@body_editor_error = error.message
|
|
431
|
+
[nil, @body_editor_error]
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def rich_text_slot_editor(slot)
|
|
435
|
+
key = [selected_index, selected_slot_name]
|
|
436
|
+
return [@body_editor, @body_editor_error] if @body_editor_index == key
|
|
437
|
+
if slot.empty?
|
|
438
|
+
@body_editor_error = "the #{slot.name} slot is empty"
|
|
439
|
+
pending_body_editor_focus(:clear) if @pending_body_editor_reload
|
|
440
|
+
return [nil, @body_editor_error]
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
@body_editor_index = key
|
|
444
|
+
@body_editor = slot.rich_text
|
|
445
|
+
restore_body_editor_selection
|
|
446
|
+
[@body_editor, @body_editor_error]
|
|
447
|
+
rescue Error => error
|
|
448
|
+
@body_editor = nil
|
|
449
|
+
@body_editor_error = error.message
|
|
450
|
+
pending_body_editor_focus(:clear) if @pending_body_editor_reload
|
|
451
|
+
[nil, @body_editor_error]
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def slot_selector
|
|
455
|
+
buttons = deck.slide(selected_index).slots.keys.map do |name|
|
|
456
|
+
Zaniah::UI::Button.new(name.to_s.capitalize, size: :sm,
|
|
457
|
+
variant: name == selected_slot_name ? :secondary : :ghost)
|
|
458
|
+
.key([:hadar_slot, selected_index, name])
|
|
459
|
+
.on_click { select_slot(name) }
|
|
460
|
+
end
|
|
461
|
+
Zaniah::UI::ButtonGroup.new(*buttons)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def image_slot_editor(slot)
|
|
465
|
+
return Zaniah::UI::Label.new("Select an image slot to insert or replace an image.") unless slot.name == :image
|
|
466
|
+
if !slot.empty? && !(slot.nodes.one? && slot.nodes.first.type == :image)
|
|
467
|
+
return Zaniah::UI::Label.new("This image slot contains multiple images and cannot be edited safely.", tone: :muted)
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
panel = Zaniah::Div.new.flex_col.gap(8)
|
|
471
|
+
panel.child(Zaniah::UI::Label.new(slot.empty? ? "No image selected" : "Image: #{slot.image_path}", tone: :muted))
|
|
472
|
+
panel.child(Zaniah::UI::Button.new(slot.empty? ? "Insert image…" : "Replace image…", variant: :secondary)
|
|
473
|
+
.on_click { safely_edit_slot { prompt_for_image } })
|
|
474
|
+
panel
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def table_slot_editor(slot)
|
|
478
|
+
tables = slot.nodes.select { |node| node.type == :table }
|
|
479
|
+
table, row, column = @table_selection || default_table_selection(slot)
|
|
480
|
+
rows = slot.table_rows(table: table)
|
|
481
|
+
return Zaniah::UI::Label.new("This table has no editable cells.", tone: :muted) if rows.empty? || rows.all?(&:empty?)
|
|
482
|
+
|
|
483
|
+
row = rows.index { |cells| !cells.empty? } if rows[row]&.empty?
|
|
484
|
+
column = [column, rows[row].length - 1].min
|
|
485
|
+
|
|
486
|
+
cell = rows.fetch(row).fetch(column)
|
|
487
|
+
panel = Zaniah::Div.new.flex_col.gap(8)
|
|
488
|
+
.child(Zaniah::UI::Label.new("Edit one plain-text cell; Markdown delimiters and newlines are not accepted.", tone: :muted))
|
|
489
|
+
panel.child(Zaniah::UI::Select.new(tables.each_index.map { |index| ["Table #{index + 1}", index] },
|
|
490
|
+
label: "Table", value: table).on_change do |value, _event, _context|
|
|
491
|
+
rows = slot.table_rows(table: value)
|
|
492
|
+
unless rows.empty? || rows.all?(&:empty?)
|
|
493
|
+
row = [row, rows.length - 1].min
|
|
494
|
+
row = rows.index { |cells| !cells.empty? } if rows[row].empty?
|
|
495
|
+
select_table_cell(table: value, row: row, column: [column, rows[row].length - 1].min)
|
|
496
|
+
end
|
|
497
|
+
end)
|
|
498
|
+
panel.child(Zaniah::UI::Select.new(rows.each_index.map { |index| ["Row #{index + 1}", index] },
|
|
499
|
+
label: "Row", value: row).on_change do |value, _event, _context|
|
|
500
|
+
cells = rows.fetch(value)
|
|
501
|
+
select_table_cell(table: table, row: value, column: [column, cells.length - 1].min) unless cells.empty?
|
|
502
|
+
end)
|
|
503
|
+
panel.child(Zaniah::UI::Select.new(rows[row].each_index.map { |index| ["Column #{index + 1}", index] },
|
|
504
|
+
label: "Column", value: column).on_change do |value, _event, _context|
|
|
505
|
+
select_table_cell(table: table, row: row, column: value)
|
|
506
|
+
end)
|
|
507
|
+
field_value = @table_draft.nil? ? cell : @table_draft
|
|
508
|
+
if !@table_field || @table_field.value != field_value
|
|
509
|
+
@table_field = Zaniah::UI::TextField.new(field_value, label: "Cell text")
|
|
510
|
+
.on_change { |value, _context| @table_draft = value }
|
|
511
|
+
end
|
|
512
|
+
panel.child(@table_field)
|
|
513
|
+
panel.child(Zaniah::UI::Button.new("Apply cell", size: :sm)
|
|
514
|
+
.on_click { safely_edit_slot { replace_selected_table_cell(@table_draft.nil? ? cell : @table_draft) } })
|
|
515
|
+
panel
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def code_slot_editor(slot)
|
|
519
|
+
blocks = slot.nodes.select { |node| node.type == :code_block }
|
|
520
|
+
block = blocks.fetch(@code_block_index)
|
|
521
|
+
panel = Zaniah::Div.new.flex_col.gap(8)
|
|
522
|
+
panel.child(Zaniah::UI::Label.new("Code preview is syntax-highlighted when Antares supports its language.", tone: :muted))
|
|
523
|
+
panel.child(Zaniah::UI::Select.new(blocks.each_index.map { |index| ["Block #{index + 1}", index] },
|
|
524
|
+
label: "Code block", value: @code_block_index).on_change do |index, _event, _context|
|
|
525
|
+
select_code_block(index)
|
|
526
|
+
end) if blocks.length > 1
|
|
527
|
+
editor_key = [selected_index, selected_slot_name, @code_block_index]
|
|
528
|
+
editor_value = @code_draft.nil? ? slot.text_for(block) : @code_draft
|
|
529
|
+
if !@code_editor || @code_editor_key != editor_key
|
|
530
|
+
@code_editor_key = editor_key
|
|
531
|
+
@code_editor = Zaniah::UI::CodeEditor.new(editor_value, language: block.attributes[:info])
|
|
532
|
+
.w_full.flex_1.on_change { |text, _context| @code_draft = text }
|
|
533
|
+
elsif @code_editor.value != editor_value
|
|
534
|
+
@code_editor.buffer.replace(0...@code_editor.buffer.bytesize, editor_value)
|
|
535
|
+
end
|
|
536
|
+
panel.child(@code_editor)
|
|
537
|
+
panel.child(Zaniah::UI::Button.new("Apply code", size: :sm)
|
|
538
|
+
.on_click { safely_edit_slot { replace_selected_code(@code_draft.nil? ? slot.text_for(block) : @code_draft) } })
|
|
539
|
+
panel
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def prompt_for_image
|
|
543
|
+
unless @main_window&.respond_to?(:prompt_for_paths)
|
|
544
|
+
raise Error, "this window backend cannot choose an image file"
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
path = @main_window.prompt_for_paths.first
|
|
548
|
+
insert_or_replace_selected_image(path) if path && !path.empty?
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def safely_edit_slot
|
|
552
|
+
yield
|
|
553
|
+
rescue Error, ArgumentError, IndexError, TypeError => error
|
|
554
|
+
@body_editor_error = error.message
|
|
555
|
+
request_frames
|
|
556
|
+
nil
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def default_table_selection(slot)
|
|
560
|
+
rows = slot.table_rows
|
|
561
|
+
[0, rows.length > 1 ? 1 : 0, 0]
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def finish_slot_edit
|
|
565
|
+
@body_editor = @body_editor_index = nil
|
|
566
|
+
@body_editor_error = @table_draft = @code_draft = nil
|
|
567
|
+
@table_field = @code_editor = @code_editor_key = nil
|
|
568
|
+
request_frames
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def reset_slot_editor
|
|
572
|
+
@body_editor = @body_editor_index = @body_editor_error = nil
|
|
573
|
+
@table_selection = @table_draft = nil
|
|
574
|
+
@code_block_index, @code_draft = 0, nil
|
|
575
|
+
@table_field = @code_editor = @code_editor_key = nil
|
|
576
|
+
@pending_body_editor_reload = nil
|
|
577
|
+
request_frames
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def reset_slot_editor_state_after_reload
|
|
581
|
+
@body_editor = @body_editor_index = @body_editor_error = nil
|
|
582
|
+
@table_selection = @table_draft = nil
|
|
583
|
+
@code_block_index, @code_draft = 0, nil
|
|
584
|
+
@table_field = @code_editor = @code_editor_key = nil
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def default_slot_name(slide)
|
|
588
|
+
slide.slots.key?(:body) ? :body : slide.slots.keys.first
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def key_context(window)
|
|
592
|
+
(window.dispatcher.focused&.ancestors || []).reverse.each_with_object({}) do |handle, context|
|
|
593
|
+
context.merge!(handle.context)
|
|
594
|
+
end
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def rebuild_slide_list
|
|
598
|
+
@slide_list = SlideList.new(deck, renderer: renderer, selected: selected_index,
|
|
599
|
+
on_select: ->(_slide, index) do
|
|
600
|
+
next if @selected_index == index
|
|
601
|
+
|
|
602
|
+
@selected_index = index
|
|
603
|
+
@selected_slot_name = default_slot_name(deck.slide(index))
|
|
604
|
+
reset_slot_editor
|
|
605
|
+
presenter.go_to(index) if presenter.started?
|
|
606
|
+
begin_slide_transition
|
|
607
|
+
request_frames
|
|
608
|
+
end)
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
def navigate_to(index)
|
|
612
|
+
unless index.is_a?(Integer) && (0...deck.length).cover?(index)
|
|
613
|
+
raise IndexError, "slide index is outside the deck"
|
|
614
|
+
end
|
|
615
|
+
return deck.slide(index) if index == selected_index
|
|
616
|
+
|
|
617
|
+
slide_list.select(index)
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
def begin_slide_transition
|
|
621
|
+
@transition_generation.each_key do |surface|
|
|
622
|
+
next if surface == :presenter && !@presenter_window
|
|
623
|
+
|
|
624
|
+
@transition_generation[surface] += 1
|
|
625
|
+
@transition_initial_frame[surface] = true
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
def transition_view(element, surface)
|
|
630
|
+
initial_frame = @transition_initial_frame.fetch(surface)
|
|
631
|
+
@transition_initial_frame[surface] = false if initial_frame
|
|
632
|
+
request_frames if initial_frame
|
|
633
|
+
|
|
634
|
+
Zaniah::Div.new.key([:hadar_slide_transition, surface, @transition_generation.fetch(surface)])
|
|
635
|
+
.w_full.h_full.opacity(initial_frame ? 0.0 : 1.0)
|
|
636
|
+
.transition(:opacity, duration: 0.24, easing: :ease_out)
|
|
637
|
+
.child(element)
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def window_for(name)
|
|
641
|
+
target = case name
|
|
642
|
+
when :main then @main_window
|
|
643
|
+
when :presenter then @presenter_window
|
|
644
|
+
else raise ArgumentError, "window must be :main or :presenter"
|
|
645
|
+
end
|
|
646
|
+
raise Error, "#{name} window is not attached" unless target
|
|
647
|
+
|
|
648
|
+
target
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
def deck_reloaded
|
|
652
|
+
previous_index = selected_index
|
|
653
|
+
previous_slot = selected_slot_name
|
|
654
|
+
previous_editor = @body_editor if @body_editor_index == [previous_index, previous_slot]
|
|
655
|
+
previous_focus = previous_editor && @main_window&.dispatcher&.focused.equal?(previous_editor.focus_handle)
|
|
656
|
+
@pending_body_editor_reload = if previous_editor
|
|
657
|
+
{index: previous_index, slot: previous_slot, text: previous_editor.text, selection: previous_editor.selection,
|
|
658
|
+
focus: previous_focus ? :pending : :none}
|
|
659
|
+
end
|
|
660
|
+
@selected_index = if deck.empty?
|
|
661
|
+
nil
|
|
662
|
+
else
|
|
663
|
+
[[selected_index || 0, 0].max, deck.length - 1].min
|
|
664
|
+
end
|
|
665
|
+
@pending_body_editor_reload = nil if @selected_index != previous_index
|
|
666
|
+
@selected_slot_name = @selected_index && deck.slide(@selected_index).slots.key?(previous_slot) ? previous_slot :
|
|
667
|
+
(@selected_index && default_slot_name(deck.slide(@selected_index)))
|
|
668
|
+
reset_slot_editor_state_after_reload
|
|
669
|
+
presenter.reconcile!
|
|
670
|
+
rebuild_slide_list
|
|
671
|
+
request_frames
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
def restore_body_editor_selection
|
|
675
|
+
pending = @pending_body_editor_reload
|
|
676
|
+
return unless pending && pending[:index] == selected_index && pending[:slot] == selected_slot_name
|
|
677
|
+
|
|
678
|
+
selection = remap_text_selection(pending[:text], @body_editor.text, pending[:selection])
|
|
679
|
+
@body_editor.selection = selection || Zaniah::TextSelection.new(0)
|
|
680
|
+
pending_body_editor_focus(pending[:focus] == :pending && selection ? :restore :
|
|
681
|
+
(pending[:focus] == :pending ? :clear : :none))
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
def pending_body_editor_focus(action)
|
|
685
|
+
@pending_body_editor_reload[:focus] = action if @pending_body_editor_reload
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
def restore_reloaded_body_editor_focus(window)
|
|
689
|
+
pending = @pending_body_editor_reload
|
|
690
|
+
return unless pending
|
|
691
|
+
if pending[:index] != selected_index || pending[:slot] != selected_slot_name
|
|
692
|
+
window.dispatcher.focus(nil, origin: :programmatic) unless pending[:focus] == :none
|
|
693
|
+
@pending_body_editor_reload = nil
|
|
694
|
+
return
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
case pending[:focus]
|
|
698
|
+
when :restore
|
|
699
|
+
window.dispatcher.focus(@body_editor&.focus_handle, origin: :programmatic)
|
|
700
|
+
when :clear
|
|
701
|
+
window.dispatcher.focus(nil, origin: :programmatic)
|
|
702
|
+
end
|
|
703
|
+
@pending_body_editor_reload = nil
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def remap_text_selection(old_text, new_text, selection)
|
|
707
|
+
return selection if old_text == new_text
|
|
708
|
+
|
|
709
|
+
old_clusters = Zaniah::Unicode.grapheme_clusters(old_text)
|
|
710
|
+
new_clusters = Zaniah::Unicode.grapheme_clusters(new_text)
|
|
711
|
+
prefix = 0
|
|
712
|
+
while prefix < old_clusters.length && prefix < new_clusters.length && old_clusters[prefix] == new_clusters[prefix]
|
|
713
|
+
prefix += 1
|
|
714
|
+
end
|
|
715
|
+
suffix = 0
|
|
716
|
+
while suffix < old_clusters.length - prefix && suffix < new_clusters.length - prefix &&
|
|
717
|
+
old_clusters[old_clusters.length - suffix - 1] == new_clusters[new_clusters.length - suffix - 1]
|
|
718
|
+
suffix += 1
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
old_prefix_end = old_clusters.take(prefix).join.bytesize
|
|
722
|
+
old_suffix_start = old_clusters.take(old_clusters.length - suffix).join.bytesize
|
|
723
|
+
new_suffix_start = new_clusters.take(new_clusters.length - suffix).join.bytesize
|
|
724
|
+
range = selection.range
|
|
725
|
+
|
|
726
|
+
if selection.collapsed?
|
|
727
|
+
position = selection.head
|
|
728
|
+
return Zaniah::TextSelection.new(position) if position < old_prefix_end
|
|
729
|
+
return Zaniah::TextSelection.new(position + new_suffix_start - old_suffix_start) if position > old_suffix_start
|
|
730
|
+
return if old_prefix_end == old_suffix_start
|
|
731
|
+
return Zaniah::TextSelection.new(position) if position == old_prefix_end
|
|
732
|
+
return Zaniah::TextSelection.new(position + new_suffix_start - old_suffix_start) if position == old_suffix_start
|
|
733
|
+
|
|
734
|
+
return
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
if range.end <= old_prefix_end
|
|
738
|
+
selection
|
|
739
|
+
elsif range.begin >= old_suffix_start
|
|
740
|
+
delta = new_suffix_start - old_suffix_start
|
|
741
|
+
Zaniah::TextSelection.new(selection.anchor + delta, selection.head + delta)
|
|
742
|
+
end
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
def request_frames
|
|
746
|
+
@windows.each(&:request_frame)
|
|
747
|
+
end
|
|
748
|
+
end
|
|
749
|
+
end
|