canopus 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +148 -0
  5. data/docs/adr/001-runtime-gem-components.md +24 -0
  6. data/docs/adr/002-bounded-diff-engine.md +22 -0
  7. data/docs/adr/003-background-language-analysis.md +22 -0
  8. data/docs/adr/004-persistent-display-map.md +23 -0
  9. data/docs/adr/005-windows-runtime.md +27 -0
  10. data/docs/adr/README.md +36 -0
  11. data/docs/distribution.md +27 -0
  12. data/docs/lsp.md +38 -0
  13. data/docs/performance.md +40 -0
  14. data/docs/snippets.md +32 -0
  15. data/docs/vim.md +24 -0
  16. data/docs/workspace_edits.md +32 -0
  17. data/examples/native_smoke.rb +25 -0
  18. data/examples/plugins/word_count.rb +11 -0
  19. data/exe/canopus +19 -0
  20. data/lib/canopus/block_map.rb +19 -0
  21. data/lib/canopus/buffer.rb +293 -0
  22. data/lib/canopus/cli.rb +171 -0
  23. data/lib/canopus/controller.rb +507 -0
  24. data/lib/canopus/data_compat.rb +25 -0
  25. data/lib/canopus/display_map/line_builder.rb +54 -0
  26. data/lib/canopus/display_map/line_set.rb +8 -0
  27. data/lib/canopus/display_map/pending_line_set.rb +9 -0
  28. data/lib/canopus/display_map/summary.rb +23 -0
  29. data/lib/canopus/display_map/worker.rb +89 -0
  30. data/lib/canopus/display_map.rb +350 -0
  31. data/lib/canopus/display_point.rb +5 -0
  32. data/lib/canopus/editor/snippet_expandable.rb +281 -0
  33. data/lib/canopus/editor.rb +445 -0
  34. data/lib/canopus/error.rb +5 -0
  35. data/lib/canopus/fold_map.rb +87 -0
  36. data/lib/canopus/git/blame.rb +50 -0
  37. data/lib/canopus/git/commit.rb +7 -0
  38. data/lib/canopus/git/corrupt_object.rb +7 -0
  39. data/lib/canopus/git/diff.rb +131 -0
  40. data/lib/canopus/git/index.rb +94 -0
  41. data/lib/canopus/git/object_database.rb +45 -0
  42. data/lib/canopus/git/pack.rb +186 -0
  43. data/lib/canopus/git/repository.rb +313 -0
  44. data/lib/canopus/git/status.rb +74 -0
  45. data/lib/canopus/git/tree_entry.rb +7 -0
  46. data/lib/canopus/git.rb +15 -0
  47. data/lib/canopus/icon_theme.rb +43 -0
  48. data/lib/canopus/language/background_analysis/job.rb +12 -0
  49. data/lib/canopus/language/background_analysis/scheduler.rb +70 -0
  50. data/lib/canopus/language/background_analysis.rb +280 -0
  51. data/lib/canopus/language/definition.rb +7 -0
  52. data/lib/canopus/language/document.rb +143 -0
  53. data/lib/canopus/language/symbol.rb +7 -0
  54. data/lib/canopus/language/syntax_worker.rb +91 -0
  55. data/lib/canopus/language.rb +42 -0
  56. data/lib/canopus/lazy_rope.rb +218 -0
  57. data/lib/canopus/lsp/client.rb +299 -0
  58. data/lib/canopus/lsp/error.rb +7 -0
  59. data/lib/canopus/lsp/future/subscription.rb +9 -0
  60. data/lib/canopus/lsp/future.rb +87 -0
  61. data/lib/canopus/lsp/protocol.rb +83 -0
  62. data/lib/canopus/lsp/server_error.rb +13 -0
  63. data/lib/canopus/lsp/timeout.rb +7 -0
  64. data/lib/canopus/lsp/transport.rb +123 -0
  65. data/lib/canopus/lsp.rb +19 -0
  66. data/lib/canopus/markdown.rb +69 -0
  67. data/lib/canopus/match_data_compat.rb +17 -0
  68. data/lib/canopus/multi_buffer.rb +266 -0
  69. data/lib/canopus/pane.rb +61 -0
  70. data/lib/canopus/patch/composite.rb +12 -0
  71. data/lib/canopus/patch/reload.rb +18 -0
  72. data/lib/canopus/patch.rb +27 -0
  73. data/lib/canopus/performance_recorder.rb +207 -0
  74. data/lib/canopus/plugins/api.rb +50 -0
  75. data/lib/canopus/plugins/isolated_runtime.rb +167 -0
  76. data/lib/canopus/plugins/local_runtime.rb +25 -0
  77. data/lib/canopus/plugins/permission_denied.rb +7 -0
  78. data/lib/canopus/plugins/registry.rb +30 -0
  79. data/lib/canopus/plugins.rb +11 -0
  80. data/lib/canopus/project/ignore_matcher.rb +104 -0
  81. data/lib/canopus/project/search.rb +164 -0
  82. data/lib/canopus/project/search_worker/cancelled.rb +3 -0
  83. data/lib/canopus/project/search_worker/runner.rb +9 -0
  84. data/lib/canopus/project/search_worker.rb +108 -0
  85. data/lib/canopus/project/tree.rb +48 -0
  86. data/lib/canopus/project/watcher.rb +59 -0
  87. data/lib/canopus/project.rb +127 -0
  88. data/lib/canopus/regexp_compat.rb +30 -0
  89. data/lib/canopus/save_conflict.rb +5 -0
  90. data/lib/canopus/selection.rb +11 -0
  91. data/lib/canopus/settings.rb +118 -0
  92. data/lib/canopus/snippet/transform.rb +209 -0
  93. data/lib/canopus/snippet.rb +183 -0
  94. data/lib/canopus/tab_map.rb +30 -0
  95. data/lib/canopus/terminal/cell.rb +7 -0
  96. data/lib/canopus/terminal/grid.rb +321 -0
  97. data/lib/canopus/terminal/pty.rb +103 -0
  98. data/lib/canopus/terminal/scrollback.rb +38 -0
  99. data/lib/canopus/terminal/vt.rb +398 -0
  100. data/lib/canopus/terminal.rb +13 -0
  101. data/lib/canopus/theme.rb +43 -0
  102. data/lib/canopus/version.rb +5 -0
  103. data/lib/canopus/vim/commandable.rb +148 -0
  104. data/lib/canopus/vim/motionable.rb +321 -0
  105. data/lib/canopus/vim/operator_capable.rb +377 -0
  106. data/lib/canopus/vim/text_object_selectable.rb +141 -0
  107. data/lib/canopus/vim.rb +426 -0
  108. data/lib/canopus/workspace/edit/executable.rb +139 -0
  109. data/lib/canopus/workspace/edit/node.rb +8 -0
  110. data/lib/canopus/workspace/edit/plan.rb +170 -0
  111. data/lib/canopus/workspace/edit/resource_preparable.rb +98 -0
  112. data/lib/canopus/workspace/edit.rb +6 -0
  113. data/lib/canopus/workspace/file_change_aware.rb +40 -0
  114. data/lib/canopus/workspace/file_previewable.rb +31 -0
  115. data/lib/canopus/workspace/git_aware.rb +140 -0
  116. data/lib/canopus/workspace/language_aware.rb +413 -0
  117. data/lib/canopus/workspace/language_server_configurable.rb +181 -0
  118. data/lib/canopus/workspace/project_searchable.rb +208 -0
  119. data/lib/canopus/workspace/project_tree_editable.rb +82 -0
  120. data/lib/canopus/workspace/session_persistable.rb +153 -0
  121. data/lib/canopus/workspace/settings_aware.rb +91 -0
  122. data/lib/canopus/workspace/view/terminal_presentable.rb +185 -0
  123. data/lib/canopus/workspace/view.rb +643 -0
  124. data/lib/canopus/workspace.rb +525 -0
  125. data/lib/canopus/wrap_map.rb +108 -0
  126. data/lib/canopus.rb +24 -0
  127. data/sig/canopus.rbs +375 -0
  128. data/sig/controller.rbs +55 -0
  129. data/sig/display_stages.rbs +49 -0
  130. data/sig/git.rbs +140 -0
  131. data/sig/lsp.rbs +99 -0
  132. data/sig/markdown.rbs +11 -0
  133. data/sig/performance_recorder.rbs +23 -0
  134. data/sig/search_services.rbs +8 -0
  135. data/sig/services.rbs +81 -0
  136. data/sig/snippet.rbs +42 -0
  137. data/sig/terminal.rbs +125 -0
  138. data/sig/vim.rbs +27 -0
  139. data/sig/workspace_edits.rbs +11 -0
  140. data/sig/workspace_services.rbs +117 -0
  141. data/tools/certify_snippet_regex.rb +35 -0
  142. data/tools/check_dependencies.rb +80 -0
  143. data/tools/native_check.rb +70 -0
  144. data/tools/package.rb +113 -0
  145. data/tools/package_test.rb +32 -0
  146. metadata +314 -0
@@ -0,0 +1,507 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "workspace/view"
4
+
5
+ module Canopus
6
+ class Controller
7
+ SHORTCUTS = {"cmd-s" => "file.save", "ctrl-s" => "file.save", "cmd-n" => "file.new", "ctrl-n" => "file.new",
8
+ "cmd-w" => "file.close", "ctrl-w" => "file.close", "cmd-p" => "file.find", "ctrl-p" => "file.find",
9
+ "cmd-shift-p" => "command.palette", "ctrl-shift-p" => "command.palette", "cmd-z" => "edit.undo", "ctrl-z" => "edit.undo",
10
+ "cmd-shift-z" => "edit.redo", "ctrl-shift-z" => "edit.redo", "cmd-a" => "edit.select_all", "ctrl-a" => "edit.select_all",
11
+ "cmd-d" => "edit.select_next", "ctrl-d" => "edit.select_next", "cmd-f" => "search.buffer", "ctrl-f" => "search.buffer",
12
+ "cmd-shift-l" => "edit.select_all_occurrences", "ctrl-shift-l" => "edit.select_all_occurrences",
13
+ "alt-up" => "edit.move_line_up", "alt-down" => "edit.move_line_down",
14
+ "cmd-shift-f" => "search.project", "ctrl-shift-f" => "search.project", "cmd-alt-f" => "search.replace", "ctrl-h" => "search.replace",
15
+ "cmd-/" => "edit.toggle_comment", "ctrl-/" => "edit.toggle_comment", "cmd-b" => "view.project", "ctrl-b" => "view.project",
16
+ "ctrl-`" => "view.terminal", "cmd-\\" => "pane.split_right", "ctrl-\\" => "pane.split_right",
17
+ "ctrl-space" => "language.completion", "f12" => "language.definition", "shift-f12" => "language.references",
18
+ "f2" => "language.rename", "alt-enter" => "language.codeAction", "cmd-shift-o" => "language.outline",
19
+ "ctrl-shift-o" => "language.outline", "cmd-k" => "language.hover", "ctrl-k" => "language.hover"}.freeze
20
+ attr_reader :workspace, :view, :window, :keymap
21
+ def initialize(workspace, window)
22
+ @workspace, @window, @view = workspace, window, Workspace::View.new(workspace)
23
+ workspace.window = window
24
+ reload_keymap
25
+ window.draw { workspace.drain; @view }
26
+ window.on_input { |event| input(event) }
27
+ window.on_close { close_requested }
28
+ window.on_appearance { |_| workspace.apply_settings if workspace.settings["theme"] == "auto" } if window.respond_to?(:on_appearance)
29
+ window.on_tick do
30
+ data = workspace.terminal&.read
31
+ window.request_frame if data && !data.empty?
32
+ workspace.drain
33
+ workspace.poll_changes
34
+ workspace.poll_settings
35
+ reload_keymap
36
+ workspace.poll_git_changes
37
+ poll_display_maps
38
+ poll_language_documents
39
+ poll_scroll
40
+ window.request_frame if @view.tick
41
+ end
42
+ @clipboard, @terminal_focus = "", false
43
+ workspace.new_buffer unless workspace.editor
44
+ end
45
+ def input(event)
46
+ case event
47
+ when Zaniah::Input::KeyUp, Zaniah::Input::MouseDown, Zaniah::Input::FileDrop
48
+ @suppress_key_text = false
49
+ when Zaniah::Input::Composition
50
+ unless @workspace.palette
51
+ @composing = !event.text.empty?
52
+ @suppress_key_text = false if @composing
53
+ end
54
+ when Zaniah::Input::TextInput
55
+ @composing = false
56
+ suppressed, @suppress_key_text = @suppress_key_text, false
57
+ return if suppressed
58
+ end
59
+ cancel_scroll if event.is_a?(Zaniah::Input::KeyDown) || event.is_a?(Zaniah::Input::TextInput) || event.is_a?(Zaniah::Input::MouseDown)
60
+ @view.reset_blink unless event.is_a?(Zaniah::Input::MouseMove)
61
+ return if palette_input(event)
62
+ case event
63
+ when Zaniah::Input::FileDrop
64
+ event.paths.each { |path| @workspace.open(path) if File.file?(path) }
65
+ when Zaniah::Input::TextInput
66
+ input_text(event.text)
67
+ when Zaniah::Input::Composition
68
+ @workspace.editor.composition = event
69
+ when Zaniah::Input::KeyDown
70
+ key(event.keystroke)
71
+ when Zaniah::Input::MouseDown
72
+ mouse_down(event)
73
+ when Zaniah::Input::MouseMove
74
+ mouse_move(event) if @drag
75
+ resize_drag(event.position) if @resize_drag
76
+ scroll_drag(event.position) if @scroll_drag
77
+ if @terminal_drag
78
+ @terminal_drag == :selection ? @view.terminal_select(event.position, extend: true) : terminal_mouse(event, :move)
79
+ elsif !(@drag || @resize_drag || @scroll_drag || @drag_file || @drag_tab) &&
80
+ @workspace.terminal_visible && @workspace.terminal&.vt&.modes&.[](1003) && @view.hit(event.position)&.first == :terminal
81
+ terminal_mouse(event, :move)
82
+ end
83
+ when Zaniah::Input::MouseUp
84
+ @resize_drag = nil
85
+ @scroll_drag = nil
86
+ if @drag_file
87
+ path, origin = @drag_file
88
+ target = @view.hit(event.position)
89
+ if target&.first == :directory && (event.position.x - origin.x).abs + (event.position.y - origin.y).abs > 10
90
+ @workspace.rename_project_entry(path, File.join(target[1], File.basename(path)))
91
+ end
92
+ @drag_file = nil
93
+ end
94
+ terminal_mouse(event, :release) if @terminal_drag && @terminal_drag != :selection
95
+ @terminal_drag = nil
96
+ if @drag_tab
97
+ from, editor, origin = @drag_tab
98
+ if (event.position.x - origin.x).abs + (event.position.y - origin.y).abs > 10
99
+ target = @view.hit(event.position)
100
+ if target && [:editor, :tab, :pane].include?(target.first)
101
+ @workspace.move_tab(from: from, to: target[1], editor: editor, index: target.first == :tab ? target[1].editors.index(target[2]) : nil)
102
+ end
103
+ end
104
+ @drag_tab = nil
105
+ end
106
+ @drag = nil
107
+ when Zaniah::Input::ScrollWheel
108
+ scroll(event)
109
+ end
110
+ @window.request_frame
111
+ rescue StandardError => error
112
+ @workspace.message = error.message
113
+ @window.request_frame
114
+ end
115
+ def input_text(text)
116
+ if @workspace.palette
117
+ return if [:workspace_edit, :confirm_close, :trash_file].include?(@workspace.palette[:kind])
118
+ @workspace.palette[:query] << text
119
+ @workspace.update_palette
120
+ elsif @terminal_focus && @workspace.terminal_visible
121
+ @workspace.terminal.write(text)
122
+ elsif @workspace.settings["vim_mode"]
123
+ text.each_grapheme_cluster { |character| @workspace.vim.feed(character) }
124
+ else
125
+ @workspace.editor.composition = nil
126
+ @workspace.editor.insert_text(text)
127
+ end
128
+ end
129
+ def key(stroke)
130
+ @suppress_key_text = false
131
+ reload_keymap
132
+ stroke = Zaniah::Input::Keystroke.normalize(stroke)
133
+ return palette_key(stroke) if @workspace.palette
134
+ return if @composing && (stroke.split("-") & %w[ctrl cmd]).empty?
135
+ return @workspace.settings_completions if stroke == "ctrl-space" && @workspace.settings_document?(@workspace.editor.buffer)
136
+ if ["cmd-q", "ctrl-q"].include?(stroke)
137
+ return @window.close
138
+ end
139
+ if @terminal_focus && @workspace.terminal_visible && stroke != "ctrl-`"
140
+ if ["cmd-v", "ctrl-shift-v"].include?(stroke)
141
+ return @workspace.terminal.paste(@window.respond_to?(:clipboard) ? @window.clipboard.to_s : @clipboard)
142
+ elsif ["cmd-c", "ctrl-shift-c"].include?(stroke)
143
+ @clipboard = @view.terminal_selected_text
144
+ @window.clipboard = @clipboard if @window.respond_to?(:clipboard=)
145
+ return
146
+ end
147
+ parts = stroke.split("-")
148
+ name = parts.pop
149
+ return if (name.length == 1 || name == "space") && (parts & %w[ctrl alt cmd]).empty?
150
+ name = {"esc" => "escape", "pageup" => "page_up", "pagedown" => "page_down", "space" => " "}.fetch(name, name)
151
+ return @workspace.terminal.key(name, control: parts.include?("ctrl"), alt: parts.include?("alt"), shift: parts.include?("shift"))
152
+ end
153
+ mode = @workspace.settings["vim_mode"] ? @workspace.vim.mode.to_s : false
154
+ action = @keymap.dispatch(stroke, context: {"Editor" => true, "vim_mode" => mode})
155
+ if action
156
+ # Native adapters deliver text separately after printable key down.
157
+ # Cocoa also sends an empty Composition before that ordinary commit.
158
+ @suppress_key_text = stroke.match?(/\A(?:(?:alt|shift)-)*(?:space|[^\x00-\x1f\x7f])\z/)
159
+ return action == :pending ? nil : @workspace.call(action)
160
+ end
161
+ if ["cmd-c", "ctrl-c", "cmd-x", "ctrl-x", "cmd-v", "ctrl-v"].include?(stroke) && !(@workspace.settings["vim_mode"] && stroke == "ctrl-v")
162
+ return clipboard(stroke.split("-").last)
163
+ end
164
+ if ["tab", "shift-tab"].include?(stroke) && @workspace.editor.snippet_active?
165
+ stroke == "tab" ? @workspace.editor.next_snippet : @workspace.editor.previous_snippet
166
+ return @workspace.show_snippet_choices
167
+ end
168
+ parts = stroke.split("-")
169
+ name = parts.pop
170
+ if @workspace.settings["vim_mode"]
171
+ @workspace.vim.feed(stroke) if name.length > 1 || parts.include?("ctrl")
172
+ return
173
+ end
174
+ editor = @workspace.editor
175
+ shift = parts.include?("shift")
176
+ movement = {"left" => :left, "right" => :right, "up" => :up, "down" => :down,
177
+ "home" => :line_start, "end" => :line_end, "pageup" => :page_up, "pagedown" => :page_down}[name]
178
+ if movement
179
+ movement = {left: :word_left, right: :word_right}.fetch(movement, movement) if parts.include?("alt") || parts.include?("ctrl")
180
+ movement = {left: :line_start, right: :line_end, up: :file_start, down: :file_end}.fetch(movement, movement) if parts.include?("cmd")
181
+ editor.move(movement, extend: shift)
182
+ else
183
+ case name
184
+ when "backspace" then editor.delete_backward
185
+ when "delete" then editor.delete_forward
186
+ when "enter" then editor.insert_text("\n")
187
+ when "tab" then shift ? editor.indent(outdent: true) : (editor.next_snippet || editor.insert_text(editor.use_tabs ? "\t" : " " * editor.tab_size))
188
+ when "esc" then @workspace.dismiss_hover; editor.clear_snippet; editor.composition = nil; editor.select(editor.primary.head)
189
+ end
190
+ end
191
+ end
192
+ def tick
193
+ reload_keymap
194
+ @window.tick
195
+ end
196
+ def cancel_scroll = @scroll_motion = nil
197
+ def poll_language_documents
198
+ visible = @workspace.panes.filter_map(&:active)
199
+ @language_viewports ||= {}
200
+ @language_viewports.delete_if { |editor, _| !visible.include?(editor) }
201
+ inactive = @workspace.panes.flat_map(&:editors).uniq - visible
202
+ @language_poll_index = (@language_poll_index || -1) + 1
203
+ candidates = visible + (inactive.empty? ? [] : [inactive[@language_poll_index % inactive.length]])
204
+ candidates.each do |editor|
205
+ document = editor.language_document
206
+ if visible.include?(editor)
207
+ map = editor.display_map
208
+ first = editor.scroll_y.floor.clamp(0, map.row_count - 1)
209
+ last = [first + editor.viewport_rows, first + 255, map.row_count - 1].min
210
+ cached = @language_viewports[editor]
211
+ # The persistent display tree changes on edits, folds, blocks and
212
+ # completed wrap batches. Idle polls need not walk every visible row.
213
+ unless cached && cached[0].equal?(document) && cached[1].equal?(map.tree) &&
214
+ cached[2] == editor.buffer.version && cached[3] == first && cached[4] == last
215
+ document.request(rows: (first..last).map { |row| map.source_row(row) }.uniq)
216
+ @language_viewports[editor] = [document, map.tree, editor.buffer.version, first, last]
217
+ end
218
+ end
219
+ changed = document.poll
220
+ @workspace.language_ready(editor, document)
221
+ @window.request_frame if changed
222
+ rescue StandardError => error
223
+ @workspace.message = "Language analysis: #{error.message}"
224
+ end
225
+ end
226
+ def poll_scroll(now: Process.clock_gettime(Process::CLOCK_MONOTONIC))
227
+ return false unless @scroll_motion
228
+ editor, vx, vy, time = @scroll_motion
229
+ friction = @workspace.settings["scroll_friction"]
230
+ if @workspace.palette || friction.zero? || !@workspace.panes.any? { |pane| pane.active.equal?(editor) }
231
+ cancel_scroll
232
+ return false
233
+ end
234
+ elapsed = now - time
235
+ return false unless elapsed.positive?
236
+ # Exact exponential integration makes motion independent of frame rate.
237
+ decay = Math.exp(-friction * elapsed)
238
+ x, y = editor.scroll_x, editor.scroll_y
239
+ editor.scroll(dx: vx * (1 - decay) / friction, dy: vy * (1 - decay) / friction)
240
+ vx = editor.scroll_x == x ? 0 : vx * decay
241
+ vy = editor.scroll_y == y ? 0 : vy * decay
242
+ @scroll_motion = vx.abs < 0.2 && vy.abs < 0.01 ? nil : [editor, vx, vy, now]
243
+ changed = editor.scroll_x != x || editor.scroll_y != y
244
+ @window.request_frame if changed
245
+ changed
246
+ end
247
+ def poll_display_maps
248
+ visible = @workspace.panes.filter_map(&:active)
249
+ inactive = @workspace.panes.flat_map(&:editors) - visible
250
+ targets = visible.map { |editor| [editor, [224 / [visible.length, 1].max, 1].max] }
251
+ unless inactive.empty?
252
+ @layout_poll_index = ((@layout_poll_index || -1) + 1) % inactive.length
253
+ targets << [inactive[@layout_poll_index], 32]
254
+ end
255
+ targets.each do |editor, limit|
256
+ map = editor.display_map
257
+ next unless map.pending?
258
+ top = editor.scroll_y
259
+ first = top.floor.clamp(0, map.row_count - 1)
260
+ anchor = map.to_buffer(DisplayPoint.new(first, 0))
261
+ cursor_was_visible = map.to_display(editor.primary.head).row.between?(first, first + editor.viewport_rows - 1)
262
+ next unless map.poll(max_lines: limit)
263
+ editor.scroll(dy: map.to_display(anchor).row + top % 1 - editor.scroll_y)
264
+ editor.reveal_cursor if @workspace.editor.equal?(editor) && cursor_was_visible
265
+ @window.request_frame
266
+ rescue StandardError => error
267
+ @workspace.message = "Background layout failed: #{error.message}"
268
+ end
269
+ end
270
+ def close_requested
271
+ if @discard_close || @workspace.buffers.values.none?(&:dirty?)
272
+ @workspace.palette = nil
273
+ return true
274
+ end
275
+ @workspace.palette = {kind: :confirm_close, query: "Save changes before closing?", index: 0, matches: ["Save all", "Discard changes", "Cancel"]}
276
+ @window.request_frame
277
+ false
278
+ end
279
+
280
+ private
281
+ def reload_keymap
282
+ language = @workspace.editor&.language_document&.definition&.name
283
+ groups = @workspace.settings["languages"].dig(language, "keymap") || @workspace.settings["keymap"]
284
+ return if @keymap_groups.equal?(groups)
285
+ unless @keymap_groups == groups
286
+ replacement = Zaniah::Input::Keymap.new
287
+ SHORTCUTS.each { |keys, action| replacement.bind(keys, action) }
288
+ groups.each do |group|
289
+ group.fetch("bindings").each { |keys, action| replacement.bind(keys, action, context: group.fetch("context")) }
290
+ end
291
+ @keymap = replacement
292
+ @view.keymap = replacement
293
+ end
294
+ @keymap_groups = groups
295
+ end
296
+ def palette_input(event)
297
+ return false unless @workspace.palette
298
+ @drag = @resize_drag = @scroll_drag = @terminal_drag = @drag_file = @drag_tab = nil
299
+ case event
300
+ when Zaniah::Input::KeyDown then key(event.keystroke)
301
+ when Zaniah::Input::TextInput then input_text(event.text)
302
+ when Zaniah::Input::MouseDown then mouse_down(event)
303
+ end
304
+ @window.request_frame
305
+ true
306
+ end
307
+ def clipboard(operation)
308
+ editor = @workspace.editor
309
+ if operation == "v"
310
+ value = @window.respond_to?(:clipboard) ? @window.clipboard : @clipboard
311
+ editor.insert_text(value.to_s, auto_indent: false)
312
+ else
313
+ value = editor.selections.map { |selection| editor.buffer.rope.byteslice(selection.range).to_s }.join("\n")
314
+ @clipboard = value
315
+ @window.clipboard = value if @window.respond_to?(:clipboard=)
316
+ editor.replace_selections("", kind: :cut) if operation == "x"
317
+ end
318
+ end
319
+ def palette_key(stroke)
320
+ palette = @workspace.palette
321
+ if palette[:search_options] && (option = {"ctrl-alt-r" => :regexp, "ctrl-alt-c" => :case_sensitive, "ctrl-alt-w" => :whole_word, "ctrl-alt-s" => :selection_only}[stroke])
322
+ return if option == :selection_only && palette[:kind] == :project_search
323
+ palette[:search_options][option] = !palette[:search_options][option]
324
+ return
325
+ end
326
+ case stroke
327
+ when "tab", "shift-tab"
328
+ if palette[:kind] == :snippet_choices
329
+ @workspace.palette_accept
330
+ stroke == "tab" ? @workspace.editor.next_snippet : @workspace.editor.previous_snippet
331
+ @workspace.show_snippet_choices
332
+ end
333
+ when "esc"
334
+ @workspace.palette = nil
335
+ when "up" then palette[:index] = [palette[:index] - 1, 0].max
336
+ when "down" then palette[:index] = [palette[:index] + 1, [palette[:matches].length - 1, 0].max].min
337
+ when "pageup", "pagedown"
338
+ if palette[:kind] == :workspace_edit
339
+ step = palette.fetch(:detail_rows, 8)
340
+ maximum = [palette.fetch(:detail_lines, []).length - step, 0].max
341
+ palette[:details_scroll] = (palette.fetch(:details_scroll, 0) + (stroke == "pageup" ? -step : step)).clamp(0, maximum)
342
+ end
343
+ when "backspace"
344
+ return if [:workspace_edit, :confirm_close, :trash_file].include?(palette[:kind])
345
+ palette[:query] = palette[:query].grapheme_clusters[0...-1].join
346
+ @workspace.update_palette
347
+ when "enter"
348
+ if palette[:kind] == :workspace_edit
349
+ result = if palette[:index].zero?
350
+ begin
351
+ @workspace.apply_workspace_edit(palette[:edit], confirmed: true)
352
+ rescue StandardError => error
353
+ {"applied" => false, "failureReason" => error.message}
354
+ end
355
+ else
356
+ {"applied" => false, "failureReason" => "User cancelled"}
357
+ end
358
+ palette[:response]&.fulfill(result.slice("applied", "failureReason", "failedChange"))
359
+ @workspace.palette = nil if @workspace.palette.equal?(palette)
360
+ if result["applied"]
361
+ palette[:on_applied]&.call
362
+ elsif result["failureReason"] != "User cancelled"
363
+ @workspace.message = result["failureReason"].to_s
364
+ end
365
+ elsif palette[:kind] == :confirm_close
366
+ case palette[:index]
367
+ when 0
368
+ dirty = @workspace.buffers.values.flat_map { |buffer| buffer.is_a?(MultiBuffer) ? buffer.excerpts.map(&:buffer) : [buffer] }.uniq.select(&:dirty?)
369
+ raise Error, "Save untitled documents first, then close again" if dirty.any? { |buffer| !buffer.path }
370
+ dirty.each { |buffer| @workspace.save_buffer(buffer) }
371
+ @workspace.palette = nil
372
+ @window.close
373
+ when 1 then @discard_close = true; @window.close
374
+ else @workspace.palette = nil
375
+ end
376
+ else
377
+ @workspace.palette_accept
378
+ end
379
+ end
380
+ end
381
+ def mouse_down(event)
382
+ action = @view.hit(event.position)
383
+ return unless action
384
+ kind, *args = action
385
+ return if @workspace.palette && (kind != :palette || event.button != :left)
386
+ if [:file, :directory].include?(kind)
387
+ @workspace.selected_project_path = args.first
388
+ @drag_file = [args.first, event.position] if event.button == :left
389
+ if event.button == :right
390
+ @workspace.palette = {kind: :commands, query: +"", index: 0, matches: %w[project.new_file project.new_folder project.rename project.trash]}
391
+ return
392
+ end
393
+ end
394
+ @terminal_focus = kind == :terminal
395
+ case kind
396
+ when :git_hunk then @workspace.toggle_git_hunk(args[0], row: args[1]) if event.button == :left
397
+ when :hover_link
398
+ return unless event.button == :left
399
+ uri = URI.parse(args.first)
400
+ @window.open_url(uri.to_s) if %w[http https].include?(uri.scheme) && uri.host && @window.respond_to?(:open_url)
401
+ when :dismiss_notification then @workspace.dismiss_notification(args.first)
402
+ when :pane then @workspace.focus(args.first)
403
+ when :split_resize, :dock_resize then @resize_drag = action
404
+ when :scrollbar
405
+ editor, direction, track, thumb, maximum = args
406
+ coordinate = direction == :vertical ? event.position.y : event.position.x
407
+ start = direction == :vertical ? thumb.y : thumb.x
408
+ length = direction == :vertical ? thumb.height : thumb.width
409
+ grab = coordinate.between?(start, start + length) ? coordinate - start : length / 2.0
410
+ @scroll_drag = [editor, direction, track, thumb, maximum, grab]
411
+ scroll_drag(event.position)
412
+ when :terminal
413
+ return if event.button == :left && @view.terminal_open_link(event.position, modifiers: event.modifiers)
414
+ tracking = [1000, 1002, 1003].any? { |mode| @workspace.terminal.vt.modes[mode] }
415
+ if tracking && !event.modifiers.map(&:to_s).include?("shift")
416
+ @terminal_drag = event.button
417
+ terminal_mouse(event, :press)
418
+ else
419
+ @terminal_drag = :selection
420
+ @view.terminal_select(event.position)
421
+ end
422
+ when :file then @workspace.open(args.first)
423
+ when :directory then @workspace.project_tree.toggle(args.first)
424
+ when :tab
425
+ pane, editor = args
426
+ @workspace.activate_tab(pane, editor)
427
+ @drag_tab = [pane, editor, event.position]
428
+ when :editor
429
+ pane, editor = args
430
+ @workspace.focus(pane)
431
+ position = @view.offset_at(editor, event.position)
432
+ modifiers = event.modifiers.map(&:to_s)
433
+ anchor = modifiers.include?("shift") ? editor.primary.anchor : position
434
+ editor.select(anchor, position, add: modifiers.include?("alt"))
435
+ if event.click_count == 2
436
+ editor.move(:word_left)
437
+ editor.move(:word_right, extend: true)
438
+ elsif event.click_count >= 3
439
+ editor.move(:line_start)
440
+ editor.move(:down, extend: true)
441
+ end
442
+ @drag = [editor, editor.primary.anchor, event.position, modifiers.include?("alt") && modifiers.include?("shift")]
443
+ when :palette
444
+ @workspace.palette[:index] = args.first
445
+ palette_key("enter")
446
+ end
447
+ end
448
+ def mouse_move(event)
449
+ editor, anchor, _origin, rectangle = @drag
450
+ offset = @view.offset_at(editor, event.position)
451
+ rectangle ? editor.rectangle(editor.display_map.to_display(anchor), editor.display_map.to_display(offset)) : editor.select(anchor, offset)
452
+ end
453
+ def scroll(event)
454
+ cancel_scroll
455
+ raise ArgumentError, "scroll deltas must be finite" unless [event.delta.x, event.delta.y].all? { |value| value.is_a?(Numeric) && value.finite? }
456
+ action = @view.hit(event.position)
457
+ if [:editor, :scrollbar].include?(action&.first)
458
+ editor = action.first == :editor ? action.last : action[1]
459
+ editor.scroll(dx: event.delta.x, dy: event.delta.y / 20.0)
460
+ # Cocoa already supplies native momentum deltas (numeric NSEvent phase).
461
+ # Other backends get a bounded decaying tail; zero friction disables it.
462
+ if !event.phase.is_a?(Numeric) && @workspace.settings["scroll_friction"].positive?
463
+ @scroll_motion = [editor, event.delta.x * 10, event.delta.y / 2.0, Process.clock_gettime(Process::CLOCK_MONOTONIC)]
464
+ end
465
+ elsif [:file, :directory].include?(action&.first)
466
+ @view.project_scroll(event.delta.y / 24.0)
467
+ elsif action&.first == :terminal
468
+ if [1000, 1002, 1003].any? { |mode| @workspace.terminal.vt.modes[mode] }
469
+ column, row = @view.terminal_point(event.position)
470
+ @workspace.terminal.mouse(button: event.delta.y.positive? ? :wheel_down : :wheel_up, column: column, row: row)
471
+ else
472
+ @view.terminal_scroll(event.delta.y / 20.0)
473
+ end
474
+ end
475
+ end
476
+ def scroll_drag(point)
477
+ editor, direction, track, thumb, maximum, grab = @scroll_drag
478
+ if direction == :vertical
479
+ fraction = (point.y - track.y - grab) / [track.height - thumb.height, 1].max
480
+ editor.scroll(dy: fraction.clamp(0, 1) * maximum - editor.scroll_y)
481
+ else
482
+ fraction = (point.x - track.x - grab) / [track.width - thumb.width, 1].max
483
+ editor.scroll(dx: fraction.clamp(0, 1) * maximum - editor.scroll_x)
484
+ end
485
+ end
486
+ def terminal_mouse(event, action)
487
+ column, row = @view.terminal_point(event.position)
488
+ modifiers = event.modifiers.map(&:to_s)
489
+ @workspace.terminal.mouse(button: @terminal_drag, column: column, row: row, action: action,
490
+ shift: modifiers.include?("shift"), alt: modifiers.include?("alt"), control: modifiers.include?("ctrl"))
491
+ end
492
+ def resize_drag(point)
493
+ kind, target, bounds = @resize_drag
494
+ if kind == :split_resize
495
+ value = target[:direction] == :horizontal ? (point.x - bounds.x).to_f / bounds.width : (point.y - bounds.y).to_f / bounds.height
496
+ target[:ratio] = value.clamp(0.1, 0.9)
497
+ else
498
+ size = case target
499
+ when :left then point.x - bounds.x
500
+ when :right then bounds.right - point.x
501
+ when :bottom then bounds.bottom - 26 - point.y
502
+ end
503
+ @workspace.docks[target][:size] = size.clamp(80, target == :bottom ? bounds.height * 0.7 : bounds.width * 0.4)
504
+ end
505
+ end
506
+ end
507
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ruby 3.2 added Data; Struct provides the same record operations used here.
4
+ unless defined?(Data)
5
+ Data = Struct
6
+ def Data.define(*members, &block)
7
+ Struct.new(*members) do
8
+ members.each { |member| undef_method :"#{member}=" }
9
+ define_method(:initialize) do |*values, **keywords|
10
+ if keywords.any?
11
+ raise ArgumentError, "expected either positional or keyword members" unless values.empty? && keywords.keys.sort == members.sort
12
+ values = members.map { |member| keywords.fetch(member) }
13
+ end
14
+ raise ArgumentError, "wrong number of members" unless values.length == members.length
15
+ super(*values)
16
+ freeze
17
+ end
18
+ define_method(:with) do |**changes|
19
+ changes.empty? ? self : self.class.new(**to_h.merge(changes))
20
+ end
21
+ class_eval(&block) if block
22
+ end
23
+ end
24
+ end
25
+
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A builder owns immutable settings and a persistent Rope snapshot. Neither
4
+ # the background thread nor provisional reads mutate the visible Tree.
5
+ class Canopus::DisplayMap::LineBuilder
6
+ EMPTY_LINES = Canopus::DisplayMap.const_get(:EMPTY_LINES, false)
7
+ Row = Canopus::DisplayMap.const_get(:Row, false)
8
+ LineSet = Canopus::DisplayMap.const_get(:LineSet, false)
9
+ PendingLineSet = Canopus::DisplayMap.const_get(:PendingLineSet, false)
10
+ UNWRAPPED_LINE = Canopus::DisplayMap.const_get(:UNWRAPPED_LINE, false)
11
+ private_constant :EMPTY_LINES, :Row, :LineSet, :PendingLineSet, :UNWRAPPED_LINE
12
+
13
+ attr_reader :rope, :fold, :blocks
14
+ def initialize(rope, fold, tab, wrap, blocks)
15
+ @rope, @tab, @wrap = rope, tab, wrap
16
+ @fold = Canopus::FoldMap.new
17
+ @fold.instance_variable_set(:@ranges, fold.ranges.dup.freeze)
18
+ @fold.freeze
19
+ @blocks = blocks.blocks.values.group_by(&:row).transform_values do |values|
20
+ values.map { |block| Canopus::BlockMap::Block.new(block.id, block.row, block.text.dup.freeze, block.kind) }.freeze
21
+ end.freeze
22
+ freeze
23
+ end
24
+
25
+ def line(row, provisional: false, from: 0, checkpoint: nil)
26
+ input = @fold.transform(@rope, row, max_bytes: provisional ? 16_384 : nil, from: from, checkpoint: checkpoint)
27
+ 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) }
31
+ (@blocks[row] || []).each do |block|
32
+ block.text.split("\n", -1).each do |value|
33
+ checkpoint&.call
34
+ rows << Row.new(value.freeze, Array.new(value.length + 1, offsets.last).freeze, block.kind, block.id)
35
+ end
36
+ end
37
+ LineSet.new(rows.freeze)
38
+ end
39
+
40
+ def pending_lines(first, count)
41
+ hidden = @fold.ranges.map { |range| (@rope.point_at(range.begin).row + 1)..@rope.point_at(range.end).row }
42
+ pointer = 0
43
+ Array.new(count) do |index|
44
+ row = first + index
45
+ pointer += 1 while hidden[pointer] && hidden[pointer].end < row
46
+ if hidden[pointer]&.cover?(row)
47
+ EMPTY_LINES
48
+ else
49
+ count = 1 + (@blocks[row] || []).sum { |block| block.text.count("\n") + 1 }
50
+ count == 1 ? UNWRAPPED_LINE : PendingLineSet.new(count)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Canopus::DisplayMap::LineSet < Struct.new(:rows, :summary)
4
+ def initialize(rows)
5
+ super(rows, Canopus::DisplayMap::Summary.new(1, rows.length, 0))
6
+ freeze
7
+ end
8
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Canopus::DisplayMap::PendingLineSet < Struct.new(:display_rows, :summary)
4
+ def initialize(display_rows)
5
+ super(display_rows, Canopus::DisplayMap::Summary.new(1, display_rows, 1))
6
+ freeze
7
+ end
8
+ def rows = nil
9
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Canopus::DisplayMap::Summary < Struct.new(:source_rows, :display_rows, :pending_rows)
4
+ def initialize(source_rows, display_rows, pending_rows)
5
+ super
6
+ freeze
7
+ end
8
+ ZERO = new(0, 0, 0)
9
+ def self.zero = ZERO
10
+ def +(other)
11
+ return other if source_rows.zero?
12
+ return self if other.source_rows.zero?
13
+ Canopus::DisplayMap::Summary.new(source_rows + other.source_rows, display_rows + other.display_rows, pending_rows + other.pending_rows)
14
+ end
15
+ def project_combined(dimension, other)
16
+ case dimension
17
+ when :source_rows then source_rows + other.source_rows
18
+ when :display_rows then display_rows + other.display_rows
19
+ when :pending_rows then pending_rows + other.pending_rows
20
+ else raise ArgumentError, "unknown display summary dimension: #{dimension}"
21
+ end
22
+ end
23
+ end