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,643 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ # A viewport is one element; only visible document rows become draw commands.
5
+ class Workspace::View < Zaniah::Element
6
+ attr_reader :editor_bounds, :row_layouts, :regions, :accessibility
7
+ attr_accessor :keymap
8
+ def initialize(workspace)
9
+ super()
10
+ @workspace = workspace
11
+ @editor_bounds, @row_layouts, @regions, @accessibility = {}, {}, [], []
12
+ @project_scroll = 0
13
+ reset_blink
14
+ @revealed_cursors, @line_widths, @code_caches = {}, {}, {}
15
+ @frame_diagnostics = {}
16
+ end
17
+ def reset_blink(now = Process.clock_gettime(Process::CLOCK_MONOTONIC))
18
+ @blink_started, @cursor_visible = now, true
19
+ end
20
+ def cursor_visible? = @cursor_visible
21
+ def tick(now = Process.clock_gettime(Process::CLOCK_MONOTONIC))
22
+ expired = @workspace.expire_notifications(now)
23
+ visible = ((now - @blink_started) / 0.5).floor.even?
24
+ return expired if visible == @cursor_visible
25
+ @cursor_visible = visible
26
+ true
27
+ end
28
+ def paint(bounds, _state, _prepaint, cx)
29
+ @cx, @theme, @scene = cx, @workspace.theme, cx.scene
30
+ @viewport_bounds = bounds
31
+ @painted_palette = @workspace.palette
32
+ @font_size = @workspace.settings["font_size"]
33
+ @line_height = (@font_size * 1.55).ceil
34
+ @line_height = 20 if @cx.window.is_a?(Zaniah::Platform::TUI::Window)
35
+ @regions.clear
36
+ @row_layouts.clear
37
+ @editor_bounds.clear
38
+ @accessibility.clear
39
+ @frame_diagnostics.clear
40
+ visible_editors = @workspace.panes.map(&:active)
41
+ @revealed_cursors.delete_if { |editor, _| !visible_editors.include?(editor) }
42
+ @line_widths.delete_if { |editor, _| !visible_editors.include?(editor) }
43
+ @code_caches.delete_if { |editor, _| !visible_editors.include?(editor) }
44
+ fill(bounds, :background)
45
+ bottom_panel = @workspace.docks[:bottom][:visible] && @workspace.panels.values.any? { |side, _| side == :bottom }
46
+ bottom = @workspace.terminal_visible || bottom_panel ? [bounds.height * 0.7, @workspace.docks[:bottom][:size]].min : 0
47
+ sidebar = @workspace.show_project && @workspace.docks[:left][:visible] && bounds.width >= 620 ? [@workspace.docks[:left][:size], bounds.width * 0.4].min : 0
48
+ right = @workspace.docks[:right][:visible] && bounds.width >= 620 ? [@workspace.docks[:right][:size], bounds.width * 0.4].min : 0
49
+ body = Zaniah::Bounds.new(sidebar, 0, bounds.width - sidebar - right, [bounds.height - 26 - bottom, 0].max)
50
+ if sidebar.positive?
51
+ left_panels = @workspace.panels.values.any? { |side, _| side == :left }
52
+ project_height = left_panels ? (bounds.height - 26) * 0.6 : bounds.height - 26
53
+ paint_project(Zaniah::Bounds.new(0, 0, sidebar, project_height))
54
+ paint_panels(:left, Zaniah::Bounds.new(0, project_height, sidebar, bounds.height - 26 - project_height)) if left_panels
55
+ end
56
+ paint_layout(@workspace.layout, body)
57
+ if bottom.positive?
58
+ area = Zaniah::Bounds.new(sidebar, body.height, body.width, bottom)
59
+ @workspace.terminal_visible ? paint_terminal(area) : paint_panels(:bottom, area)
60
+ end
61
+ paint_panels(:right, Zaniah::Bounds.new(bounds.width - right, 0, right, bounds.height - 26)) if right.positive?
62
+ region(Zaniah::Bounds.new(sidebar - 3, 0, 6, body.height), role: :separator, label: "Resize left dock", action: [:dock_resize, :left, bounds]) if sidebar.positive?
63
+ region(Zaniah::Bounds.new(bounds.width - right - 3, 0, 6, body.height), role: :separator, label: "Resize right dock", action: [:dock_resize, :right, bounds]) if right.positive?
64
+ region(Zaniah::Bounds.new(body.x, body.bottom - 3, body.width, 6), role: :separator, label: "Resize bottom dock", action: [:dock_resize, :bottom, bounds]) if bottom.positive?
65
+ paint_status(Zaniah::Bounds.new(0, bounds.height - 26, bounds.width, 26))
66
+ paint_hover(bounds) if @workspace.hover_card && !@workspace.hover_card.empty?
67
+ paint_palette(bounds) if @workspace.palette
68
+ paint_notifications(bounds) unless @workspace.notifications.empty?
69
+ if @workspace.performance
70
+ stats = @workspace.performance.statistics
71
+ label = "#{stats[:last_frame_ms]&.round(2) || 'n/a'} ms | #{stats[:last_allocations] || 'n/a'} objects | #{stats[:last_draw_calls] || 'n/a'} draws"
72
+ @scene.layer(2_000_000) do
73
+ area = Zaniah::Bounds.new([bounds.right - 360, 0].max, 3, [bounds.width, 356].min, 24)
74
+ fill(area, :panel)
75
+ text(label, area.x + 8, area.y + 4, color: :accent, size: 11)
76
+ end
77
+ end
78
+ end
79
+ def hit(point)
80
+ return unless @painted_palette.equal?(@workspace.palette)
81
+ @regions.reverse_each { |bounds, action| return action if bounds.contains?(point) }
82
+ nil
83
+ end
84
+ def offset_at(editor, point)
85
+ bounds = @editor_bounds.fetch(editor)
86
+ row = (((point.y - bounds.y) / @line_height) + editor.scroll_y).floor.clamp(0, editor.display_map.row_count - 1)
87
+ value = editor.display_map.row(row).text
88
+ layout = @row_layouts[[editor, row]] || @cx.text_system&.layout_line(value, size: @font_size)
89
+ x = point.x - bounds.x - gutter(editor) + editor.scroll_x
90
+ byte = layout ? layout.index_for_x(x) : (x / (@font_size * 0.6)).round.clamp(0, value.length)
91
+ column = layout ? value.byteslice(0, byte).length : byte
92
+ editor.display_map.to_buffer(DisplayPoint.new(row, column))
93
+ end
94
+ def project_scroll(delta)
95
+ @project_scroll = [@project_scroll + delta, 0].max
96
+ end
97
+
98
+ private
99
+ def paint_notifications(bounds)
100
+ @scene.layer(1_500_000) do
101
+ width = [420, bounds.width - 16].min
102
+ @workspace.notifications.reverse_each.with_index do |item, index|
103
+ area = Zaniah::Bounds.new(bounds.right - width - 8, bounds.bottom - 88 - index * 58, width, 50)
104
+ fill(area, :panel)
105
+ fill(Zaniah::Bounds.new(area.x, area.y, 3, area.height), :accent)
106
+ @scene.clip(area) { text(item[:text].lines.first.to_s, area.x + 12, area.y + 16, size: 12) }
107
+ close = Zaniah::Bounds.new(area.right - 28, area.y, 28, area.height)
108
+ fill(close, :panel)
109
+ text("×", close.x + 6, close.y + 14, color: :muted)
110
+ @accessibility << {role: :alert, label: item[:text], bounds: area}
111
+ region(close, role: :button, label: "Dismiss notification", action: [:dismiss_notification, item[:id]])
112
+ end
113
+ end
114
+ end
115
+ def fill(bounds, color)
116
+ @scene.quad(bounds.x, bounds.y, bounds.width, bounds.height, color: color.is_a?(Symbol) ? @theme[color] : color)
117
+ end
118
+ def text(value, x, y, color: :foreground, size: @font_size, font: nil)
119
+ color = @theme[color] if color.is_a?(Symbol)
120
+ value = value.to_s.encode(Encoding::UTF_8)
121
+ line = @cx.text_system&.layout_line(value, size: size, font: font)
122
+ @cx.text_system&.paint_line(@scene, line, x: x, y: y + line.ascent, color: color) if line
123
+ @cx.window.text_runs << [x, y, value, color]
124
+ line
125
+ end
126
+ def region(bounds, role:, label:, action:)
127
+ bounds = bounds.intersect(@viewport_bounds)
128
+ bounds = bounds.intersect(@scene.current_clip) if @scene.current_clip
129
+ return if bounds.width <= 0 || bounds.height <= 0
130
+ @regions << [bounds, action]
131
+ @accessibility << {role: role, label: label, bounds: bounds}
132
+ end
133
+ def paint_project(bounds)
134
+ fill(bounds, :panel)
135
+ text(File.basename(@workspace.root), 16, 12, color: :foreground, size: 13)
136
+ text("Files", 16, 43, color: :muted, size: 12)
137
+ files = @workspace.project_tree.visible
138
+ status = @workspace.git_status
139
+ count = [(bounds.height - 76) / 24, 0].max.floor
140
+ @project_scroll = @project_scroll.clamp(0, [files.length - count, 0].max)
141
+ @scene.clip(bounds) do
142
+ files.slice(@project_scroll.floor, count).to_a.each_with_index do |entry, index|
143
+ path = entry.path
144
+ row = Zaniah::Bounds.new(0, 70 + index * 24, bounds.width, 24)
145
+ active = @workspace.editor&.buffer&.path == File.expand_path(path, @workspace.root)
146
+ fill(row, :active_tab) if active
147
+ x = 16 + entry.depth * 12
148
+ if @cx.window.is_a?(Zaniah::Platform::TUI::Window)
149
+ label = entry.directory ? "#{entry.expanded ? '−' : '+'} #{entry.name}" : entry.name
150
+ else
151
+ icon = @workspace.icon_theme.texture(path, directory: entry.directory, expanded: entry.expanded, scale: @cx.window.scale_factor, color: @theme[active ? :foreground : :muted])
152
+ @scene.sprite(x, row.y + 3, 16, 16, texture: icon)
153
+ x += 22
154
+ label = entry.name
155
+ end
156
+ text(label, x, row.y + 3, color: active ? :foreground : :muted, size: 12)
157
+ if (change = status[path])
158
+ text(change.strip, bounds.right - 30, row.y + 3, color: change.include?("D") ? :error : :accent, size: 11)
159
+ end
160
+ region(row, role: :treeitem, label: path, action: [entry.directory ? :directory : :file, path])
161
+ end
162
+ end
163
+ fill(Zaniah::Bounds.new(bounds.right - 1, 0, 1, bounds.height), :border)
164
+ end
165
+ def paint_panels(side, bounds)
166
+ fill(bounds, :panel)
167
+ panels = @workspace.panels.select { |_, (position, _)| position == side }.to_a
168
+ return if panels.empty?
169
+ panels.each_with_index do |(name, (_, render)), index|
170
+ height = bounds.height / panels.length
171
+ area = Zaniah::Bounds.new(bounds.x + 8, bounds.y + height * index, [bounds.width - 16, 0].max, height)
172
+ text(name, area.x + 4, area.y + 10, color: :muted, size: 12)
173
+ key = [name, @workspace.editor.buffer.object_id, @workspace.editor.buffer.version, @theme.object_id]
174
+ @panel_cache ||= {}
175
+ @panel_cache.clear if @panel_cache.length > 50
176
+ element = @panel_cache[key] ||= render.call
177
+ element = Zaniah::Text.new(element.to_s, color: @theme[:foreground]) unless element.is_a?(Zaniah::Element)
178
+ root = element.request_layout(@cx)
179
+ Zaniah::Layout::Engine.new.compute(root, x: area.x, y: area.y + 34, width: area.width, height: [area.height - 34, 0].max)
180
+ @scene.clip(area) do
181
+ element.prepaint(root.bounds, nil, @cx)
182
+ element.paint(root.bounds, nil, nil, @cx)
183
+ end
184
+ rescue StandardError => error
185
+ text(error.message, area.x, area.y + 34, color: :error, size: 12)
186
+ end
187
+ end
188
+ def paint_layout(node, bounds)
189
+ return paint_pane(node[:pane], bounds) if node[:pane]
190
+ horizontal = node[:direction] == :horizontal
191
+ ratio = node.fetch(:ratio, 0.5)
192
+ node[:children].each_with_index do |child, index|
193
+ start, fraction = index.zero? ? [0, ratio] : [ratio, 1 - ratio]
194
+ part = if horizontal
195
+ Zaniah::Bounds.new(bounds.x + bounds.width * start, bounds.y, bounds.width * fraction, bounds.height)
196
+ else
197
+ Zaniah::Bounds.new(bounds.x, bounds.y + bounds.height * start, bounds.width, bounds.height * fraction)
198
+ end
199
+ paint_layout(child, part)
200
+ end
201
+ divider = horizontal ? Zaniah::Bounds.new(bounds.x + bounds.width * ratio - 2, bounds.y, 4, bounds.height) : Zaniah::Bounds.new(bounds.x, bounds.y + bounds.height * ratio - 2, bounds.width, 4)
202
+ fill(divider, :border)
203
+ region(divider, role: :separator, label: "Resize split", action: [:split_resize, node, bounds])
204
+ end
205
+ def paint_pane(pane, bounds)
206
+ region(bounds, role: :group, label: "Pane", action: [:pane, pane])
207
+ fill(Zaniah::Bounds.new(bounds.x, bounds.y, bounds.width, 34), :panel)
208
+ @scene.clip(bounds) do
209
+ x = bounds.x
210
+ pane.editors.each do |editor|
211
+ name = editor.buffer.path ? File.basename(editor.buffer.path) : "Untitled"
212
+ name += " *" if editor.buffer.dirty?
213
+ name = "[#{name}]" if pane.pinned.include?(editor)
214
+ width = [[name.length * 7.5 + 30, 100].max, 240].min
215
+ tab = Zaniah::Bounds.new(x, bounds.y, width, 34)
216
+ fill(tab, :active_tab) if pane.active.equal?(editor)
217
+ if pane.active.equal?(editor) && pane == @workspace.active_pane
218
+ fill(Zaniah::Bounds.new(x, bounds.y, width, 2), :accent)
219
+ end
220
+ text(name, x + 14, bounds.y + 10, color: pane.active.equal?(editor) ? :foreground : :muted, size: 12)
221
+ region(tab, role: :tab, label: name, action: [:tab, pane, editor])
222
+ x += width
223
+ end
224
+ end
225
+ editor = pane.active
226
+ return unless editor
227
+ area = Zaniah::Bounds.new(bounds.x, bounds.y + 34, bounds.width, [bounds.height - 34, 0].max)
228
+ @editor_bounds[editor] = area
229
+ region(area, role: :textbox, label: editor.buffer.path || "Untitled document", action: [:editor, pane, editor])
230
+ paint_editor(editor, area, pane == @workspace.active_pane)
231
+ fill(Zaniah::Bounds.new(bounds.right - 1, bounds.y, 1, bounds.height), :border)
232
+ end
233
+ def gutter(editor) = [editor.buffer.line_count.to_s.length * @font_size * 0.6 + 24, 52].max
234
+ def paint_editor(editor, bounds, active)
235
+ @selection_spans = {}
236
+ editor.viewport_rows = [(bounds.height / @line_height).floor, 1].max
237
+ map, first = editor.display_map, editor.scroll_y.floor
238
+ last = [first + editor.viewport_rows + 1, map.row_count].min
239
+ hunks = @workspace.git_hunks(editor.buffer)
240
+ cursor_offset = @workspace.settings["vim_mode"] && active ? @workspace.vim.cursor_position : editor.primary.head
241
+ cursor = map.to_display(cursor_offset)
242
+ if map.wrap_map.width && !editor.buffer.read_only
243
+ width = [bounds.width - gutter(editor) - 16, 1].max
244
+ if @cx.window.is_a?(Zaniah::Platform::TUI::Window)
245
+ map.wrap_width = [(width / 8).floor, 1].max
246
+ elsif (system = @cx.text_system)
247
+ begin
248
+ map.wrap_pixels(width, typesetter: system, font_size: @font_size)
249
+ rescue Zaniah::TextSystem::Typesetter::CopyError => error
250
+ map.wrap_width = nil
251
+ @workspace.message = "Soft wrap disabled: #{error.message}"
252
+ end
253
+ end
254
+ last = [first + editor.viewport_rows + 1, map.row_count].min
255
+ cursor = map.to_display(cursor_offset)
256
+ end
257
+ cursor_row = map.row(cursor.row)
258
+ cursor_line = @cx.text_system&.layout_line(cursor_row.text, size: @font_size)
259
+ brackets = []
260
+ if active && editor.buffer.rope.bytesize <= 1 << 20 && !editor.buffer.read_only
261
+ column = cursor.column
262
+ column -= 1 unless "()[]{}".include?(cursor_row.text[column].to_s) && !cursor_row.text[column].to_s.empty?
263
+ if column >= 0 && (character = cursor_row.text[column]) && "()[]{}".include?(character)
264
+ range = editor.language_document.bracket_at(map.to_buffer(DisplayPoint.new(cursor.row, column)))
265
+ brackets = [map.to_display(range.begin), map.to_display(range.end - 1)] if range
266
+ end
267
+ end
268
+ reveal_key = [cursor_offset, editor.buffer.version, bounds.width]
269
+ if active && @revealed_cursors[editor] != reveal_key
270
+ x = column_x(cursor_row.text, cursor_line, cursor.column)
271
+ visible_width = [bounds.width - gutter(editor) - 18, 1].max
272
+ editor.scroll(dx: x < editor.scroll_x ? x - editor.scroll_x : [x - editor.scroll_x - visible_width, 0].max)
273
+ @revealed_cursors[editor] = reveal_key
274
+ end
275
+ left = bounds.x + gutter(editor) - editor.scroll_x
276
+ measured_width = cursor_line&.width || cursor_row.text.length * @font_size * 0.6
277
+ @scene.clip(bounds) do
278
+ (first...last).each do |index|
279
+ row = map.row(index)
280
+ y = bounds.y + (index - editor.scroll_y) * @line_height + 4
281
+ fill(Zaniah::Bounds.new(bounds.x, y - 2, bounds.width, @line_height), :current_line) if cursor.row == index
282
+ source_row = map.source_row(index)
283
+ change = hunks.find { |hunk| (source_row + 1).between?([hunk.new_start, 1].max, [hunk.new_start + hunk.new_count - 1, hunk.new_start].max) }
284
+ if change
285
+ color = change.new_count.zero? ? @theme[:error] : change.old_count.zero? ? "#80b987" : @theme[:accent]
286
+ fill(Zaniah::Bounds.new(bounds.x + 2, y - 1, 3, @line_height), color)
287
+ region(Zaniah::Bounds.new(bounds.x, y - 1, 10, @line_height), role: :button, label: "Toggle Git hunk", action: [:git_hunk, editor, source_row]) if row.kind == :text
288
+ end
289
+ number = editor.relative_line_numbers ? (source_row - editor.buffer.rope.point_at(editor.primary.head).row).abs : source_row + 1
290
+ number = source_row + 1 if number.zero?
291
+ text(number, bounds.x + 12, y, color: cursor.row == index ? :foreground : :muted, size: @font_size - 1) if row.kind == :text
292
+ line = @cx.text_system&.layout_line(row.text, size: @font_size)
293
+ measured_width = [measured_width, line&.width || row.text.length * @font_size * 0.6].max
294
+ @row_layouts[[editor, index]] = line
295
+ paint_selections(editor, index, row, line, left, y)
296
+ brackets.each do |point|
297
+ next unless point.row == index
298
+ x = column_x(row.text, line, point.column)
299
+ width = [column_x(row.text, line, point.column + 1) - x, 4].max
300
+ fill(Zaniah::Bounds.new(left + x, y + @line_height - 2, width, 2), :accent)
301
+ end
302
+ if row.kind == :text && line
303
+ spans = code_spans(editor, source_row, row)
304
+ @cx.text_system.paint_line(@scene, line, x: left, y: y + line.ascent, color: @theme[:foreground], spans: spans)
305
+ @cx.window.text_runs << [left, y, row.text, @theme[:foreground]]
306
+ else
307
+ color = if row.kind == :git_diff
308
+ row.text.start_with?("+") ? "#80b987" : row.text.start_with?("-") ? :error : :muted
309
+ else
310
+ row.kind == :text ? :foreground : :error
311
+ end
312
+ text(row.text, left, y, color: color)
313
+ end
314
+ paint_diagnostics(editor, index, row, line, left, y) if row.kind == :text
315
+ if cursor.row == index && active
316
+ x = left + column_x(row.text, line, cursor.column)
317
+ width = @workspace.settings["vim_mode"] && @workspace.vim.mode != :insert ? @font_size * 0.6 : 1.5
318
+ fill(Zaniah::Bounds.new(x, y, width, @line_height - 2), width > 2 ? "#e9a66177" : :cursor) if @cursor_visible || editor.composition
319
+ @cx.window.ime_state = Zaniah::Bounds.new(x, y, 2, @line_height)
320
+ if editor.composition && !editor.composition.text.empty?
321
+ composition = text(editor.composition.text, x, y, color: :accent)
322
+ fill(Zaniah::Bounds.new(x, y + @line_height - 2, composition&.width || 20, 1), :accent)
323
+ end
324
+ end
325
+ end
326
+ end
327
+ previous_width = @line_widths[editor]
328
+ measured_width = [measured_width, previous_width.last].max if previous_width && previous_width.first == editor.buffer.version
329
+ @line_widths[editor] = [editor.buffer.version, measured_width]
330
+ paint_scrollbars(editor, bounds, measured_width, hunks)
331
+ end
332
+ def paint_scrollbars(editor, bounds, content_width, hunks)
333
+ track = Zaniah::Bounds.new(bounds.right - 9, bounds.y, 9, [bounds.height - 9, 0].max)
334
+ total = editor.display_map.row_count
335
+ maximum = [total - editor.viewport_rows, 0].max
336
+ if maximum.positive? && track.height.positive?
337
+ height = [24, track.height * editor.viewport_rows / total].max.clamp(0, track.height)
338
+ thumb = Zaniah::Bounds.new(track.x + 2, track.y + (track.height - height) * editor.scroll_y / maximum, 5, height)
339
+ fill(thumb, :muted)
340
+ hunks.first(500).each do |hunk|
341
+ y = track.y + track.height * [hunk.new_start - 1, 0].max / [editor.buffer.line_count, 1].max
342
+ fill(Zaniah::Bounds.new(track.x, y, 3, 2), :accent)
343
+ end
344
+ frame_diagnostics(editor.buffer).first(500).each do |diagnostic|
345
+ row = diagnostic.dig("range", "start", "line")
346
+ next unless row.is_a?(Integer) && row >= 0
347
+ y = track.y + track.height * row / [editor.buffer.line_count, 1].max
348
+ fill(Zaniah::Bounds.new(track.x + 5, y, 4, 2), :error)
349
+ end
350
+ region(track, role: :scrollbar, label: "Vertical scroll", action: [:scrollbar, editor, :vertical, track, thumb, maximum])
351
+ end
352
+ track = Zaniah::Bounds.new(bounds.x + gutter(editor), bounds.bottom - 9, [bounds.width - gutter(editor) - 9, 0].max, 9)
353
+ maximum = [content_width - track.width + 12, 0].max
354
+ if maximum.positive? && track.width.positive?
355
+ width = [24, track.width * track.width / (content_width + 12)].max.clamp(0, track.width)
356
+ thumb = Zaniah::Bounds.new(track.x + (track.width - width) * [editor.scroll_x / maximum, 1].min, track.y + 2, width, 5)
357
+ fill(thumb, :muted)
358
+ region(track, role: :scrollbar, label: "Horizontal scroll", action: [:scrollbar, editor, :horizontal, track, thumb, maximum])
359
+ end
360
+ end
361
+ def column_x(value, line, column)
362
+ line ? line.x_for_index(value[0, column].to_s.bytesize) : column * @font_size * 0.6
363
+ end
364
+ def code_spans(editor, source_row, row)
365
+ return [] if editor.buffer.rope.respond_to?(:lazy?) && editor.buffer.rope.lazy?
366
+ raw = editor.language_document.tokens_for(source_row)
367
+ semantic_state = @workspace.semantic_styles&.[](editor.buffer)
368
+ semantic_state = nil unless semantic_state && semantic_state.first == editor.buffer.version
369
+ cache = @code_caches[editor] ||= {}
370
+ cached = cache[row.object_id]
371
+ return cached[3] if cached && cached[4].equal?(row) && cached[0] == raw && cached[1].equal?(@theme) && cached[2].equal?(semantic_state)
372
+ offset = 0
373
+ tokens = raw.map do |token, value|
374
+ start = offset
375
+ offset += value.bytesize
376
+ [start, offset, @theme.token_color(token)]
377
+ end
378
+ spans, index, byte = [], 0, 0
379
+ semantic = @workspace.semantic_spans_for(editor.buffer, source_row)
380
+ row.text.each_codepoint.with_index do |character, column|
381
+ length = character < 0x80 ? 1 : character < 0x800 ? 2 : character < 0x10000 ? 3 : 4
382
+ original = row.offsets[column]
383
+ index += 1 while index < tokens.length && original >= tokens[index][1]
384
+ color = tokens[index] && original >= tokens[index][0] ? tokens[index][2] : @theme[:foreground]
385
+ overlay = semantic.find { |first, last, _| original >= first && original < last }
386
+ color = overlay[2] if overlay
387
+ if spans.last && spans.last[2] == color
388
+ spans.last[1] += length
389
+ else
390
+ spans << [byte, byte + length, color]
391
+ end
392
+ byte += length
393
+ end
394
+ cache.shift if cache.length >= 512
395
+ spans.each(&:freeze).freeze
396
+ cache[row.object_id] = [raw, @theme, semantic_state, spans, row]
397
+ spans
398
+ end
399
+ def paint_diagnostics(editor, index, row, line, left, y)
400
+ frame_diagnostics(editor.buffer).each do |diagnostic|
401
+ range = diagnostic["range"]
402
+ first = editor.display_map.to_display(LSP::Protocol.offset(editor.buffer.rope, range.fetch("start")))
403
+ last = editor.display_map.to_display(LSP::Protocol.offset(editor.buffer.rope, range.fetch("end")))
404
+ next unless index.between?(first.row, last.row)
405
+ from = index == first.row ? first.column : 0
406
+ to = index == last.row ? last.column : row.text.length
407
+ x = column_x(row.text, line, from)
408
+ width = [column_x(row.text, line, to) - x, 6].max
409
+ @scene.underline(left + x, y + @line_height - 2, width, color: @theme[:error], wave: true)
410
+ rescue RangeError, KeyError
411
+ next # Stale server positions must not break a frame after a local edit.
412
+ end
413
+ end
414
+ def frame_diagnostics(buffer)
415
+ @frame_diagnostics[buffer] ||= @workspace.diagnostics_for(buffer)
416
+ end
417
+ def paint_selections(editor, index, row, line, left, y)
418
+ editor.selections.each do |selection|
419
+ next if selection.empty?
420
+ if editor.buffer.rope.respond_to?(:lazy?)
421
+ base = editor.buffer.rope.line_start(index)
422
+ first = selection.start - base
423
+ last = selection.end - base
424
+ next if last < row.offsets.first || first > row.offsets.last
425
+ from = row.offsets.bsearch_index { |offset| offset >= first } || row.text.length
426
+ to = row.offsets.bsearch_index { |offset| offset >= last } || row.text.length
427
+ x = column_x(row.text, line, from)
428
+ fill(Zaniah::Bounds.new(left + x, y - 1, column_x(row.text, line, to) - x, @line_height), :selection)
429
+ next
430
+ end
431
+ span = selection_span(editor, selection, index)
432
+ next unless span
433
+ before = selection_span(editor, selection, index - 1)
434
+ after = selection_span(editor, selection, index + 1)
435
+ x, right = span
436
+ # Only exposed convex corners are rounded; no alpha-overlapping bridge
437
+ # and no convex hull that would highlight unselected source text.
438
+ radii = [before && x >= before[0] && x < before[1] ? 0 : 3,
439
+ before && right > before[0] && right <= before[1] ? 0 : 3,
440
+ after && right > after[0] && right <= after[1] ? 0 : 3,
441
+ after && x >= after[0] && x < after[1] ? 0 : 3]
442
+ @scene.quad(left + x, y - 1, right - x, @line_height, color: @theme[:selection], radius: radii)
443
+ end
444
+ end
445
+ def selection_span(editor, selection, index)
446
+ cache = @selection_spans[selection] ||= {first: editor.display_map.to_display(selection.start), last: editor.display_map.to_display(selection.end)}
447
+ return cache[index] if cache.key?(index)
448
+ first, last = cache.values_at(:first, :last)
449
+ return unless index.between?(first.row, last.row)
450
+ row = editor.display_map.row(index)
451
+ return unless row.kind == :text
452
+ line = @cx.text_system&.layout_line(row.text, size: @font_size)
453
+ from = index == first.row ? first.column : 0
454
+ to = index == last.row ? last.column : row.text.length + 1
455
+ x = column_x(row.text, line, from)
456
+ right = column_x(row.text, line, [to, row.text.length].min)
457
+ right += @font_size * 0.6 if to > row.text.length
458
+ cache[index] = right > x ? [x, right] : nil
459
+ end
460
+ def paint_terminal(bounds)
461
+ render_terminal(bounds)
462
+ end
463
+ def paint_status(bounds)
464
+ fill(bounds, :status)
465
+ editor = @workspace.editor
466
+ return unless editor
467
+ point = editor.buffer.rope.point_at(editor.primary.head)
468
+ mode = @workspace.settings["vim_mode"] ? @workspace.vim.mode.to_s : "edit"
469
+ left = "#{mode} #{@workspace.message}"
470
+ branch = @workspace.git&.branch
471
+ left = "#{branch} #{left}" if branch
472
+ left = ":#{@workspace.vim.command_line}" if @workspace.settings["vim_mode"] && @workspace.vim.command_line
473
+ diagnostics = frame_diagnostics(editor.buffer).length
474
+ language = editor.language_document.definition.name
475
+ lsp = @workspace.clients[language]
476
+ right = "#{diagnostics.zero? ? '' : "!#{diagnostics} "}#{language}#{lsp ? " LSP:#{lsp.state}" : ''} #{point.row + 1}:#{point.column + 1} #{editor.use_tabs ? 'Tab' : 'Spaces'}:#{editor.tab_size} #{editor.buffer.encoding.name}"
477
+ @scene.clip(bounds) do
478
+ text(left, 12, bounds.y + 6, color: :foreground, size: 12)
479
+ text(right, [bounds.width - right.length * 7.2 - 16, bounds.width * 0.5].max, bounds.y + 6, color: :muted, size: 12)
480
+ end
481
+ end
482
+ def paint_hover(bounds)
483
+ require_relative "../markdown"
484
+ source = @workspace.hover_card
485
+ if @hover_source != source || @hover_markup != @workspace.hover_markup
486
+ @hover_document = Markdown.new(source, markup: @workspace.hover_markup != false)
487
+ @hover_source = source.dup.freeze
488
+ @hover_markup = @workspace.hover_markup
489
+ end
490
+ lines = @hover_document.rows
491
+ width = [[lines.map { |row| row.sum { |run| Unicode::DisplayWidth.of(run.text, emoji: :rgi) } }.max.to_i * 7.8 + 24, 180].max, bounds.width - 32].min
492
+ box = Zaniah::Bounds.new([bounds.width - width - 24, 8].max, 64, width, lines.length * 20 + 20)
493
+ fill(box, :panel)
494
+ @scene.quad(box.x, box.y, box.width, box.height, color: "#0000", radius: 5, border_width: 1, border_color: @theme[:border])
495
+ @scene.clip(box) do
496
+ lines.each_with_index do |runs, index|
497
+ x, y = box.x + 12, box.y + 10 + index * 20
498
+ runs.each do |run|
499
+ color = case run.style
500
+ when :heading, :link then :accent
501
+ when :quote then :muted
502
+ when :code then @theme.token_color("Literal.String")
503
+ when String then @theme.token_color(run.style)
504
+ else :foreground
505
+ end
506
+ system = @cx.text_system
507
+ font = if system.respond_to?(:font_db) && [:heading, :strong, :emphasis].include?(run.style)
508
+ system.font_db.find(family: @workspace.settings["font_family"], weight: run.style == :emphasis ? 400 : 700, style: run.style == :emphasis ? :italic : :normal)
509
+ end
510
+ layout = text(run.text, x, y, color: color, size: 13, font: font)
511
+ run_width = layout&.width || Unicode::DisplayWidth.of(run.text, emoji: :rgi) * 8
512
+ if run.style == :link && run.url&.match?(/\Ahttps?:\/\//i)
513
+ region(Zaniah::Bounds.new(x, y, run_width, 20), role: :link, label: run.text, action: [:hover_link, run.url])
514
+ end
515
+ x += run_width
516
+ end
517
+ end
518
+ end
519
+ end
520
+ def confirmation_width(value, size = 11)
521
+ @cx.text_system&.layout_line(value, size: size)&.width || Unicode::DisplayWidth.of(value, emoji: :rgi) * 8
522
+ end
523
+ def confirmation_lines(value, width)
524
+ lines, line = [], +""
525
+ value.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace).scrub.each_grapheme_cluster do |grapheme|
526
+ # Show control characters and over-wide graphemes as readable escapes;
527
+ # never silently clip part of a target filename.
528
+ escaped = grapheme.match?(/[\x00-\x1f\x7f]/) || confirmation_width(grapheme) > width
529
+ pieces = escaped ? grapheme.codepoints.map { |point| "\\u{#{point.to_s(16)}}" }.join.each_char : [grapheme]
530
+ pieces.each do |piece|
531
+ if !line.empty? && confirmation_width(line + piece) > width
532
+ lines << line
533
+ line = +""
534
+ end
535
+ line << piece
536
+ end
537
+ end
538
+ lines << line unless line.empty?
539
+ lines
540
+ end
541
+ def paint_workspace_edit(bounds, palette)
542
+ require "unicode/display_width"
543
+ compact = bounds.width < 360 || bounds.height < 240
544
+ margin, padding = compact ? [4, 4] : [16, 12]
545
+ width = [bounds.width - margin * 2, 600].min
546
+ inner_width = width - padding * 2
547
+ sources = [palette[:query], *palette.fetch(:details, [])]
548
+ cache_key = [inner_width, @cx.text_system, sources]
549
+ if palette[:detail_wrap_key] != cache_key
550
+ palette[:detail_lines] = sources.flat_map { |source| confirmation_lines(source, inner_width) }
551
+ palette[:detail_lines] = [""] if palette[:detail_lines].empty?
552
+ palette[:detail_wrap_key] = [inner_width, @cx.text_system, sources.map(&:dup)]
553
+ end
554
+ lines = palette[:detail_lines]
555
+ rows = [lines.length, 8, [((bounds.height - margin * 2 - padding * 2 - 62) / 18).floor, 1].max].min
556
+ palette[:detail_rows] = rows
557
+ palette[:details_scroll] = palette.fetch(:details_scroll, 0).clamp(0, [lines.length - rows, 0].max)
558
+ start = palette[:details_scroll]
559
+ box = Zaniah::Bounds.new((bounds.width - width) / 2, margin, width, padding * 2 + 62 + rows * 18)
560
+ fill(bounds, "#0007")
561
+ @scene.quad(box.x, box.y, box.width, box.height, color: @theme[:panel], radius: 5, border_width: 1, border_color: @theme[:border])
562
+ @scene.clip(box) do
563
+ heading = compact ? "#{start + 1}/#{lines.length}" : "#{start + 1}–#{start + rows} / #{lines.length} PageUp / PageDown"
564
+ text(heading, box.x + padding, box.y + padding, color: :muted, size: 11)
565
+ visible = lines.slice(start, rows)
566
+ visible.each_with_index { |value, index| text(value, box.x + padding, box.y + padding + 18 + index * 18, size: 11) }
567
+ @accessibility << {role: :text, label: visible.join("\n"), bounds: box}
568
+ palette[:matches].each_with_index do |match, index|
569
+ row = Zaniah::Bounds.new(box.x + padding, box.y + padding + 18 + rows * 18 + index * 22, inner_width, 22)
570
+ fill(row, :active_tab) if index == palette[:index]
571
+ label = compact ? ["Apply", "Cancel"].fetch(index) : match
572
+ @scene.clip(row) { text(label, row.x + 2, row.y + 2, size: 11) }
573
+ region(row, role: :option, label: match, action: [:palette, index])
574
+ end
575
+ end
576
+ end
577
+ def shortcut_labels
578
+ return {} unless @keymap
579
+ context = {"Editor" => true, "vim_mode" => @workspace.settings["vim_mode"] ? @workspace.vim.mode.to_s : false}
580
+ seen, labels = {}, {}
581
+ @keymap.bindings.reverse_each do |binding|
582
+ next unless binding.predicate.call(context)
583
+ next if seen.keys.any? { |keys| keys.first(binding.keys.length) == binding.keys || binding.keys.first(keys.length) == keys }
584
+ seen[binding.keys] = true
585
+ next if !RUBY_PLATFORM.include?("darwin") && binding.keys.any? { |key| key.split("-").include?("cmd") }
586
+ labels[binding.action] ||= binding.keys.join(" ") if binding.action
587
+ end
588
+ labels
589
+ end
590
+ def paint_palette(bounds)
591
+ palette = @workspace.palette
592
+ return paint_workspace_edit(bounds, palette) if palette[:kind] == :workspace_edit
593
+ matches = palette[:matches]
594
+ shortcuts = palette[:kind] == :commands ? shortcut_labels : {}
595
+ preview = palette[:kind] == :files && matches[palette[:index]] && bounds.width >= 850
596
+ width = [bounds.width - 32, preview ? 900 : 600].min
597
+ height = [90 + matches.length * 28, preview ? 330 : 0].max
598
+ box = Zaniah::Bounds.new((bounds.width - width) / 2, [50, bounds.height * 0.1].min, width, [height, bounds.height - 80].min)
599
+ list_width = preview ? width * 0.52 : width
600
+ fill(bounds, "#0007")
601
+ @scene.shadow(box.x, box.y, box.width, box.height, color: "#0008", blur: 14)
602
+ @scene.quad(box.x, box.y, box.width, box.height, color: @theme[:panel], radius: 8, border_width: 1, border_color: @theme[:border])
603
+ labels = {commands: "Command palette", files: "Open file", search: "Find in buffer", save_as: "Save as", confirm_close: "Unsaved changes"}
604
+ title = labels.fetch(palette[:kind], palette[:kind].to_s)
605
+ if palette[:search_options]
606
+ title += " Ctrl+Alt " + {regexp: "R:regex", case_sensitive: "C:case", whole_word: "W:word", selection_only: "S:selection"}.filter_map do |key, label|
607
+ "#{label}=#{palette[:search_options][key] ? 'on' : 'off'}" unless key == :selection_only && palette[:kind] == :project_search
608
+ end.join(" ")
609
+ end
610
+ @scene.clip(box) { text(title, box.x + 16, box.y + 12, color: :muted, size: palette[:search_options] ? 10 : 12) }
611
+ text(palette[:query].empty? ? "Type here…" : palette[:query], box.x + 16, box.y + 36, color: palette[:query].empty? ? :muted : :foreground)
612
+ @scene.clip(box) do
613
+ matches.each_with_index do |match, index|
614
+ row = Zaniah::Bounds.new(box.x + 6, box.y + 74 + index * 28, list_width - 12, 28)
615
+ fill(row, :active_tab) if index == palette[:index]
616
+ @scene.clip(row) { text(match, row.x + 10, row.y + 5, size: 13) }
617
+ if palette[:kind] == :commands
618
+ shortcut = shortcuts[match]
619
+ if shortcut
620
+ label = shortcut.tr("-", " ")
621
+ x = row.right - label.length * 6.5 - 10
622
+ fill(Zaniah::Bounds.new(x - 4, row.y + 2, row.right - x, 24), index == palette[:index] ? :active_tab : :panel)
623
+ text(label, x, row.y + 6, color: :muted, size: 11)
624
+ end
625
+ end
626
+ region(row, role: :option, label: match, action: [:palette, index])
627
+ end
628
+ if preview
629
+ area = Zaniah::Bounds.new(box.x + list_width, box.y + 74, width - list_width, box.height - 80)
630
+ fill(Zaniah::Bounds.new(area.x, area.y, 1, area.height), :border)
631
+ @scene.clip(area) do
632
+ @workspace.file_preview(matches[palette[:index]]).first((area.height / 18).floor).each_with_index do |line, index|
633
+ text("#{index + 1} #{line}", area.x + 12, area.y + index * 18, color: :muted, size: 11)
634
+ end
635
+ end
636
+ end
637
+ end
638
+ end
639
+ end
640
+ end
641
+
642
+ require_relative "view/terminal_presentable"
643
+ Canopus::Workspace::View.include Canopus::Workspace::View::TerminalPresentable