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.
@@ -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