canopus 0.3.0 → 0.4.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.
@@ -27,6 +27,7 @@ module Canopus
27
27
  end
28
28
  def paint(bounds, _state, _prepaint, cx)
29
29
  @cx, @theme, @scene = cx, @workspace.theme, cx.scene
30
+ @children.clear
30
31
  @viewport_bounds = bounds
31
32
  @painted_palette = @workspace.palette
32
33
  @font_size = @workspace.settings["font_size"]
@@ -34,6 +35,7 @@ module Canopus
34
35
  @line_height = 20 if @cx.window.is_a?(Zaniah::Platform::TUI::Window)
35
36
  @regions.clear
36
37
  @row_layouts.clear
38
+ @overlay_rows = []
37
39
  @editor_bounds.clear
38
40
  @accessibility.clear
39
41
  @frame_diagnostics.clear
@@ -42,16 +44,21 @@ module Canopus
42
44
  @line_widths.delete_if { |editor, _| !visible_editors.include?(editor) }
43
45
  @code_caches.delete_if { |editor, _| !visible_editors.include?(editor) }
44
46
  fill(bounds, :background)
45
- bottom_panel = @workspace.docks[:bottom][:visible] && @workspace.panels.values.any? { |side, _| side == :bottom }
46
- bottom = @workspace.terminal_visible || bottom_panel ? [bounds.height * 0.7, @workspace.docks[:bottom][:size]].min : 0
47
- sidebar = @workspace.show_project && @workspace.docks[:left][:visible] && bounds.width >= 620 ? [@workspace.docks[:left][:size], bounds.width * 0.4].min : 0
48
- right = @workspace.docks[:right][:visible] && bounds.width >= 620 ? [@workspace.docks[:right][:size], bounds.width * 0.4].min : 0
47
+ left_panels = drawable_panels(:left)
48
+ bottom_panels = drawable_panels(:bottom)
49
+ bottom_visible = @workspace.docks[:bottom][:visible] && (@workspace.terminal_visible || !bottom_panels.empty?)
50
+ bottom = bottom_visible ? [bounds.height * 0.7, @workspace.docks[:bottom][:size]].min : 0
51
+ left_visible = @workspace.docks[:left][:visible] && (@workspace.show_project || !left_panels.empty?)
52
+ sidebar = left_visible && bounds.width >= 620 ? [@workspace.docks[:left][:size], bounds.width * 0.4].min : 0
53
+ right = @workspace.docks[:right][:visible] && !drawable_panels(:right).empty? && bounds.width >= 620 ? [@workspace.docks[:right][:size], bounds.width * 0.4].min : 0
49
54
  body = Zaniah::Bounds.new(sidebar, 0, bounds.width - sidebar - right, [bounds.height - 26 - bottom, 0].max)
50
55
  if sidebar.positive?
51
- left_panels = @workspace.panels.values.any? { |side, _| side == :left }
52
- project_height = left_panels ? (bounds.height - 26) * 0.6 : bounds.height - 26
53
- paint_project(Zaniah::Bounds.new(0, 0, sidebar, project_height))
54
- paint_panels(:left, Zaniah::Bounds.new(0, project_height, sidebar, bounds.height - 26 - project_height)) if left_panels
56
+ project_height = if @workspace.show_project
57
+ left_panels.empty? ? bounds.height - 26 : (bounds.height - 26) * 0.6
58
+ else 0
59
+ end
60
+ paint_project(Zaniah::Bounds.new(0, 0, sidebar, project_height)) if project_height.positive?
61
+ paint_panels(:left, Zaniah::Bounds.new(0, project_height, sidebar, bounds.height - 26 - project_height)) unless left_panels.empty?
55
62
  end
56
63
  paint_layout(@workspace.layout, body)
57
64
  if bottom.positive?
@@ -164,27 +171,34 @@ module Canopus
164
171
  end
165
172
  def paint_panels(side, bounds)
166
173
  fill(bounds, :panel)
167
- panels = @workspace.panels.select { |_, (position, _)| position == side }.to_a
174
+ panels = drawable_panels(side)
168
175
  return if panels.empty?
169
- panels.each_with_index do |(name, (_, render)), index|
176
+ panels.each_with_index do |definition, index|
170
177
  height = bounds.height / panels.length
171
178
  area = Zaniah::Bounds.new(bounds.x + 8, bounds.y + height * index, [bounds.width - 16, 0].max, height)
172
- text(name, area.x + 4, area.y + 10, color: :muted, size: 12)
173
- key = [name, @workspace.editor&.buffer&.object_id, @workspace.editor&.buffer&.version, @theme.object_id]
179
+ text(definition.title, area.x + 4, area.y + 10, color: :muted, size: 12)
180
+ if definition.badge
181
+ badge = Zaniah::Text.new(ui_text(definition.badge), size: 11, color: @theme[:accent])
182
+ .w(36).h(22).test_id("syrma:panel:#{instrumentation_component(definition.id)}:badge")
183
+ paint_element(badge, Zaniah::Bounds.new(area.right - 40, area.y + 5, 36, 22))
184
+ end
185
+ key = [definition.id, @workspace.editor&.buffer&.object_id, @workspace.editor&.buffer&.version, @theme.object_id]
174
186
  @panel_cache ||= {}
175
187
  @panel_cache.clear if @panel_cache.length > 50
176
- element = @panel_cache[key] ||= render.call
177
- element = Zaniah::Text.new(element.to_s, color: @theme[:foreground]) unless element.is_a?(Zaniah::Element)
178
- root = element.request_layout(@cx)
179
- Zaniah::Layout::Engine.new.compute(root, x: area.x, y: area.y + 34, width: area.width, height: [area.height - 34, 0].max)
188
+ element = @panel_cache[key] ||= definition.build.call
189
+ element = Zaniah::Text.new(ui_text(element), color: @theme[:foreground]) unless element.is_a?(Zaniah::Element)
190
+ panel = Zaniah::Div.new.w(area.width).h([area.height - 34, 0].max)
191
+ .test_id("syrma:panel:#{instrumentation_component(definition.id)}").child(element)
180
192
  @scene.clip(area) do
181
- element.prepaint(root.bounds, nil, @cx)
182
- element.paint(root.bounds, nil, nil, @cx)
193
+ paint_element(panel, Zaniah::Bounds.new(area.x, area.y + 34, area.width, [area.height - 34, 0].max))
183
194
  end
184
195
  rescue StandardError => error
185
196
  text(error.message, area.x, area.y + 34, color: :error, size: 12)
186
197
  end
187
198
  end
199
+ def drawable_panels(side)
200
+ @workspace.panels.active(side).reject { |definition| %w[terminal explorer search].include?(definition.id) }
201
+ end
188
202
  def paint_layout(node, bounds)
189
203
  return paint_pane(node[:pane], bounds) if node[:pane]
190
204
  horizontal = node[:direction] == :horizontal
@@ -244,7 +258,6 @@ module Canopus
244
258
  editor.viewport_rows = [(bounds.height / @line_height).floor, 1].max
245
259
  map, first = editor.display_map, editor.scroll_y.floor
246
260
  last = [first + editor.viewport_rows + 1, map.row_count].min
247
- marks = @workspace.git_gutter_marks(editor.buffer)
248
261
  cursor_offset = @workspace.settings["vim_mode"] && active ? @workspace.vim.cursor_position : editor.primary.head
249
262
  cursor = map.to_display(cursor_offset)
250
263
  if map.wrap_map.width && !editor.buffer.read_only
@@ -262,6 +275,16 @@ module Canopus
262
275
  last = [first + editor.viewport_rows + 1, map.row_count].min
263
276
  cursor = map.to_display(cursor_offset)
264
277
  end
278
+ decorations = decorations_for_display_rows(editor, map, first, last)
279
+ if map.set_overlays(decorations.select { |item| %i[inline block].include?(item.kind) },
280
+ font: @cx.text_system&.font, font_size: @font_size, line_height: @line_height)
281
+ last = [first + editor.viewport_rows + 1, map.row_count].min
282
+ cursor = map.to_display(cursor_offset)
283
+ decorations = decorations_for_display_rows(editor, map, first, last)
284
+ end
285
+ gutter_items = decorations.select { |item| item.kind == :gutter }
286
+ highlight_items = decorations.select { |item| item.kind == :highlight }
287
+ line_items = decorations.select { |item| item.kind == :line }
265
288
  cursor_row = map.row(cursor.row)
266
289
  cursor_line = @cx.text_system&.layout_line(cursor_row.text, size: @font_size)
267
290
  brackets = []
@@ -286,35 +309,43 @@ module Canopus
286
309
  (first...last).each do |index|
287
310
  row = map.row(index)
288
311
  y = bounds.y + (index - editor.scroll_y) * @line_height + 4
289
- fill(Zaniah::Bounds.new(bounds.x, y - 2, bounds.width, @line_height), :current_line) if cursor.row == index
290
312
  source_row = map.source_row(index)
291
- change = marks.find do |mark|
292
- first = [mark.new_line, 1].max
293
- mark.kind == :removed ? source_row + 1 == first : (source_row + 1).between?(first, first + mark.count - 1)
294
- end
295
- if change
296
- color = change.kind == :removed ? @theme[:error] : change.kind == :added ? "#80b987" : @theme[:accent]
297
- fill(Zaniah::Bounds.new(bounds.x + 2, y - 1, 3, @line_height), color)
298
- region(Zaniah::Bounds.new(bounds.x, y - 1, 10, @line_height), role: :button, label: "Toggle Git hunk", action: [:git_hunk, editor, source_row]) if row.kind == :text
313
+ fill(Zaniah::Bounds.new(bounds.x, y - 2, bounds.width, @line_height), :current_line) if cursor.row == index
314
+ paint_line_decorations(editor, source_row, y, bounds, line_items)
315
+ gutter_items.each do |item|
316
+ style = item.style.is_a?(Hash) ? item.style : {color: item.style, rows: 1}
317
+ next unless source_row.between?(item.row, item.row + style.fetch(:rows, 1) - 1)
318
+
319
+ marker = Zaniah::Bounds.new(bounds.x + 2, y - 1, 3, @line_height)
320
+ paint_instrumented_fill(marker, style.fetch(:color, :accent),
321
+ "syrma:decoration:gutter:#{source_row}:#{instrumentation_component(item.source)}")
322
+ if row.kind == :text && item.on_click
323
+ region(Zaniah::Bounds.new(bounds.x, y - 1, 10, @line_height), role: :button,
324
+ label: item.content.to_s, action: [:decoration, item.on_click, editor, source_row])
325
+ end
299
326
  end
300
327
  number = editor.relative_line_numbers ? (source_row - editor.buffer.rope.point_at(editor.primary.head).row).abs : source_row + 1
301
328
  number = source_row + 1 if number.zero?
302
329
  text(number, bounds.x + 12, y, color: cursor.row == index ? :foreground : :muted, size: @font_size - 1) if row.kind == :text
303
- line = @cx.text_system&.layout_line(row.text, size: @font_size)
330
+ line, paint_overlays = prepare_inline_overlays(editor, row, left, y,
331
+ [bounds.width - gutter(editor), 1].max)
332
+ line ||= @cx.text_system&.layout_line(row.text, size: @font_size)
304
333
  measured_width = [measured_width, line&.width || row.text.length * @font_size * 0.6].max
305
334
  @row_layouts[[editor, index]] = line
306
- paint_selections(editor, index, row, line, left, y)
335
+ paint_highlights(editor, index, row, line, left, y, highlight_items)
307
336
  brackets.each do |point|
308
337
  next unless point.row == index
309
338
  x = column_x(row.text, line, point.column)
310
339
  width = [column_x(row.text, line, point.column + 1) - x, 4].max
311
340
  fill(Zaniah::Bounds.new(left + x, y + @line_height - 2, width, 2), :accent)
312
341
  end
313
- if row.kind == :text && line
342
+ if row.kind == :text && line && @cx.text_system
314
343
  spans = code_spans(editor, source_row, row)
315
344
  @cx.text_system.paint_line(@scene, line, x: left, y: y + line.ascent, color: @theme[:foreground], spans: spans)
316
345
  @cx.window.text_runs << [left, y, row.text, @theme[:foreground]]
317
- else
346
+ elsif row.kind == :overlay_block
347
+ paint_block_overlay(editor, row.metadata, left, y, [bounds.width - gutter(editor), 1].max) if row.metadata
348
+ elsif row.kind != :overlay_block_continuation
318
349
  color = if row.kind == :git_diff
319
350
  row.text.start_with?("+") ? "#80b987" : row.text.start_with?("-") ? :error : :muted
320
351
  else
@@ -323,6 +354,7 @@ module Canopus
323
354
  text(row.text, left, y, color: color)
324
355
  end
325
356
  paint_diagnostics(editor, index, row, line, left, y) if row.kind == :text
357
+ paint_overlays&.call
326
358
  if cursor.row == index && active
327
359
  x = left + column_x(row.text, line, cursor.column)
328
360
  width = @workspace.settings["vim_mode"] && @workspace.vim.mode != :insert ? @font_size * 0.6 : 1.5
@@ -338,9 +370,10 @@ module Canopus
338
370
  previous_width = @line_widths[editor]
339
371
  measured_width = [measured_width, previous_width.last].max if previous_width && previous_width.first == editor.buffer.version
340
372
  @line_widths[editor] = [editor.buffer.version, measured_width]
341
- paint_scrollbars(editor, bounds, measured_width, marks)
373
+ overview = @workspace.decorations.items_for(editor.buffer, 0...editor.buffer.line_count, context: editor)
374
+ paint_scrollbars(editor, bounds, measured_width, overview.select { |item| item.kind == :gutter })
342
375
  end
343
- def paint_scrollbars(editor, bounds, content_width, marks)
376
+ def paint_scrollbars(editor, bounds, content_width, gutter_items)
344
377
  track = Zaniah::Bounds.new(bounds.right - 9, bounds.y, 9, [bounds.height - 9, 0].max)
345
378
  total = editor.display_map.row_count
346
379
  maximum = [total - editor.viewport_rows, 0].max
@@ -348,8 +381,8 @@ module Canopus
348
381
  height = [24, track.height * editor.viewport_rows / total].max.clamp(0, track.height)
349
382
  thumb = Zaniah::Bounds.new(track.x + 2, track.y + (track.height - height) * editor.scroll_y / maximum, 5, height)
350
383
  fill(thumb, :muted)
351
- marks.first(500).each do |mark|
352
- y = track.y + track.height * [mark.new_line - 1, 0].max / [editor.buffer.line_count, 1].max
384
+ gutter_items.first(500).each do |item|
385
+ y = track.y + track.height * item.row / [editor.buffer.line_count, 1].max
353
386
  fill(Zaniah::Bounds.new(track.x, y, 3, 2), :accent)
354
387
  end
355
388
  frame_diagnostics(editor.buffer).first(500).each do |diagnostic|
@@ -372,6 +405,97 @@ module Canopus
372
405
  def column_x(value, line, column)
373
406
  line ? line.x_for_index(value[0, column].to_s.bytesize) : column * @font_size * 0.6
374
407
  end
408
+ def decorations_for_display_rows(editor, map, first, last)
409
+ rows = (first...last).flat_map do |index|
410
+ row = map.row(index)
411
+ ending = map.to_buffer(DisplayPoint.new(index, row.text.length))
412
+ [map.source_row(index), editor.buffer.rope.point_at(ending).row]
413
+ end
414
+ rows.sort.uniq.slice_when { |left, right| right != left + 1 }.flat_map do |group|
415
+ @workspace.decorations.items_for(editor.buffer, group.first...(group.last + 1), context: editor)
416
+ end.uniq.sort_by(&:priority).freeze
417
+ end
418
+ def prepare_inline_overlays(editor, row, left, y, width)
419
+ return [nil, nil] unless row.kind == :text && row.metadata.is_a?(Array) && !row.metadata.empty?
420
+
421
+ overlay = Zaniah::Text.new(row.text, size: @font_size, color: "#0000", wrap: :none,
422
+ line_height: @line_height).w(width)
423
+ row.metadata.each do |placement|
424
+ overlay.inline_overlay(offset: placement.offset,
425
+ element: decoration_element(editor, placement.item, placement.width, placement.height)
426
+ .test_id("syrma:decoration:inline:#{editor.buffer.rope.point_at(placement.item.range.begin).row}"),
427
+ align: placement.align)
428
+ end
429
+ node = overlay.request_layout(@cx)
430
+ Zaniah::Layout::Engine.new.compute(node, x: left, y: y, width: width, height: @line_height)
431
+ overlay.prepaint(node.bounds, nil, @cx)
432
+ row.metadata.zip(overlay.children).each do |placement, element|
433
+ register_overlay_region(editor, placement.item, element.layout_node.bounds)
434
+ end
435
+ @overlay_rows << overlay
436
+ child(overlay)
437
+ paragraph = overlay.instance_variable_get(:@paragraph)
438
+ painter = -> { overlay.children.each { |child| child.paint(child.layout_node.bounds, nil, nil, @cx) } }
439
+ [paragraph.lines.first.layout, painter]
440
+ end
441
+ def paint_block_overlay(editor, block, left, y, width)
442
+ overlay = Zaniah::Text.new(" ", size: @font_size, color: "#0000", wrap: :none,
443
+ line_height: @line_height).w(width)
444
+ overlay.block_overlay(line: 0, position: :above, height: block.height,
445
+ element: decoration_element(editor, block.item, width, block.height))
446
+ node = overlay.request_layout(@cx)
447
+ Zaniah::Layout::Engine.new.compute(node, x: left, y: y, width: width, height: block.height + @line_height)
448
+ overlay.prepaint(node.bounds, nil, @cx)
449
+ register_overlay_region(editor, block.item, overlay.children.first.layout_node.bounds)
450
+ overlay.children.each { |child| child.paint(child.layout_node.bounds, nil, nil, @cx) }
451
+ @overlay_rows << overlay
452
+ child(overlay)
453
+ end
454
+ def paint_line_decorations(editor, source_row, y, bounds, items)
455
+ items.each do |item|
456
+ row = item.row || (item.range && editor.buffer.rope.point_at(item.range.begin).row)
457
+ next unless row == source_row
458
+
459
+ style = item.style.is_a?(Hash) ? item.style : {color: item.style}
460
+ area = Zaniah::Bounds.new(bounds.x + gutter(editor), y - 2,
461
+ [bounds.width - gutter(editor), 1].max, @line_height)
462
+ paint_instrumented_fill(area, style.fetch(:color, :current_line),
463
+ "syrma:decoration:line:#{source_row}:#{instrumentation_component(item.source)}")
464
+ end
465
+ end
466
+ def paint_instrumented_fill(bounds, color, test_id)
467
+ color = @theme[color] if color.is_a?(Symbol)
468
+ paint_element(Zaniah::Div.new.w(bounds.width).h(bounds.height).bg(color).test_id(test_id), bounds)
469
+ end
470
+ def paint_element(element, bounds)
471
+ child(element)
472
+ root = element.request_layout(@cx)
473
+ Zaniah::Layout::Engine.new.compute(root, x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height)
474
+ element.prepaint(root.bounds, nil, @cx)
475
+ element.paint(root.bounds, nil, nil, @cx)
476
+ element
477
+ end
478
+ def instrumentation_component(value) = value.to_s
479
+ def ui_text(value) = value.to_s.encode(Encoding::UTF_8)
480
+ def decoration_element(editor, item, width, height)
481
+ style = item.style.is_a?(Hash) ? item.style : {}
482
+ color = style.fetch(:color, item.style.is_a?(Symbol) ? item.style : :muted)
483
+ color = @theme[color] if color.is_a?(Symbol)
484
+ content = if item.content.respond_to?(:request_layout) && item.content.respond_to?(:paint)
485
+ item.content
486
+ else
487
+ Zaniah::Text.new(ui_text(item.content), size: [@font_size - 2, 1].max, color: color)
488
+ end
489
+ element = Zaniah::Div.new.w(width).h(height).items_center.child(content)
490
+ element.bg(style[:background]) if style[:background]
491
+ element
492
+ end
493
+ def register_overlay_region(editor, item, bounds)
494
+ return unless item.on_click
495
+ offset = item.range&.begin || editor.buffer.rope.line_start(item.row)
496
+ region(bounds, role: :button, label: item.content.to_s,
497
+ action: [:decoration, item.on_click, editor, offset])
498
+ end
375
499
  def code_spans(editor, source_row, row)
376
500
  return [] if editor.buffer.rope.respond_to?(:lazy?) && editor.buffer.rope.lazy?
377
501
  raw = editor.language_document.tokens_for(source_row)
@@ -425,24 +549,25 @@ module Canopus
425
549
  def frame_diagnostics(buffer)
426
550
  @frame_diagnostics[buffer] ||= @workspace.diagnostics_for(buffer)
427
551
  end
428
- def paint_selections(editor, index, row, line, left, y)
429
- editor.selections.each do |selection|
430
- next if selection.empty?
552
+ def paint_highlights(editor, index, row, line, left, y, items)
553
+ items.each do |item|
554
+ range = item.range
555
+ next unless range
431
556
  if editor.buffer.rope.respond_to?(:lazy?)
432
557
  base = editor.buffer.rope.line_start(index)
433
- first = selection.start - base
434
- last = selection.end - base
558
+ first = range.begin - base
559
+ last = range.end - base
435
560
  next if last < row.offsets.first || first > row.offsets.last
436
561
  from = row.offsets.bsearch_index { |offset| offset >= first } || row.text.length
437
562
  to = row.offsets.bsearch_index { |offset| offset >= last } || row.text.length
438
563
  x = column_x(row.text, line, from)
439
- fill(Zaniah::Bounds.new(left + x, y - 1, column_x(row.text, line, to) - x, @line_height), :selection)
564
+ fill(Zaniah::Bounds.new(left + x, y - 1, column_x(row.text, line, to) - x, @line_height), item.style)
440
565
  next
441
566
  end
442
- span = selection_span(editor, selection, index)
567
+ span = highlight_span(editor, item, index)
443
568
  next unless span
444
- before = selection_span(editor, selection, index - 1)
445
- after = selection_span(editor, selection, index + 1)
569
+ before = highlight_span(editor, item, index - 1)
570
+ after = highlight_span(editor, item, index + 1)
446
571
  x, right = span
447
572
  # Only exposed convex corners are rounded; no alpha-overlapping bridge
448
573
  # and no convex hull that would highlight unselected source text.
@@ -450,11 +575,12 @@ module Canopus
450
575
  before && right > before[0] && right <= before[1] ? 0 : 3,
451
576
  after && right > after[0] && right <= after[1] ? 0 : 3,
452
577
  after && x >= after[0] && x < after[1] ? 0 : 3]
453
- @scene.quad(left + x, y - 1, right - x, @line_height, color: @theme[:selection], radius: radii)
578
+ color = item.style.is_a?(Symbol) ? @theme[item.style] : item.style
579
+ @scene.quad(left + x, y - 1, right - x, @line_height, color: color, radius: radii)
454
580
  end
455
581
  end
456
- def selection_span(editor, selection, index)
457
- cache = @selection_spans[selection] ||= {first: editor.display_map.to_display(selection.start), last: editor.display_map.to_display(selection.end)}
582
+ def highlight_span(editor, item, index)
583
+ cache = @selection_spans[item] ||= {first: editor.display_map.to_display(item.range.begin), last: editor.display_map.to_display(item.range.end)}
458
584
  return cache[index] if cache.key?(index)
459
585
  first, last = cache.values_at(:first, :last)
460
586
  return unless index.between?(first.row, last.row)
@@ -587,8 +713,7 @@ module Canopus
587
713
  end
588
714
  def shortcut_labels
589
715
  return {} unless @keymap
590
- context = {"Editor" => !!@workspace.editor,
591
- "vim_mode" => @workspace.settings["vim_mode"] && @workspace.editor ? @workspace.vim.mode.to_s : false}
716
+ context = @workspace.palette&.dig(:command_context) || @workspace.command_context
592
717
  seen, labels = {}, {}
593
718
  @keymap.bindings.reverse_each do |binding|
594
719
  next unless binding.predicate.call(context)
@@ -627,7 +752,8 @@ module Canopus
627
752
  fill(row, :active_tab) if index == palette[:index]
628
753
  @scene.clip(row) { text(match, row.x + 10, row.y + 5, size: 13) }
629
754
  if palette[:kind] == :commands
630
- shortcut = shortcuts[match]
755
+ command_index = palette[:indices] ? palette[:indices][index] : index
756
+ shortcut = shortcuts[palette[:command_definitions][command_index].id]
631
757
  if shortcut
632
758
  label = shortcut.tr("-", " ")
633
759
  x = row.right - label.length * 6.5 - 10
@@ -8,13 +8,16 @@ require_relative "vim"
8
8
  require_relative "lsp"
9
9
  require_relative "pane"
10
10
  require_relative "project/tree"
11
+ require_relative "command"
12
+ require_relative "panel"
13
+ require_relative "decoration"
11
14
 
12
15
  module Canopus
13
16
  class Workspace
14
17
  ClosedTab = Data.define(:path, :selections, :scroll_x, :scroll_y, :pane_id, :index)
15
- attr_reader :panes, :active_pane, :buffers, :actions, :settings, :theme, :project, :clients, :root, :docks, :panels
18
+ attr_reader :panes, :active_pane, :buffers, :actions, :commands, :settings, :theme, :project, :clients, :root, :docks, :panels, :decorations
16
19
  attr_reader :terminals, :active_terminal_index
17
- attr_accessor :window, :show_project, :terminal_visible, :terminal_composition, :selected_project_path, :performance
20
+ attr_accessor :window, :terminal_composition, :selected_project_path, :performance
18
21
  attr_reader :message, :palette
19
22
 
20
23
  def initialize(root: Dir.pwd, settings: nil)
@@ -24,18 +27,47 @@ module Canopus
24
27
  @active_pane = @panes.first
25
28
  @layout = {pane: @active_pane}
26
29
  @theme = Theme.new(name: @settings["theme"])
27
- @actions, @clients, @vim_states = Zaniah::Input::ActionRegistry.new, {}, {}
28
- @show_project, @message = true, ""
30
+ @actions = @commands = Command::Registry.new
31
+ @clients, @vim_states = {}, {}
32
+ @message = ""
29
33
  @project = Project.new(@root) if defined?(Project)
30
- bottom = @settings["dock"]["bottom"]
31
- @docks = {left: {visible: true, size: 220}, right: {visible: false, size: 260},
32
- bottom: {visible: bottom["visible"], size: bottom["size"]}}
33
- @panels, @languages, @terminals = {}, {}, []
34
+ dock = @settings["dock"]
35
+ @docks = %i[left right bottom].to_h do |side|
36
+ value = dock.fetch(side.to_s)
37
+ [side, {visible: value["visible"], size: value["size"]}]
38
+ end
39
+ @panels = Panel::Registry.new(@docks)
40
+ @panels.restore(dock.fetch("panels"))
41
+ @panels.register(Panel::Definition.new("terminal", "Terminal", nil, :bottom, -> { terminal }, nil))
42
+ @panels.register(Panel::Definition.new("search", "Search", nil, :left, -> { palette_open(:project_search) }, nil))
43
+ @panels.register(Panel::Definition.new("explorer", "Explorer", nil, :left, -> { project_tree }, nil))
44
+ @decorations = Decoration::Registry.new
45
+ @decorations.register(:selection_match) do |buffer, rows, current|
46
+ selections = current.is_a?(Editor) && current.buffer.equal?(buffer) ? current.selections : buffer.selections
47
+ selections.filter_map do |selection|
48
+ next if selection.empty?
49
+ first = buffer.rope.point_at(selection.start).row
50
+ last = buffer.rope.point_at(selection.end).row
51
+ next if last < rows.begin || first >= rows.end
52
+
53
+ Decoration::Item.new(:highlight, selection.range, nil, nil, :selection, 100, :selection_match, nil)
54
+ end
55
+ end
56
+ @decorations.register(:git) { |buffer, rows| git_decorations(buffer, rows) }
57
+ @languages, @terminals = {}, []
34
58
  @active_terminal_index = 0
35
59
  @closed_tabs, @terminal_names = [], {}
36
60
  register_actions
37
61
  end
38
62
  def editor = @active_pane.active
63
+ def show_project = @panels.visible?("explorer")
64
+ def show_project=(visible)
65
+ visible ? @panels.show("explorer") : @panels.hide("explorer")
66
+ end
67
+ def terminal_visible = @panels.visible?("terminal") && @docks[:bottom][:visible]
68
+ def terminal_visible=(visible)
69
+ visible ? @panels.show("terminal") : @panels.hide("terminal")
70
+ end
39
71
  def terminal = @terminals[@active_terminal_index]
40
72
  def terminal=(value)
41
73
  @terminals.each { |item| item.close if !item.equal?(value) && item.respond_to?(:close) }
@@ -306,7 +338,7 @@ module Canopus
306
338
  queue_limit_bytes: options["queue_limit_bytes"])
307
339
  @terminals << created
308
340
  @active_terminal_index = @terminals.length - 1
309
- @terminal_visible = true
341
+ self.terminal_visible = true
310
342
  resize_terminal(*@terminal_dimensions, final: true) if @terminal_dimensions
311
343
  @window&.request_frame
312
344
  created
@@ -352,7 +384,7 @@ module Canopus
352
384
  else
353
385
  @terminals.index(active) || 0
354
386
  end
355
- @terminal_visible = false if @terminals.empty? && @settings["terminal"]["hide_when_empty"]
387
+ self.terminal_visible = false if @terminals.empty? && @settings["terminal"]["hide_when_empty"]
356
388
  @window&.request_frame
357
389
  current
358
390
  end
@@ -432,11 +464,13 @@ module Canopus
432
464
  dock[:visible] = !dock[:visible]
433
465
  end
434
466
  def register_panel(name, side: :left, &render)
435
- raise ArgumentError, "invalid dock side" unless @docks.key?(side)
436
- @panels[name] = [side, render]
437
- register_action("panel.#{name}") { @docks[side][:visible] = true }
467
+ definition = @panels.register(Panel::Definition.new(name, name.to_s, nil, side, render, nil))
468
+ register_action("panel.#{name}") { @panels.show(definition.id) }
469
+ definition
470
+ end
471
+ def register_action(name, description: name, category: name.to_s.split(".", 2).first, condition: "", keybinding: Command::DEFAULT_KEYBINDINGS[name.to_s], &block)
472
+ @commands.register(Command::Definition.new(name.to_s, description, category, condition, block, keybinding))
438
473
  end
439
- def register_action(name, description: name, &block) = @actions.register(name, description: description, &block)
440
474
  def definition_for(path)
441
475
  @languages.values.find { |definition| definition.extensions.include?(File.extname(path.to_s)) || definition.extensions.include?(File.basename(path.to_s)) } || Language.for_path(path)
442
476
  end
@@ -450,20 +484,32 @@ module Canopus
450
484
  end
451
485
  end
452
486
  end
453
- def call(name, *args)
454
- @actions.call(name, *args)
487
+ def command_context(terminal: false)
488
+ {"Terminal" => terminal, "Editor" => !!editor,
489
+ "vim_mode" => @settings["vim_mode"] && editor ? vim.mode.to_s : false}
490
+ end
491
+ def call(name, *args, context: nil)
492
+ context ||= @active_command_context || command_context
493
+ previous, @active_command_context = @active_command_context, context
494
+ @commands.call(name, *args, context: context)
455
495
  @window&.request_frame
456
496
  rescue StandardError => error
457
497
  @message = error.message
458
498
  @window&.request_frame
499
+ ensure
500
+ @active_command_context = previous
459
501
  end
460
502
 
461
503
  def theme=(theme)
462
504
  @theme = theme.is_a?(Theme) ? theme : Theme.new(name: theme)
463
505
  @window&.request_frame
464
506
  end
465
- def palette_open(kind)
507
+ def palette_open(kind, command_ids: nil, context: nil)
466
508
  self.palette = {kind: kind, query: +"", index: 0, matches: []}
509
+ if kind == :commands
510
+ @palette[:command_context] = context || @active_command_context || command_context
511
+ @palette[:command_ids] = command_ids if command_ids
512
+ end
467
513
  if [:search, :replace_query, :project_search].include?(kind)
468
514
  @palette[:search_options] = {regexp: false, case_sensitive: true, whole_word: false, selection_only: false}
469
515
  @palette[:selection] = editor.primary.range unless editor.primary.empty?
@@ -478,6 +524,20 @@ module Canopus
478
524
  end
479
525
  def update_palette
480
526
  return unless @palette
527
+ if @palette[:kind] == :commands
528
+ context = @palette.fetch(:command_context)
529
+ definitions = @commands.each(context: context).to_a
530
+ ids = @palette[:command_ids]
531
+ definitions = definitions.select { |definition| ids.include?(definition.id) } if ids
532
+ @palette[:command_definitions] = definitions
533
+ session = @palette[:search] ||= Spica::Index.new(definitions.map(&:title)).session
534
+ session.query = @palette[:query]
535
+ matches = session.matches(12)
536
+ @palette[:indices] = matches.map(&:index)
537
+ @palette[:matches] = matches.map(&:candidate)
538
+ @palette[:index] = @palette[:index].clamp(0, [@palette[:matches].length - 1, 0].max)
539
+ return
540
+ end
481
541
  if [:completion, :locations, :symbols, :code_actions, :outline, :branches, :settings_keys, :snippet_choices].include?(@palette[:kind])
482
542
  labels = @palette[:all_matches] ||= @palette[:matches].dup
483
543
  session = @palette[:search] ||= Spica::Index.new(labels).session
@@ -489,7 +549,6 @@ module Canopus
489
549
  return
490
550
  end
491
551
  @palette[:search] ||= case @palette[:kind]
492
- when :commands then Spica::Index.new(@actions.entries.keys).session
493
552
  when :files
494
553
  @finder_index ||= Spica::Index.new(((@recent_files || []).select { |path| File.file?(path) }.map { |path| path.delete_prefix(@root + File::SEPARATOR) } + files).uniq, tie_break: :index)
495
554
  @finder_index.session
@@ -525,12 +584,17 @@ module Canopus
525
584
  return
526
585
  end
527
586
  selected = @palette[:matches][@palette[:index]]
587
+ selected_command = if @palette[:kind] == :commands && selected
588
+ index = @palette[:indices] ? @palette[:indices][@palette[:index]] : @palette[:index]
589
+ @palette[:command_definitions][index]
590
+ end
591
+ command_context = @palette[:command_context]
528
592
  kind, query, pattern = @palette.values_at(:kind, :query, :pattern)
529
593
  self.palette = nil
530
594
  if kind == :files && selected
531
595
  open(selected)
532
- elsif kind == :commands && selected
533
- call(selected)
596
+ elsif kind == :commands && selected_command
597
+ call(selected_command.id, context: command_context)
534
598
  elsif kind == :search
535
599
  pattern, options = search_query(query, search_state)
536
600
  matches = editor.search(pattern, **options)
@@ -668,12 +732,15 @@ module Canopus
668
732
  register_action("edit.indent") { editor.indent }
669
733
  register_action("edit.outdent") { editor.indent(outdent: true) }
670
734
  register_action("search.buffer") { palette_open(:search) }
671
- register_action("search.project") { palette_open(:project_search) }
735
+ register_action("search.project") { @panels.fetch("search").build.call }
672
736
  register_action("search.replace") { palette_open(:replace_query) }
673
737
  register_action("settings.open") { open_settings }
674
738
  register_action("settings.complete") { settings_completions }
675
739
  register_action("language.diagnostics") { show_diagnostics }
676
- register_action("view.project") { @show_project = !@show_project }
740
+ register_action("view.project") { @panels.toggle("explorer") }
741
+ register_action("panel.explorer") { @panels.toggle("explorer") }
742
+ register_action("panel.search") { @panels.fetch("search").build.call }
743
+ register_action("panel.terminal") { @terminals.empty? ? new_terminal : @panels.toggle("terminal") }
677
744
  [:left, :right, :bottom].each { |side| register_action("view.dock_#{side}") { toggle_dock(side) } }
678
745
  register_action("view.wrap") { editor.display_map.wrap_width = editor.display_map.wrap_map.width ? nil : 100 }
679
746
  register_action("view.theme") { self.theme = @theme.name.include?("Dark") ? "Canopus Light" : "Canopus Dark" }
@@ -682,7 +749,7 @@ module Canopus
682
749
  clear_vim_states unless @settings["vim_mode"]
683
750
  end
684
751
  register_action("view.terminal") do
685
- @terminals.empty? ? new_terminal : @terminal_visible = !@terminal_visible
752
+ @terminals.empty? ? new_terminal : @panels.toggle("terminal")
686
753
  end
687
754
  register_action("terminal.toggle") { call("view.terminal") }
688
755
  register_action("terminal.new") { new_terminal }
@@ -23,7 +23,9 @@ module Canopus
23
23
  end
24
24
  end
25
25
  end
26
- def transform(text, offsets, checkpoint: nil)
26
+ def transform(text, offsets, checkpoint: nil, overlays: [], tab_map: nil)
27
+ text, offsets, overlays = tab_map.transform(text, offsets, checkpoint: checkpoint, overlays: overlays) if tab_map
28
+ return overlay_rows(text, offsets, overlays, checkpoint) unless overlays.empty?
27
29
  return pixel_rows(text, offsets, checkpoint) if @width && @font
28
30
  return [[text.freeze, offsets.freeze]] if !@width || (text.ascii_only? && text.length <= @width)
29
31
  rows, offset, chunk, positions, cells = [], 0, +"", [offsets.first], 0
@@ -53,6 +55,30 @@ module Canopus
53
55
  end
54
56
 
55
57
  private
58
+ def overlay_rows(text, offsets, overlays, checkpoint)
59
+ checkpoint&.call
60
+ typesetter = @font ? (@typesetters[Thread.current] ||= @layout_template.fork(capacity: 32)) : nil
61
+ scale = @font ? 1.0 : 0.6
62
+ width = @width ? @width * scale : Float::INFINITY
63
+ inline = overlays.map do |overlay|
64
+ Zaniah::TextSystem::Paragraph::InlineOverlay.new(overlay.object_id, overlay.offset,
65
+ (@font ? overlay.width : overlay.cell_width * scale), overlay.height, overlay.align)
66
+ end
67
+ paragraph = Zaniah::TextSystem::Paragraph.new(text, width: width,
68
+ size: @font ? @font_size : 1, line_height: @font ? nil : 1,
69
+ wrap: @width ? :anywhere : :none, typesetter: typesetter, inline_overlays: inline)
70
+ by_key = overlays.to_h { |overlay| [overlay.object_id, overlay] }
71
+ paragraph.lines.each_with_index.map do |line, index|
72
+ first = text.byteslice(0...line.start).to_s.length
73
+ last = first + line.layout.text.length
74
+ placements = paragraph.inline_placements.filter_map do |placement|
75
+ next unless placement.line == index
76
+ by_key.fetch(placement.key).with(offset: placement.offset - line.start)
77
+ end.freeze
78
+ [line.layout.text.freeze, offsets[first..last].freeze, placements]
79
+ end
80
+ end
81
+
56
82
  def pixel_rows(text, offsets, checkpoint)
57
83
  # The template is never used for shaping. Each owner copies its complete
58
84
  # typography without reading the rendering thread's mutable caches.
data/lib/canopus.rb CHANGED
@@ -11,6 +11,9 @@ require "denebola"
11
11
  require "zaniah"
12
12
  require "porrima"
13
13
  require "thuban"
14
+ require_relative "canopus/command"
15
+ require_relative "canopus/panel"
16
+ require_relative "canopus/decoration"
14
17
  require_relative "canopus/patch"
15
18
  require_relative "canopus/buffer"
16
19
  require_relative "canopus/multi_buffer"