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 +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +8 -2
- data/docs/adr/008-component-domain-boundaries.md +26 -0
- data/docs/adr/009-decoration-overlay-path.md +26 -0
- data/docs/adr/010-overlay-aware-display-map.md +32 -0
- data/lib/canopus/command.rb +109 -0
- data/lib/canopus/controller.rb +30 -39
- data/lib/canopus/decoration.rb +132 -0
- data/lib/canopus/display_map/line_builder.rb +25 -5
- data/lib/canopus/display_map/overlay_map.rb +123 -0
- data/lib/canopus/display_map.rb +25 -4
- data/lib/canopus/lsp/client.rb +19 -11
- data/lib/canopus/panel.rb +104 -0
- data/lib/canopus/settings.rb +29 -5
- data/lib/canopus/tab_map.rb +8 -3
- data/lib/canopus/version.rb +1 -1
- data/lib/canopus/workspace/git_aware.rb +17 -1
- data/lib/canopus/workspace/session_persistable.rb +16 -6
- data/lib/canopus/workspace/view.rb +177 -51
- data/lib/canopus/workspace.rb +90 -23
- data/lib/canopus/wrap_map.rb +27 -1
- data/lib/canopus.rb +3 -0
- data/sig/canopus.rbs +7 -5
- data/sig/command.rbs +30 -0
- data/sig/controller.rbs +0 -2
- data/sig/decoration.rbs +26 -0
- data/sig/display_stages.rbs +28 -2
- data/sig/panel.rbs +35 -0
- data/sig/workspace_services.rbs +5 -2
- metadata +15 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 64a5ffb35c2d8fdfbc91881be747cdf0882548d2e924aa04846f333e23a73487
|
|
4
|
+
data.tar.gz: 17a825bf14afce060db1589fc6cb13986e0e7857a1d28622b99f1125d83de484
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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": {
|
|
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.
|
|
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
|
data/lib/canopus/controller.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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.
|
|
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.
|
|
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 =
|
|
29
|
-
|
|
30
|
-
|
|
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 +
|
|
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
|