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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +148 -0
- data/docs/adr/001-runtime-gem-components.md +24 -0
- data/docs/adr/002-bounded-diff-engine.md +22 -0
- data/docs/adr/003-background-language-analysis.md +22 -0
- data/docs/adr/004-persistent-display-map.md +23 -0
- data/docs/adr/005-windows-runtime.md +27 -0
- data/docs/adr/README.md +36 -0
- data/docs/distribution.md +27 -0
- data/docs/lsp.md +38 -0
- data/docs/performance.md +40 -0
- data/docs/snippets.md +32 -0
- data/docs/vim.md +24 -0
- data/docs/workspace_edits.md +32 -0
- data/examples/native_smoke.rb +25 -0
- data/examples/plugins/word_count.rb +11 -0
- data/exe/canopus +19 -0
- data/lib/canopus/block_map.rb +19 -0
- data/lib/canopus/buffer.rb +293 -0
- data/lib/canopus/cli.rb +171 -0
- data/lib/canopus/controller.rb +507 -0
- data/lib/canopus/data_compat.rb +25 -0
- data/lib/canopus/display_map/line_builder.rb +54 -0
- data/lib/canopus/display_map/line_set.rb +8 -0
- data/lib/canopus/display_map/pending_line_set.rb +9 -0
- data/lib/canopus/display_map/summary.rb +23 -0
- data/lib/canopus/display_map/worker.rb +89 -0
- data/lib/canopus/display_map.rb +350 -0
- data/lib/canopus/display_point.rb +5 -0
- data/lib/canopus/editor/snippet_expandable.rb +281 -0
- data/lib/canopus/editor.rb +445 -0
- data/lib/canopus/error.rb +5 -0
- data/lib/canopus/fold_map.rb +87 -0
- data/lib/canopus/git/blame.rb +50 -0
- data/lib/canopus/git/commit.rb +7 -0
- data/lib/canopus/git/corrupt_object.rb +7 -0
- data/lib/canopus/git/diff.rb +131 -0
- data/lib/canopus/git/index.rb +94 -0
- data/lib/canopus/git/object_database.rb +45 -0
- data/lib/canopus/git/pack.rb +186 -0
- data/lib/canopus/git/repository.rb +313 -0
- data/lib/canopus/git/status.rb +74 -0
- data/lib/canopus/git/tree_entry.rb +7 -0
- data/lib/canopus/git.rb +15 -0
- data/lib/canopus/icon_theme.rb +43 -0
- data/lib/canopus/language/background_analysis/job.rb +12 -0
- data/lib/canopus/language/background_analysis/scheduler.rb +70 -0
- data/lib/canopus/language/background_analysis.rb +280 -0
- data/lib/canopus/language/definition.rb +7 -0
- data/lib/canopus/language/document.rb +143 -0
- data/lib/canopus/language/symbol.rb +7 -0
- data/lib/canopus/language/syntax_worker.rb +91 -0
- data/lib/canopus/language.rb +42 -0
- data/lib/canopus/lazy_rope.rb +218 -0
- data/lib/canopus/lsp/client.rb +299 -0
- data/lib/canopus/lsp/error.rb +7 -0
- data/lib/canopus/lsp/future/subscription.rb +9 -0
- data/lib/canopus/lsp/future.rb +87 -0
- data/lib/canopus/lsp/protocol.rb +83 -0
- data/lib/canopus/lsp/server_error.rb +13 -0
- data/lib/canopus/lsp/timeout.rb +7 -0
- data/lib/canopus/lsp/transport.rb +123 -0
- data/lib/canopus/lsp.rb +19 -0
- data/lib/canopus/markdown.rb +69 -0
- data/lib/canopus/match_data_compat.rb +17 -0
- data/lib/canopus/multi_buffer.rb +266 -0
- data/lib/canopus/pane.rb +61 -0
- data/lib/canopus/patch/composite.rb +12 -0
- data/lib/canopus/patch/reload.rb +18 -0
- data/lib/canopus/patch.rb +27 -0
- data/lib/canopus/performance_recorder.rb +207 -0
- data/lib/canopus/plugins/api.rb +50 -0
- data/lib/canopus/plugins/isolated_runtime.rb +167 -0
- data/lib/canopus/plugins/local_runtime.rb +25 -0
- data/lib/canopus/plugins/permission_denied.rb +7 -0
- data/lib/canopus/plugins/registry.rb +30 -0
- data/lib/canopus/plugins.rb +11 -0
- data/lib/canopus/project/ignore_matcher.rb +104 -0
- data/lib/canopus/project/search.rb +164 -0
- data/lib/canopus/project/search_worker/cancelled.rb +3 -0
- data/lib/canopus/project/search_worker/runner.rb +9 -0
- data/lib/canopus/project/search_worker.rb +108 -0
- data/lib/canopus/project/tree.rb +48 -0
- data/lib/canopus/project/watcher.rb +59 -0
- data/lib/canopus/project.rb +127 -0
- data/lib/canopus/regexp_compat.rb +30 -0
- data/lib/canopus/save_conflict.rb +5 -0
- data/lib/canopus/selection.rb +11 -0
- data/lib/canopus/settings.rb +118 -0
- data/lib/canopus/snippet/transform.rb +209 -0
- data/lib/canopus/snippet.rb +183 -0
- data/lib/canopus/tab_map.rb +30 -0
- data/lib/canopus/terminal/cell.rb +7 -0
- data/lib/canopus/terminal/grid.rb +321 -0
- data/lib/canopus/terminal/pty.rb +103 -0
- data/lib/canopus/terminal/scrollback.rb +38 -0
- data/lib/canopus/terminal/vt.rb +398 -0
- data/lib/canopus/terminal.rb +13 -0
- data/lib/canopus/theme.rb +43 -0
- data/lib/canopus/version.rb +5 -0
- data/lib/canopus/vim/commandable.rb +148 -0
- data/lib/canopus/vim/motionable.rb +321 -0
- data/lib/canopus/vim/operator_capable.rb +377 -0
- data/lib/canopus/vim/text_object_selectable.rb +141 -0
- data/lib/canopus/vim.rb +426 -0
- data/lib/canopus/workspace/edit/executable.rb +139 -0
- data/lib/canopus/workspace/edit/node.rb +8 -0
- data/lib/canopus/workspace/edit/plan.rb +170 -0
- data/lib/canopus/workspace/edit/resource_preparable.rb +98 -0
- data/lib/canopus/workspace/edit.rb +6 -0
- data/lib/canopus/workspace/file_change_aware.rb +40 -0
- data/lib/canopus/workspace/file_previewable.rb +31 -0
- data/lib/canopus/workspace/git_aware.rb +140 -0
- data/lib/canopus/workspace/language_aware.rb +413 -0
- data/lib/canopus/workspace/language_server_configurable.rb +181 -0
- data/lib/canopus/workspace/project_searchable.rb +208 -0
- data/lib/canopus/workspace/project_tree_editable.rb +82 -0
- data/lib/canopus/workspace/session_persistable.rb +153 -0
- data/lib/canopus/workspace/settings_aware.rb +91 -0
- data/lib/canopus/workspace/view/terminal_presentable.rb +185 -0
- data/lib/canopus/workspace/view.rb +643 -0
- data/lib/canopus/workspace.rb +525 -0
- data/lib/canopus/wrap_map.rb +108 -0
- data/lib/canopus.rb +24 -0
- data/sig/canopus.rbs +375 -0
- data/sig/controller.rbs +55 -0
- data/sig/display_stages.rbs +49 -0
- data/sig/git.rbs +140 -0
- data/sig/lsp.rbs +99 -0
- data/sig/markdown.rbs +11 -0
- data/sig/performance_recorder.rbs +23 -0
- data/sig/search_services.rbs +8 -0
- data/sig/services.rbs +81 -0
- data/sig/snippet.rbs +42 -0
- data/sig/terminal.rbs +125 -0
- data/sig/vim.rbs +27 -0
- data/sig/workspace_edits.rbs +11 -0
- data/sig/workspace_services.rbs +117 -0
- data/tools/certify_snippet_regex.rb +35 -0
- data/tools/check_dependencies.rb +80 -0
- data/tools/native_check.rb +70 -0
- data/tools/package.rb +113 -0
- data/tools/package_test.rb +32 -0
- 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
|