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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7542ff6c8bf489ddfdb978f292d32778cc450b57d64035d94ef67797901b1545
4
- data.tar.gz: 2a7f330905efe963d94d303c953f4d1b4ae8da51a0b409720b6c1cfbccf837a6
3
+ metadata.gz: 64a5ffb35c2d8fdfbc91881be747cdf0882548d2e924aa04846f333e23a73487
4
+ data.tar.gz: 17a825bf14afce060db1589fc6cb13986e0e7857a1d28622b99f1125d83de484
5
5
  SHA512:
6
- metadata.gz: d62037a03bb3753cddd8551d88d33f59760500f6c9680b2ae4cb284b26a11de43c1c17ea9f9e437b890e722494b83702f7666d61df76d99045d6242e8cab852e
7
- data.tar.gz: ec60287bf355093ace58c7d24fb0805e37a207d457bbce3eae09087d03bdaa4748742d37d4e98570095bb52f55ffa01c1b78aecb600ae4699966d8101f383471
6
+ metadata.gz: c499a3d6b07424ef0d696df9ae842eabc81ef6265aa251b3e32f22c20c1ab70be30e1a9df56351cbd182f7d11bc05e536726429e53a1adc57ce2a5729df37043
7
+ data.tar.gz: d1212a4befda0c26dee61f60de0a74c4a1d729b37806eccf99198d52045dbbdd6d43c493eaf33ebf0635892d9eae9b6773dd349e9bc40d031d5863a36b949b30
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 — 2026-09-15
4
+
5
+ - Route commands and keybindings through one context-aware command registry.
6
+ - Register the explorer, project search, terminal, and plugin panels through one stateful panel registry.
7
+ - Supply Git gutter marks and selection highlights through the decoration registry.
8
+ - Include inline and block decorations in wrapping, display coordinates, hit testing, and generic rendering.
9
+ - Expose rendered panels, badges, and decorations through stable UI test identifiers.
10
+
3
11
  ## 0.3.0 — 2026-09-14
4
12
 
5
13
  - Use the public `Thuban::IgnoreMatcher` and remove Canopus's duplicate implementation.
data/README.md CHANGED
@@ -39,6 +39,8 @@ installable.
39
39
  - Editable project-wide search results and safe project file operations
40
40
  - Optional Vim-compatible modes, motions, operators, registers, macros, and Ex commands
41
41
  - Layered JSONC settings, keymaps, themes, snippets, and trusted Ruby plugins
42
+ - Overlay-aware wrapping and coordinates for inline hints and block decorations
43
+ - Rendered panel and decoration nodes expose stable `test_id` conventions for UI tests
42
44
 
43
45
  ## Installation
44
46
 
@@ -109,7 +111,10 @@ settings live at `$XDG_CONFIG_HOME/canopus/settings.jsonc` or
109
111
  "use_tabs": false,
110
112
  "tabs": { "activate_on_close": "history", "reopen_history_limit": 20 },
111
113
  "terminal": { "working_directory": "project", "scrollback_lines": 10000 },
112
- "dock": { "bottom": { "size": 280, "visible": false } },
114
+ "dock": {
115
+ "bottom": { "size": 280, "visible": false },
116
+ "panels": { "terminal": { "size": 280, "visible": false } }
117
+ },
113
118
  "keymap": [
114
119
  { "context": "Editor && !vim_mode", "bindings": { "ctrl-k ctrl-s": "file.save" } }
115
120
  ],
@@ -120,7 +125,8 @@ settings live at `$XDG_CONFIG_HOME/canopus/settings.jsonc` or
120
125
 
121
126
  Closing a dirty tab asks whether to save, discard, or cancel. Reopening restores
122
127
  the file, selections, position, and pane while Canopus is running; discarded
123
- unsaved changes are never restored. Terminal session restoration, when enabled,
128
+ unsaved changes are never restored. Dock and panel visibility and sizes are also
129
+ restored. Terminal session restoration, when enabled,
124
130
  starts fresh shells with the saved tab count and working directories and does
125
131
  not restore processes or scrollback.
126
132
 
@@ -0,0 +1,26 @@
1
+ # ADR 008: Keep one domain in each component repository
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-15
5
+
6
+ ## Context
7
+
8
+ New editor capabilities can live in Canopus, extend an existing component, or
9
+ become another independently released gem. Splitting by individual protocol
10
+ operation would create small packages that expose each other's internal models;
11
+ keeping every capability in Canopus would couple unrelated release cycles.
12
+
13
+ ## Decision
14
+
15
+ Keep one externally specified or independently testable domain in each
16
+ component repository. Extend an existing component when its internal model is
17
+ required, and keep product UX and cross-component state ownership in Canopus.
18
+ Canopus consumes components through released gems and does not copy their
19
+ sources into its repository.
20
+
21
+ ## Consequences
22
+
23
+ Component APIs and release order remain explicit, and each domain can be tested
24
+ headlessly. Coordinated changes require publishing the provider before updating
25
+ Canopus. Revisit a split when two components must expose private models to each
26
+ other or routinely require lockstep releases.
@@ -0,0 +1,26 @@
1
+ # ADR 009: Supply editor overlays through one decoration path
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-15
5
+
6
+ ## Context
7
+
8
+ Git marks and selections were painted directly by the editor view. Diagnostics,
9
+ inlay hints, code lenses, debugger state, and plugin overlays would each add
10
+ another provider-specific branch to that view. Letting providers draw directly
11
+ would also mix invalidation and data ownership with viewport rendering.
12
+
13
+ ## Decision
14
+
15
+ Providers register bounded suppliers with `Decoration::Registry`. The registry
16
+ owns source identity, ordering, caching, and invalidation. The view asks the
17
+ registry for the current row range and renders items by decoration kind; click
18
+ handlers return through the controller without embedding provider behavior in
19
+ the renderer.
20
+
21
+ ## Consequences
22
+
23
+ Future overlays share one rendering and invalidation boundary, while providers
24
+ stay independent of scene construction. The registry contract becomes a public
25
+ internal extension point, and display-coordinate-changing inline and block
26
+ items still require a separate overlay-aware display map.
@@ -0,0 +1,32 @@
1
+ # ADR 010: Apply overlays before display wrapping
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-15
5
+
6
+ ## Context
7
+
8
+ Inline hints occupy space without adding editable buffer bytes, and block
9
+ decorations add vertical space without adding source lines. Painting either
10
+ after display mapping makes wrapping, cursor placement, selections, and hit
11
+ testing disagree with what the user sees. Embedding provider-specific layout in
12
+ the workspace view would also bypass the decoration boundary from ADR 009.
13
+
14
+ ## Decision
15
+
16
+ Make `OverlayMap` a display-map stage and keep the stage order `fold → overlay
17
+ → wrap → tab → block`. The wrap stage accounts for inline width measured from
18
+ the active Alhena font, while the final tab projection preserves established
19
+ tab-stop output. Block height is represented by non-selectable display rows.
20
+ The generic view renders their content through Zaniah text overlays and routes
21
+ clicks through the existing decoration action.
22
+
23
+ Rendered panels, badges, inline overlays, gutter markers, and line highlights
24
+ also carry Syrma's public test identifiers. They remain real Zaniah nodes with
25
+ their painted bounds; Canopus does not depend on the test driver at runtime.
26
+
27
+ ## Consequences
28
+
29
+ Buffer offsets remain the only editable coordinates, so overlay content cannot
30
+ enter a selection. Changing one decoration rebuilds only its source row. A
31
+ block height is rounded up to whole editor rows; sub-row scrolling would require
32
+ a pixel-based vertical display coordinate model.
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module Command
5
+ Definition = Data.define(:id, :title, :category, :when, :run, :keybinding)
6
+
7
+ DEFAULT_KEYBINDINGS = {
8
+ "file.save" => {"cmd-s" => "", "ctrl-s" => ""}.freeze,
9
+ "file.new" => {"cmd-n" => "", "ctrl-n" => ""}.freeze,
10
+ "tab.close" => {"cmd-w" => "", "ctrl-w" => ""}.freeze,
11
+ "tab.close_all" => {"cmd-alt-w" => "", "ctrl-alt-w" => ""}.freeze,
12
+ "tab.reopen_closed" => {"cmd-shift-t" => "", "ctrl-shift-t" => ""}.freeze,
13
+ "file.find" => {"cmd-p" => "", "ctrl-p" => ""}.freeze,
14
+ "command.palette" => {"cmd-shift-p" => "", "ctrl-shift-p" => ""}.freeze,
15
+ "edit.undo" => {"cmd-z" => "", "ctrl-z" => ""}.freeze,
16
+ "edit.redo" => {"cmd-shift-z" => "", "ctrl-shift-z" => ""}.freeze,
17
+ "edit.select_all" => {"cmd-a" => "", "ctrl-a" => ""}.freeze,
18
+ "edit.select_next" => {"cmd-d" => "", "ctrl-d" => ""}.freeze,
19
+ "search.buffer" => {"cmd-f" => "", "ctrl-f" => ""}.freeze,
20
+ "edit.select_all_occurrences" => {"cmd-shift-l" => "", "ctrl-shift-l" => ""}.freeze,
21
+ "edit.move_line_up" => {"alt-up" => ""}.freeze,
22
+ "edit.move_line_down" => {"alt-down" => ""}.freeze,
23
+ "search.project" => {"cmd-shift-f" => "", "ctrl-shift-f" => ""}.freeze,
24
+ "search.replace" => {"cmd-alt-f" => "", "ctrl-h" => ""}.freeze,
25
+ "edit.toggle_comment" => {"cmd-/" => "", "ctrl-/" => ""}.freeze,
26
+ "view.project" => {"cmd-b" => "", "ctrl-b" => ""}.freeze,
27
+ "view.terminal" => {"ctrl-`" => ""}.freeze,
28
+ "pane.split_right" => {"cmd-\\" => "", "ctrl-\\" => ""}.freeze,
29
+ "language.completion" => {"ctrl-space" => ""}.freeze,
30
+ "language.definition" => {"f12" => ""}.freeze,
31
+ "language.references" => {"shift-f12" => ""}.freeze,
32
+ "language.rename" => {"f2" => ""}.freeze,
33
+ "language.codeAction" => {"alt-enter" => ""}.freeze,
34
+ "language.outline" => {"cmd-shift-o" => "", "ctrl-shift-o" => ""}.freeze,
35
+ "language.hover" => {"cmd-k" => "", "ctrl-k" => ""}.freeze,
36
+ "terminal.toggle" => {"ctrl-`" => "Terminal"}.freeze,
37
+ "terminal.new" => {"ctrl-shift-`" => ""}.freeze,
38
+ "terminal.close" => {"cmd-w" => "Terminal", "ctrl-w" => "Terminal"}.freeze,
39
+ "terminal.copy" => {"cmd-c" => "Terminal", "ctrl-shift-c" => "Terminal"}.freeze,
40
+ "terminal.paste" => {"cmd-v" => "Terminal", "ctrl-shift-v" => "Terminal"}.freeze,
41
+ "terminal.next" => {"ctrl-shift-]" => "Terminal"}.freeze,
42
+ "terminal.prev" => {"ctrl-shift-[" => "Terminal"}.freeze,
43
+ "terminal.clear" => {"cmd-k" => "Terminal"}.freeze,
44
+ **(1..9).to_h { |index| ["terminal.select_#{index}", {"cmd-#{index}" => "Terminal"}.freeze] }
45
+ }.freeze
46
+
47
+ class Registry
48
+ attr_reader :version
49
+
50
+ def initialize
51
+ @definitions, @predicates, @version = {}, {}, 0
52
+ end
53
+
54
+ def register(definition, description: definition.to_s, category: definition.to_s.split(".", 2).first,
55
+ condition: "", keybinding: nil, &run)
56
+ definition = Definition.new(definition.to_s, description, category, condition, run, keybinding) unless definition.is_a?(Definition)
57
+ definition = normalize(definition)
58
+ predicate = Zaniah::Input::ContextPredicate.new(definition.when)
59
+ @definitions[definition.id] = definition
60
+ @predicates[definition.id] = predicate
61
+ @version += 1
62
+ definition
63
+ end
64
+
65
+ def resolve(id, context: {})
66
+ id = id.to_s
67
+ definition = @definitions[id]
68
+ definition if definition && @predicates.fetch(id).call(context)
69
+ end
70
+
71
+ def call(id, *arguments, context: {})
72
+ definition = resolve(id, context: context)
73
+ raise KeyError, "command unavailable: #{id}" unless definition
74
+ definition.run.call(*arguments)
75
+ end
76
+
77
+ def each(context: nil)
78
+ return enum_for(__method__, context: context) unless block_given?
79
+ @definitions.each_value { |definition| yield definition if context.nil? || resolve(definition.id, context: context) }
80
+ self
81
+ end
82
+
83
+ def entries(context: nil) = each(context: context).to_h { |definition| [definition.id, definition.title] }.freeze
84
+
85
+ private
86
+
87
+ def normalize(definition)
88
+ raise ArgumentError, "command definition required" unless definition.is_a?(Definition)
89
+ id, title, category, condition, run, keybinding = definition.deconstruct
90
+ raise ArgumentError, "command id must not be empty" unless id.is_a?(String) && !id.empty?
91
+ raise ArgumentError, "command title must be a string" unless title.is_a?(String)
92
+ raise ArgumentError, "command category must be a string" unless category.is_a?(String)
93
+ raise ArgumentError, "command condition must be a string" unless condition.is_a?(String)
94
+ raise ArgumentError, "command runner must be callable" unless run.respond_to?(:call)
95
+ keybinding = normalize_keybinding(keybinding)
96
+ Definition.new(id.dup.freeze, title.dup.freeze, category.dup.freeze, condition.dup.freeze, run, keybinding)
97
+ end
98
+
99
+ def normalize_keybinding(keybinding)
100
+ return if keybinding.nil?
101
+ return keybinding.dup.freeze if keybinding.is_a?(String) && !keybinding.empty?
102
+ unless keybinding.is_a?(Hash) && keybinding.all? { |keys, condition| keys.is_a?(String) && !keys.empty? && condition.is_a?(String) }
103
+ raise ArgumentError, "command keybinding must be a string, binding map, or nil"
104
+ end
105
+ keybinding.to_h { |keys, condition| [keys.dup.freeze, condition.dup.freeze] }.freeze
106
+ end
107
+ end
108
+ end
109
+ end
@@ -4,29 +4,14 @@ require_relative "workspace/view"
4
4
 
5
5
  module Canopus
6
6
  class Controller
7
- SHORTCUTS = {"cmd-s" => "file.save", "ctrl-s" => "file.save", "cmd-n" => "file.new", "ctrl-n" => "file.new",
8
- "cmd-w" => "tab.close", "ctrl-w" => "tab.close", "cmd-alt-w" => "tab.close_all", "ctrl-alt-w" => "tab.close_all",
9
- "cmd-shift-t" => "tab.reopen_closed", "ctrl-shift-t" => "tab.reopen_closed", "cmd-p" => "file.find", "ctrl-p" => "file.find",
10
- "cmd-shift-p" => "command.palette", "ctrl-shift-p" => "command.palette", "cmd-z" => "edit.undo", "ctrl-z" => "edit.undo",
11
- "cmd-shift-z" => "edit.redo", "ctrl-shift-z" => "edit.redo", "cmd-a" => "edit.select_all", "ctrl-a" => "edit.select_all",
12
- "cmd-d" => "edit.select_next", "ctrl-d" => "edit.select_next", "cmd-f" => "search.buffer", "ctrl-f" => "search.buffer",
13
- "cmd-shift-l" => "edit.select_all_occurrences", "ctrl-shift-l" => "edit.select_all_occurrences",
14
- "alt-up" => "edit.move_line_up", "alt-down" => "edit.move_line_down",
15
- "cmd-shift-f" => "search.project", "ctrl-shift-f" => "search.project", "cmd-alt-f" => "search.replace", "ctrl-h" => "search.replace",
16
- "cmd-/" => "edit.toggle_comment", "ctrl-/" => "edit.toggle_comment", "cmd-b" => "view.project", "ctrl-b" => "view.project",
17
- "ctrl-`" => "view.terminal", "ctrl-shift-`" => "terminal.new", "cmd-\\" => "pane.split_right", "ctrl-\\" => "pane.split_right",
18
- "ctrl-space" => "language.completion", "f12" => "language.definition", "shift-f12" => "language.references",
19
- "f2" => "language.rename", "alt-enter" => "language.codeAction", "cmd-shift-o" => "language.outline",
20
- "ctrl-shift-o" => "language.outline", "cmd-k" => "language.hover", "ctrl-k" => "language.hover"}.freeze
21
- TERMINAL_SHORTCUTS = {"ctrl-`" => "terminal.toggle", "ctrl-shift-`" => "terminal.new", "cmd-w" => "terminal.close",
22
- "ctrl-w" => "terminal.close", "cmd-c" => "terminal.copy", "ctrl-shift-c" => "terminal.copy",
23
- "cmd-v" => "terminal.paste", "ctrl-shift-v" => "terminal.paste", "cmd-shift-p" => "command.palette",
24
- "ctrl-shift-p" => "command.palette", "ctrl-shift-]" => "terminal.next", "ctrl-shift-[" => "terminal.prev",
25
- "cmd-k" => "terminal.clear"}.merge((1..9).to_h { |index| ["cmd-#{index}", "terminal.select_#{index}"] }).freeze
26
7
  attr_reader :workspace, :view, :window, :keymap
27
8
  def initialize(workspace, window)
28
9
  @workspace, @window, @view = workspace, window, Workspace::View.new(workspace)
29
10
  workspace.window = window
11
+ workspace.register_action("terminal.copy", condition: "Terminal") { terminal_copy }
12
+ workspace.register_action("terminal.paste", condition: "Terminal") do
13
+ terminal_paste(@window.respond_to?(:clipboard) ? @window.clipboard.to_s : @clipboard)
14
+ end
30
15
  reload_keymap
31
16
  window.draw { workspace.drain; @view }
32
17
  window.on_input { |event| input(event) }
@@ -167,23 +152,15 @@ module Canopus
167
152
  return @workspace.restart_terminal
168
153
  end
169
154
  mode = @workspace.settings["vim_mode"] && @workspace.editor ? @workspace.vim.mode.to_s : false
170
- action = @keymap.dispatch(stroke, context: {"Terminal" => true, "Editor" => false, "vim_mode" => mode})
155
+ context = @workspace.command_context(terminal: true).merge("Editor" => false, "vim_mode" => mode)
156
+ action = @keymap.dispatch(stroke, context: context)
171
157
  if action && action != :pending
172
- if action == "terminal.paste"
173
- return terminal_paste(@window.respond_to?(:clipboard) ? @window.clipboard.to_s : @clipboard)
174
- elsif action == "terminal.copy"
175
- @clipboard = @view.terminal_selected_text
176
- @window.clipboard = @clipboard if @window.respond_to?(:clipboard=)
177
- return
178
- end
179
158
  @drag_terminal_tab = nil if action == "terminal.close"
180
- return @workspace.call(action)
159
+ return @workspace.call(action, context: context)
181
160
  elsif action == :pending
182
161
  return
183
162
  elsif ["cmd-c", "ctrl-shift-c"].include?(stroke)
184
- @clipboard = @view.terminal_selected_text
185
- @window.clipboard = @clipboard if @window.respond_to?(:clipboard=)
186
- return
163
+ return terminal_copy
187
164
  end
188
165
  parts = stroke.split("-")
189
166
  name = parts.pop
@@ -193,13 +170,14 @@ module Canopus
193
170
  end
194
171
  return @workspace.settings_completions if stroke == "ctrl-space" && @workspace.editor && @workspace.settings_document?(@workspace.editor.buffer)
195
172
  mode = @workspace.settings["vim_mode"] && @workspace.editor ? @workspace.vim.mode.to_s : false
196
- action = @keymap.dispatch(stroke, context: {"Editor" => !!@workspace.editor, "vim_mode" => mode})
173
+ context = @workspace.command_context.merge("vim_mode" => mode)
174
+ action = @keymap.dispatch(stroke, context: context)
197
175
  if action
198
176
  # Native adapters deliver text separately after printable key down.
199
177
  # Cocoa also sends an empty Composition before that ordinary commit.
200
178
  @suppress_key_text = stroke.match?(/\A(?:(?:alt|shift)-)*(?:space|[^\x00-\x1f\x7f])\z/)
201
179
  @drag_tab = nil if action.to_s.start_with?("tab.close") || action == "pane.close"
202
- return action == :pending ? nil : @workspace.call(action)
180
+ return action == :pending ? nil : @workspace.call(action, context: context)
203
181
  end
204
182
  if ["cmd-c", "ctrl-c", "cmd-x", "ctrl-x", "cmd-v", "ctrl-v"].include?(stroke) && !(@workspace.settings["vim_mode"] && stroke == "ctrl-v")
205
183
  return clipboard(stroke.split("-").last)
@@ -325,11 +303,16 @@ module Canopus
325
303
  def reload_keymap
326
304
  language = @workspace.editor&.language_document&.definition&.name
327
305
  groups = @workspace.settings["languages"].dig(language, "keymap") || @workspace.settings["keymap"]
328
- return if @keymap_groups.equal?(groups)
329
- unless @keymap_groups == groups
306
+ return if @keymap_groups.equal?(groups) && @command_version == @workspace.commands.version
307
+ unless @keymap_groups == groups && @command_version == @workspace.commands.version
330
308
  replacement = Zaniah::Input::Keymap.new
331
- SHORTCUTS.each { |keys, action| replacement.bind(keys, action) }
332
- TERMINAL_SHORTCUTS.each { |keys, action| replacement.bind(keys, action, context: "Terminal") }
309
+ @workspace.commands.each do |definition|
310
+ bindings = definition.keybinding.is_a?(String) ? {definition.keybinding => ""} : definition.keybinding || {}
311
+ bindings.each do |keys, binding_context|
312
+ condition = [definition.when, binding_context].reject(&:empty?).map { |value| "(#{value})" }.join(" && ")
313
+ replacement.bind(keys, definition.id, context: condition)
314
+ end
315
+ end
333
316
  groups.each do |group|
334
317
  group.fetch("bindings").each { |keys, action| replacement.bind(keys, action, context: group.fetch("context")) }
335
318
  end
@@ -337,6 +320,7 @@ module Canopus
337
320
  @view.keymap = replacement
338
321
  end
339
322
  @keymap_groups = groups
323
+ @command_version = @workspace.commands.version
340
324
  end
341
325
  def palette_input(event)
342
326
  return false unless @workspace.palette
@@ -445,12 +429,15 @@ module Canopus
445
429
  @workspace.selected_project_path = args.first
446
430
  @drag_file = [args.first, event.position] if event.button == :left
447
431
  if event.button == :right
448
- @workspace.palette = {kind: :commands, query: +"", index: 0, matches: %w[project.new_file project.new_folder project.rename project.trash]}
432
+ @workspace.palette_open(:commands, command_ids: %w[project.new_file project.new_folder project.rename project.trash],
433
+ context: @workspace.command_context)
449
434
  return
450
435
  end
451
436
  end
452
437
  @terminal_focus = [:terminal, :terminal_tab, :terminal_close].include?(kind)
453
438
  case kind
439
+ when :decoration
440
+ args[0].call(args[1], args[2]) if event.button == :left
454
441
  when :git_hunk then @workspace.toggle_git_hunk(args[0], row: args[1]) if event.button == :left
455
442
  when :hover_link
456
443
  return unless event.button == :left
@@ -567,6 +554,10 @@ module Canopus
567
554
  @workspace.terminal.paste(text)
568
555
  end
569
556
  end
557
+ def terminal_copy
558
+ @clipboard = @view.terminal_selected_text
559
+ @window.clipboard = @clipboard if @window.respond_to?(:clipboard=)
560
+ end
570
561
  def resize_drag(point)
571
562
  kind, target, bounds = @resize_drag
572
563
  if kind == :split_resize
@@ -578,7 +569,7 @@ module Canopus
578
569
  when :right then bounds.right - point.x
579
570
  when :bottom then bounds.bottom - 26 - point.y
580
571
  end
581
- @workspace.docks[target][:size] = size.clamp(80, target == :bottom ? bounds.height * 0.7 : bounds.width * 0.4)
572
+ @workspace.panels.resize(target, size.clamp(80, target == :bottom ? bounds.height * 0.7 : bounds.width * 0.4))
582
573
  end
583
574
  end
584
575
  end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module Decoration
5
+ KINDS = %i[inline block gutter highlight line].freeze
6
+ Item = Data.define(:kind, :range, :row, :content, :style, :priority, :source, :on_click)
7
+
8
+ class Registry
9
+ CACHE_LIMIT = 256
10
+
11
+ attr_reader :version
12
+
13
+ def initialize
14
+ @suppliers, @generations, @cache = {}, Hash.new(0), {}
15
+ @lock, @version = Mutex.new, 0
16
+ end
17
+
18
+ def register(source, &supplier)
19
+ raise ArgumentError, "decoration supplier required" unless supplier
20
+
21
+ source = normalize_source(source)
22
+ @lock.synchronize do
23
+ @suppliers[source] = supplier
24
+ invalidate_locked(source)
25
+ end
26
+ source
27
+ end
28
+
29
+ def unregister(source)
30
+ source = normalize_source(source)
31
+ @lock.synchronize do
32
+ supplier = @suppliers.delete(source)
33
+ invalidate_locked(source) if supplier
34
+ supplier
35
+ end
36
+ end
37
+
38
+ def items_for(buffer, row_range, context: nil)
39
+ first, last = normalize_rows(row_range)
40
+ state = [buffer.respond_to?(:version) ? buffer.version : nil,
41
+ context&.object_id,
42
+ context&.respond_to?(:selections) ? context.selections : buffer.respond_to?(:selections) ? buffer.selections : nil]
43
+ suppliers = @lock.synchronize do
44
+ @suppliers.map { |source, supplier| [source, supplier, @generations[source]] }
45
+ end
46
+ items = suppliers.flat_map do |source, supplier, generation|
47
+ key = [source, buffer.object_id, state, first, last]
48
+ cached = @lock.synchronize { @cache[key] }
49
+ next cached if cached
50
+
51
+ supplied = if supplier.arity == 2
52
+ supplier.call(buffer, first...last)
53
+ else
54
+ supplier.call(buffer, first...last, context)
55
+ end
56
+ raise TypeError, "decoration supplier must return an array" unless supplied.is_a?(Array)
57
+ supplied = supplied.map { |item| normalize_item(item, source) }.freeze
58
+ @lock.synchronize do
59
+ if @suppliers[source].equal?(supplier) && @generations[source] == generation
60
+ @cache.shift while @cache.length >= CACHE_LIMIT
61
+ @cache[key] = supplied
62
+ end
63
+ end
64
+ supplied
65
+ end
66
+ items.sort_by { |item| item.priority }.freeze
67
+ end
68
+
69
+ def invalidate(source, buffer: nil, rows: nil)
70
+ source = normalize_source(source)
71
+ row_bounds = normalize_rows(rows) if rows
72
+ @lock.synchronize do
73
+ @version += 1
74
+ @generations[source] += 1
75
+ if buffer || row_bounds
76
+ @cache.delete_if do |key, _|
77
+ same_source = key[0] == source
78
+ same_buffer = !buffer || key[1] == buffer.object_id
79
+ overlaps = !row_bounds || (key[3] < row_bounds[1] && row_bounds[0] < key[4])
80
+ same_source && same_buffer && overlaps
81
+ end
82
+ else
83
+ @cache.delete_if { |key, _| key[0] == source }
84
+ end
85
+ end
86
+ nil
87
+ end
88
+
89
+ private
90
+
91
+ def invalidate_locked(source)
92
+ @generations[source] += 1
93
+ @version += 1
94
+ @cache.delete_if { |key, _| key[0] == source }
95
+ end
96
+
97
+ def normalize_source(source)
98
+ valid = source.is_a?(Symbol) && !source.to_s.empty? && !source.to_s.include?(":") &&
99
+ !source.to_s.match?(/[\x00-\x1f\x7f]/)
100
+ raise ArgumentError, "invalid decoration source" unless valid
101
+
102
+ source
103
+ end
104
+
105
+ def normalize_rows(rows)
106
+ unless rows.is_a?(Range) && rows.begin.is_a?(Integer) && rows.end.is_a?(Integer) && rows.begin >= 0
107
+ raise ArgumentError, "decoration rows must be a nonnegative integer range"
108
+ end
109
+ last = rows.exclude_end? ? rows.end : rows.end + 1
110
+ raise ArgumentError, "decoration row range is reversed" if last < rows.begin
111
+
112
+ [rows.begin, last]
113
+ end
114
+
115
+ def normalize_item(item, source)
116
+ raise TypeError, "decoration supplier must return items" unless item.is_a?(Item)
117
+ raise ArgumentError, "invalid decoration kind" unless KINDS.include?(item.kind)
118
+ unless item.priority.is_a?(Numeric) && item.priority.finite?
119
+ raise ArgumentError, "decoration priority must be finite"
120
+ end
121
+ unless item.row.nil? || (item.row.is_a?(Integer) && item.row >= 0)
122
+ raise ArgumentError, "decoration row must be nonnegative"
123
+ end
124
+ unless item.range.nil? || (item.range.is_a?(Range) && item.range.begin.is_a?(Integer) && item.range.end.is_a?(Integer))
125
+ raise ArgumentError, "decoration range must contain integer offsets"
126
+ end
127
+ raise ArgumentError, "decoration click handler must be callable" unless item.on_click.nil? || item.on_click.respond_to?(:call)
128
+ item.source == source ? item : item.with(source: source)
129
+ end
130
+ end
131
+ end
132
+ end
@@ -11,8 +11,9 @@ class Canopus::DisplayMap::LineBuilder
11
11
  private_constant :EMPTY_LINES, :Row, :LineSet, :PendingLineSet, :UNWRAPPED_LINE
12
12
 
13
13
  attr_reader :rope, :fold, :blocks
14
- def initialize(rope, fold, tab, wrap, blocks)
14
+ def initialize(rope, fold, overlay, tab, wrap, blocks)
15
15
  @rope, @tab, @wrap = rope, tab, wrap
16
+ @overlay = overlay.snapshot
16
17
  @fold = Canopus::FoldMap.new
17
18
  @fold.instance_variable_set(:@ranges, fold.ranges.dup.freeze)
18
19
  @fold.freeze
@@ -25,9 +26,17 @@ class Canopus::DisplayMap::LineBuilder
25
26
  def line(row, provisional: false, from: 0, checkpoint: nil)
26
27
  input = @fold.transform(@rope, row, max_bytes: provisional ? 16_384 : nil, from: from, checkpoint: checkpoint)
27
28
  return EMPTY_LINES unless input
28
- text, offsets = @tab.transform(*input, checkpoint: checkpoint)
29
- wrapped = provisional ? [[text.freeze, offsets.freeze]] : @wrap.transform(text, offsets, checkpoint: checkpoint)
30
- rows = wrapped.map { |value, positions| Row.new(value, positions, :text, nil) }
29
+ text, offsets = input
30
+ overlays = @overlay.transform(@rope, row, text, offsets)
31
+ wrapped = provisional ? [[text.freeze, offsets.freeze, overlays]] : @wrap.transform(text, offsets,
32
+ checkpoint: checkpoint, overlays: overlays, tab_map: @tab)
33
+ rows = wrapped.map do |value, positions, placements|
34
+ placements ||= []
35
+ value, positions, placements = @tab.transform(value, positions, checkpoint: checkpoint, overlays: placements) if provisional && !placements.empty?
36
+ value, positions = @tab.transform(value, positions, checkpoint: checkpoint) if provisional && placements.empty?
37
+ Row.new(value.freeze, positions.freeze, :text, placements.empty? ? nil : placements)
38
+ end
39
+ rows = overlay_blocks(row, :above) + rows + overlay_blocks(row, :below)
31
40
  (@blocks[row] || []).each do |block|
32
41
  block.text.split("\n", -1).each do |value|
33
42
  checkpoint&.call
@@ -46,9 +55,20 @@ class Canopus::DisplayMap::LineBuilder
46
55
  if hidden[pointer]&.cover?(row)
47
56
  EMPTY_LINES
48
57
  else
49
- count = 1 + (@blocks[row] || []).sum { |block| block.text.count("\n") + 1 }
58
+ count = 1 + %i[above below].sum { |position| @overlay.blocks(row, position).sum(&:row_span) } +
59
+ (@blocks[row] || []).sum { |block| block.text.count("\n") + 1 }
50
60
  count == 1 ? UNWRAPPED_LINE : PendingLineSet.new(count)
51
61
  end
52
62
  end
53
63
  end
64
+
65
+ private
66
+
67
+ def overlay_blocks(row, position)
68
+ @overlay.blocks(row, position).flat_map do |block|
69
+ anchor = position == :above ? 0 : @rope.line(row).bytesize
70
+ [Row.new("".freeze, [anchor].freeze, :overlay_block, block)] +
71
+ Array.new(block.row_span - 1) { Row.new("".freeze, [anchor].freeze, :overlay_block_continuation, nil) }
72
+ end
73
+ end
54
74
  end