canopus 0.1.0 → 0.2.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.
@@ -5,7 +5,8 @@ require_relative "workspace/view"
5
5
  module Canopus
6
6
  class Controller
7
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",
8
+ "cmd-w" => "tab.close", "ctrl-w" => "tab.close", "cmd-alt-w" => "tab.close_all", "ctrl-alt-w" => "tab.close_all",
9
+ "cmd-shift-t" => "tab.reopen_closed", "ctrl-shift-t" => "tab.reopen_closed", "cmd-p" => "file.find", "ctrl-p" => "file.find",
9
10
  "cmd-shift-p" => "command.palette", "ctrl-shift-p" => "command.palette", "cmd-z" => "edit.undo", "ctrl-z" => "edit.undo",
10
11
  "cmd-shift-z" => "edit.redo", "ctrl-shift-z" => "edit.redo", "cmd-a" => "edit.select_all", "ctrl-a" => "edit.select_all",
11
12
  "cmd-d" => "edit.select_next", "ctrl-d" => "edit.select_next", "cmd-f" => "search.buffer", "ctrl-f" => "search.buffer",
@@ -13,10 +14,15 @@ module Canopus
13
14
  "alt-up" => "edit.move_line_up", "alt-down" => "edit.move_line_down",
14
15
  "cmd-shift-f" => "search.project", "ctrl-shift-f" => "search.project", "cmd-alt-f" => "search.replace", "ctrl-h" => "search.replace",
15
16
  "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-`" => "view.terminal", "ctrl-shift-`" => "terminal.new", "cmd-\\" => "pane.split_right", "ctrl-\\" => "pane.split_right",
17
18
  "ctrl-space" => "language.completion", "f12" => "language.definition", "shift-f12" => "language.references",
18
19
  "f2" => "language.rename", "alt-enter" => "language.codeAction", "cmd-shift-o" => "language.outline",
19
20
  "ctrl-shift-o" => "language.outline", "cmd-k" => "language.hover", "ctrl-k" => "language.hover"}.freeze
21
+ TERMINAL_SHORTCUTS = {"ctrl-`" => "terminal.toggle", "ctrl-shift-`" => "terminal.new", "cmd-w" => "terminal.close",
22
+ "ctrl-w" => "terminal.close", "cmd-c" => "terminal.copy", "ctrl-shift-c" => "terminal.copy",
23
+ "cmd-v" => "terminal.paste", "ctrl-shift-v" => "terminal.paste", "cmd-shift-p" => "command.palette",
24
+ "ctrl-shift-p" => "command.palette", "ctrl-shift-]" => "terminal.next", "ctrl-shift-[" => "terminal.prev",
25
+ "cmd-k" => "terminal.clear"}.merge((1..9).to_h { |index| ["cmd-#{index}", "terminal.select_#{index}"] }).freeze
20
26
  attr_reader :workspace, :view, :window, :keymap
21
27
  def initialize(workspace, window)
22
28
  @workspace, @window, @view = workspace, window, Workspace::View.new(workspace)
@@ -27,8 +33,7 @@ module Canopus
27
33
  window.on_close { close_requested }
28
34
  window.on_appearance { |_| workspace.apply_settings if workspace.settings["theme"] == "auto" } if window.respond_to?(:on_appearance)
29
35
  window.on_tick do
30
- data = workspace.terminal&.read
31
- window.request_frame if data && !data.empty?
36
+ workspace.drain_terminals
32
37
  workspace.drain
33
38
  workspace.poll_changes
34
39
  workspace.poll_settings
@@ -65,7 +70,12 @@ module Canopus
65
70
  when Zaniah::Input::TextInput
66
71
  input_text(event.text)
67
72
  when Zaniah::Input::Composition
68
- @workspace.editor.composition = event
73
+ if @terminal_focus && @workspace.terminal_visible
74
+ @workspace.terminal_composition = event
75
+ else
76
+ @workspace.new_buffer unless @workspace.editor
77
+ @workspace.editor.composition = event
78
+ end
69
79
  when Zaniah::Input::KeyDown
70
80
  key(event.keystroke)
71
81
  when Zaniah::Input::MouseDown
@@ -81,6 +91,7 @@ module Canopus
81
91
  terminal_mouse(event, :move)
82
92
  end
83
93
  when Zaniah::Input::MouseUp
94
+ @workspace.flush_terminal_resize if @resize_drag&.first == :dock_resize
84
95
  @resize_drag = nil
85
96
  @scroll_drag = nil
86
97
  if @drag_file
@@ -92,6 +103,10 @@ module Canopus
92
103
  @drag_file = nil
93
104
  end
94
105
  terminal_mouse(event, :release) if @terminal_drag && @terminal_drag != :selection
106
+ if @terminal_drag == :selection && @workspace.settings["terminal"]["copy_on_select"]
107
+ @clipboard = @view.terminal_selected_text
108
+ @window.clipboard = @clipboard if @window.respond_to?(:clipboard=)
109
+ end
95
110
  @terminal_drag = nil
96
111
  if @drag_tab
97
112
  from, editor, origin = @drag_tab
@@ -103,6 +118,14 @@ module Canopus
103
118
  end
104
119
  @drag_tab = nil
105
120
  end
121
+ if @drag_terminal_tab
122
+ from, origin = @drag_terminal_tab
123
+ target = @view.hit(event.position)
124
+ if target&.first == :terminal_tab && (event.position.x - origin.x).abs + (event.position.y - origin.y).abs > 10
125
+ @workspace.move_terminal(from, target[1])
126
+ end
127
+ @drag_terminal_tab = nil
128
+ end
106
129
  @drag = nil
107
130
  when Zaniah::Input::ScrollWheel
108
131
  scroll(event)
@@ -114,16 +137,20 @@ module Canopus
114
137
  end
115
138
  def input_text(text)
116
139
  if @workspace.palette
117
- return if [:workspace_edit, :confirm_close, :trash_file].include?(@workspace.palette[:kind])
140
+ return if [:workspace_edit, :confirm_close, :confirm_tab_close, :confirm_terminal_close, :confirm_terminal_paste, :trash_file].include?(@workspace.palette[:kind])
118
141
  @workspace.palette[:query] << text
119
142
  @workspace.update_palette
120
143
  elsif @terminal_focus && @workspace.terminal_visible
144
+ @workspace.terminal_composition = nil
121
145
  @workspace.terminal.write(text)
122
- elsif @workspace.settings["vim_mode"]
123
- text.each_grapheme_cluster { |character| @workspace.vim.feed(character) }
124
146
  else
125
- @workspace.editor.composition = nil
126
- @workspace.editor.insert_text(text)
147
+ @workspace.new_buffer unless @workspace.editor
148
+ if @workspace.settings["vim_mode"]
149
+ text.each_grapheme_cluster { |character| @workspace.vim.feed(character) }
150
+ else
151
+ @workspace.editor.composition = nil
152
+ @workspace.editor.insert_text(text)
153
+ end
127
154
  end
128
155
  end
129
156
  def key(stroke)
@@ -132,13 +159,27 @@ module Canopus
132
159
  stroke = Zaniah::Input::Keystroke.normalize(stroke)
133
160
  return palette_key(stroke) if @workspace.palette
134
161
  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)
162
+ if stroke == "cmd-q" || stroke == "ctrl-q" && !(@terminal_focus && @workspace.terminal_visible)
137
163
  return @window.close
138
164
  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)
165
+ if @terminal_focus && @workspace.terminal_visible && @workspace.terminal
166
+ if stroke == "enter" && @workspace.terminal.respond_to?(:alive?) && !@workspace.terminal.alive?
167
+ return @workspace.restart_terminal
168
+ end
169
+ mode = @workspace.settings["vim_mode"] && @workspace.editor ? @workspace.vim.mode.to_s : false
170
+ action = @keymap.dispatch(stroke, context: {"Terminal" => true, "Editor" => false, "vim_mode" => mode})
171
+ if action && action != :pending
172
+ if action == "terminal.paste"
173
+ return terminal_paste(@window.respond_to?(:clipboard) ? @window.clipboard.to_s : @clipboard)
174
+ elsif action == "terminal.copy"
175
+ @clipboard = @view.terminal_selected_text
176
+ @window.clipboard = @clipboard if @window.respond_to?(:clipboard=)
177
+ return
178
+ end
179
+ @drag_terminal_tab = nil if action == "terminal.close"
180
+ return @workspace.call(action)
181
+ elsif action == :pending
182
+ return
142
183
  elsif ["cmd-c", "ctrl-shift-c"].include?(stroke)
143
184
  @clipboard = @view.terminal_selected_text
144
185
  @window.clipboard = @clipboard if @window.respond_to?(:clipboard=)
@@ -150,23 +191,26 @@ module Canopus
150
191
  name = {"esc" => "escape", "pageup" => "page_up", "pagedown" => "page_down", "space" => " "}.fetch(name, name)
151
192
  return @workspace.terminal.key(name, control: parts.include?("ctrl"), alt: parts.include?("alt"), shift: parts.include?("shift"))
152
193
  end
153
- mode = @workspace.settings["vim_mode"] ? @workspace.vim.mode.to_s : false
154
- action = @keymap.dispatch(stroke, context: {"Editor" => true, "vim_mode" => mode})
194
+ return @workspace.settings_completions if stroke == "ctrl-space" && @workspace.editor && @workspace.settings_document?(@workspace.editor.buffer)
195
+ mode = @workspace.settings["vim_mode"] && @workspace.editor ? @workspace.vim.mode.to_s : false
196
+ action = @keymap.dispatch(stroke, context: {"Editor" => !!@workspace.editor, "vim_mode" => mode})
155
197
  if action
156
198
  # Native adapters deliver text separately after printable key down.
157
199
  # Cocoa also sends an empty Composition before that ordinary commit.
158
200
  @suppress_key_text = stroke.match?(/\A(?:(?:alt|shift)-)*(?:space|[^\x00-\x1f\x7f])\z/)
201
+ @drag_tab = nil if action.to_s.start_with?("tab.close") || action == "pane.close"
159
202
  return action == :pending ? nil : @workspace.call(action)
160
203
  end
161
204
  if ["cmd-c", "ctrl-c", "cmd-x", "ctrl-x", "cmd-v", "ctrl-v"].include?(stroke) && !(@workspace.settings["vim_mode"] && stroke == "ctrl-v")
162
205
  return clipboard(stroke.split("-").last)
163
206
  end
164
- if ["tab", "shift-tab"].include?(stroke) && @workspace.editor.snippet_active?
207
+ if ["tab", "shift-tab"].include?(stroke) && @workspace.editor&.snippet_active?
165
208
  stroke == "tab" ? @workspace.editor.next_snippet : @workspace.editor.previous_snippet
166
209
  return @workspace.show_snippet_choices
167
210
  end
168
211
  parts = stroke.split("-")
169
212
  name = parts.pop
213
+ @workspace.new_buffer unless @workspace.editor
170
214
  if @workspace.settings["vim_mode"]
171
215
  @workspace.vim.feed(stroke) if name.length > 1 || parts.include?("ctrl")
172
216
  return
@@ -285,6 +329,7 @@ module Canopus
285
329
  unless @keymap_groups == groups
286
330
  replacement = Zaniah::Input::Keymap.new
287
331
  SHORTCUTS.each { |keys, action| replacement.bind(keys, action) }
332
+ TERMINAL_SHORTCUTS.each { |keys, action| replacement.bind(keys, action, context: "Terminal") }
288
333
  groups.each do |group|
289
334
  group.fetch("bindings").each { |keys, action| replacement.bind(keys, action, context: group.fetch("context")) }
290
335
  end
@@ -295,7 +340,7 @@ module Canopus
295
340
  end
296
341
  def palette_input(event)
297
342
  return false unless @workspace.palette
298
- @drag = @resize_drag = @scroll_drag = @terminal_drag = @drag_file = @drag_tab = nil
343
+ @drag = @resize_drag = @scroll_drag = @terminal_drag = @drag_file = @drag_tab = @drag_terminal_tab = nil
299
344
  case event
300
345
  when Zaniah::Input::KeyDown then key(event.keystroke)
301
346
  when Zaniah::Input::TextInput then input_text(event.text)
@@ -341,7 +386,7 @@ module Canopus
341
386
  palette[:details_scroll] = (palette.fetch(:details_scroll, 0) + (stroke == "pageup" ? -step : step)).clamp(0, maximum)
342
387
  end
343
388
  when "backspace"
344
- return if [:workspace_edit, :confirm_close, :trash_file].include?(palette[:kind])
389
+ return if [:workspace_edit, :confirm_close, :confirm_tab_close, :confirm_terminal_close, :confirm_terminal_paste, :trash_file].include?(palette[:kind])
345
390
  palette[:query] = palette[:query].grapheme_clusters[0...-1].join
346
391
  @workspace.update_palette
347
392
  when "enter"
@@ -373,6 +418,19 @@ module Canopus
373
418
  when 1 then @discard_close = true; @window.close
374
419
  else @workspace.palette = nil
375
420
  end
421
+ elsif palette[:kind] == :confirm_tab_close
422
+ @workspace.resolve_tab_close([:save, :discard, :cancel].fetch(palette[:index]))
423
+ elsif palette[:kind] == :confirm_terminal_close
424
+ index = @workspace.terminals.index(palette[:terminal])
425
+ @workspace.palette = nil
426
+ @workspace.close_terminal(index) if palette[:index].zero? && index
427
+ elsif palette[:kind] == :confirm_terminal_paste
428
+ value = palette[:text]
429
+ @workspace.palette = nil
430
+ @workspace.terminal&.paste(value) if palette[:index].zero?
431
+ elsif palette[:kind] == :terminal_rename
432
+ @workspace.rename_terminal(palette[:query])
433
+ @workspace.palette = nil
376
434
  else
377
435
  @workspace.palette_accept
378
436
  end
@@ -391,7 +449,7 @@ module Canopus
391
449
  return
392
450
  end
393
451
  end
394
- @terminal_focus = kind == :terminal
452
+ @terminal_focus = [:terminal, :terminal_tab, :terminal_close].include?(kind)
395
453
  case kind
396
454
  when :git_hunk then @workspace.toggle_git_hunk(args[0], row: args[1]) if event.button == :left
397
455
  when :hover_link
@@ -419,12 +477,23 @@ module Canopus
419
477
  @terminal_drag = :selection
420
478
  @view.terminal_select(event.position)
421
479
  end
480
+ when :terminal_tab
481
+ @workspace.activate_terminal(args.first)
482
+ @drag_terminal_tab = [args.first, event.position] if event.button == :left
483
+ when :terminal_close
484
+ @workspace.request_terminal_close(args.first) if event.button == :left
422
485
  when :file then @workspace.open(args.first)
423
486
  when :directory then @workspace.project_tree.toggle(args.first)
424
487
  when :tab
425
488
  pane, editor = args
426
- @workspace.activate_tab(pane, editor)
427
- @drag_tab = [pane, editor, event.position]
489
+ if event.button == :middle && @workspace.settings["tabs"]["close_on_middle_click"]
490
+ @workspace.request_close([editor])
491
+ elsif event.button == :left
492
+ @workspace.activate_tab(pane, editor)
493
+ @drag_tab = [pane, editor, event.position]
494
+ end
495
+ when :tab_close
496
+ @workspace.request_close([args.last]) if event.button == :left
428
497
  when :editor
429
498
  pane, editor = args
430
499
  @workspace.focus(pane)
@@ -489,6 +558,15 @@ module Canopus
489
558
  @workspace.terminal.mouse(button: @terminal_drag, column: column, row: row, action: action,
490
559
  shift: modifiers.include?("shift"), alt: modifiers.include?("alt"), control: modifiers.include?("ctrl"))
491
560
  end
561
+
562
+ def terminal_paste(text)
563
+ if @workspace.settings["terminal"]["confirm_multiline_paste"] && text.match?(/[\r\n].*[\r\n]?/m)
564
+ @workspace.palette = {kind: :confirm_terminal_paste, query: "Paste multiple lines into the terminal?", index: 1,
565
+ matches: ["Paste", "Cancel"], text: text}
566
+ else
567
+ @workspace.terminal.paste(text)
568
+ end
569
+ end
492
570
  def resize_drag(point)
493
571
  kind, target, bounds = @resize_drag
494
572
  if kind == :split_resize
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "antares"
3
4
  require_relative "../../canopus"
4
5
 
5
6
  module Canopus
data/lib/canopus/pane.rb CHANGED
@@ -27,13 +27,29 @@ module Canopus
27
27
  def pin(editor = active)
28
28
  @pinned << editor unless @pinned.include?(editor)
29
29
  end
30
- def close(editor = active, discard: false)
30
+ def close(editor = active, discard: false, activate: :history)
31
31
  raise Error, "buffer has unsaved changes" if editor.buffer.dirty? && !discard
32
+ index = @editors.index(editor)
33
+ raise Error, "tab is not in pane" unless index
34
+ current = active
35
+ historical = @past.reverse.find { |item| !item.equal?(editor) && @editors.include?(item) }
32
36
  @pinned.delete(editor)
33
37
  @editors.delete(editor)
34
38
  @past.delete(editor)
35
39
  @future.delete(editor)
36
- @active_index = [@active_index, @editors.length - 1].min.clamp(0, @editors.length)
40
+ @future.clear if current.equal?(editor)
41
+ @active_index = if @editors.empty?
42
+ 0
43
+ elsif !current.equal?(editor)
44
+ @editors.index(current)
45
+ elsif activate == :history && historical
46
+ @past.delete(historical)
47
+ @editors.index(historical)
48
+ elsif activate == :left
49
+ [index - 1, 0].max
50
+ else
51
+ [index, @editors.length - 1].min
52
+ end
37
53
  editor.dispose
38
54
  end
39
55
  def detach(editor)
@@ -7,7 +7,15 @@ module Canopus
7
7
  class Settings
8
8
  DEFAULTS = {"font_size" => 14, "tab_size" => 4, "use_tabs" => false, "soft_wrap" => false, "vim_mode" => false, "keymap" => [].freeze,
9
9
  "scroll_friction" => 12,
10
- "theme" => "Canopus Dark", "font_family" => nil, "icon_theme" => nil, "languages" => {}, "language_servers" => {}}.freeze
10
+ "theme" => "Canopus Dark", "font_family" => nil, "icon_theme" => nil, "languages" => {}, "language_servers" => {},
11
+ "tabs" => {"activate_on_close" => "history", "close_on_middle_click" => true, "close_empty_pane" => true,
12
+ "reopen_history_limit" => 20, "confirm_on_close_dirty" => true}.freeze,
13
+ "dock" => {"bottom" => {"size" => 280, "visible" => false}.freeze}.freeze,
14
+ "terminal" => {"shell" => nil, "working_directory" => "project", "env" => {}.freeze, "scrollback_lines" => 10_000,
15
+ "font_size" => nil, "line_height" => 1.2, "copy_on_select" => false, "blinking" => "terminal_controlled", "cursor_shape" => "block",
16
+ "close_on_exit" => "clean", "confirm_close_running" => true, "confirm_multiline_paste" => true,
17
+ "restore_on_startup" => false, "hide_when_empty" => false, "max_bytes_per_frame" => 262_144,
18
+ "queue_limit_bytes" => 8_388_608, "resize_debounce_ms" => 100, "min_rows" => 4, "min_cols" => 20}.freeze}.freeze
11
19
  SCHEMA = {"$schema" => "https://json-schema.org/draft/2020-12/schema", "type" => "object", "properties" => {
12
20
  "font_size" => {"type" => "number", "minimum" => 6, "maximum" => 96},
13
21
  "tab_size" => {"type" => "integer", "minimum" => 1, "maximum" => 16},
@@ -19,6 +27,7 @@ module Canopus
19
27
  "bindings" => {"type" => "object", "maxProperties" => 1024, "additionalProperties" => {"type" => ["string", "null"]}}}}},
20
28
  "theme" => {"type" => "string"}, "font_family" => {"type" => ["string", "null"]},
21
29
  "icon_theme" => {"type" => ["string", "null"]},
30
+ "tabs" => {"type" => "object"}, "terminal" => {"type" => "object"}, "dock" => {"type" => "object"},
22
31
  "languages" => {"type" => "object", "additionalProperties" => {"$ref" => "#"}},
23
32
  "language_servers" => {"type" => "object"}}}.freeze
24
33
  attr_reader :values, :errors, :layers
@@ -85,6 +94,9 @@ module Canopus
85
94
  raise Error, "theme must be a string" unless @values["theme"].is_a?(String)
86
95
  raise Error, "font_family must be a string or null" unless @values["font_family"].nil? || @values["font_family"].is_a?(String)
87
96
  raise Error, "icon_theme must be a string or null" unless @values["icon_theme"].nil? || @values["icon_theme"].is_a?(String)
97
+ validate_tabs!
98
+ validate_terminal!
99
+ validate_dock!
88
100
  @values["languages"] = @values["languages"].to_h do |name, layer|
89
101
  raise Error, "language settings must be objects" unless name.is_a?(String) && layer.is_a?(Hash)
90
102
  raise Error, "language settings cannot contain nested languages" if layer.key?("languages")
@@ -114,5 +126,45 @@ module Canopus
114
126
  rescue ArgumentError => error
115
127
  raise Error, "invalid keymap: #{error.message}"
116
128
  end
129
+
130
+ def validate_tabs!
131
+ tabs = @values["tabs"]
132
+ raise Error, "tabs must be an object" unless tabs.is_a?(Hash)
133
+ raise Error, "invalid tabs.activate_on_close" unless %w[history neighbour left right].include?(tabs["activate_on_close"])
134
+ %w[close_on_middle_click close_empty_pane confirm_on_close_dirty].each do |key|
135
+ raise Error, "tabs.#{key} must be true or false" unless [true, false].include?(tabs[key])
136
+ end
137
+ limit = tabs["reopen_history_limit"]
138
+ raise Error, "invalid tabs.reopen_history_limit" unless limit.is_a?(Integer) && limit.between?(0, 1000)
139
+ end
140
+
141
+ def validate_terminal!
142
+ terminal = @values["terminal"]
143
+ raise Error, "terminal must be an object" unless terminal.is_a?(Hash)
144
+ raise Error, "invalid terminal.shell" unless terminal["shell"].nil? || terminal["shell"].is_a?(String) ||
145
+ (terminal["shell"].is_a?(Array) && terminal["shell"].all? { |value| value.is_a?(String) })
146
+ raise Error, "invalid terminal.working_directory" unless terminal["working_directory"].is_a?(String)
147
+ raise Error, "invalid terminal.env" unless terminal["env"].is_a?(Hash) && terminal["env"].all? { |key, value| key.is_a?(String) && (value.nil? || value.is_a?(String)) }
148
+ raise Error, "invalid terminal.font_size" unless terminal["font_size"].nil? || terminal["font_size"].is_a?(Numeric) && terminal["font_size"].between?(6, 96)
149
+ raise Error, "invalid terminal.line_height" unless terminal["line_height"].is_a?(Numeric) && terminal["line_height"].between?(0.5, 4)
150
+ %w[confirm_close_running confirm_multiline_paste copy_on_select restore_on_startup hide_when_empty].each do |key|
151
+ raise Error, "terminal.#{key} must be true or false" unless [true, false].include?(terminal[key])
152
+ end
153
+ raise Error, "invalid terminal.close_on_exit" unless %w[never clean always].include?(terminal["close_on_exit"])
154
+ raise Error, "invalid terminal.blinking" unless %w[off on terminal_controlled].include?(terminal["blinking"])
155
+ raise Error, "invalid terminal.cursor_shape" unless %w[block bar underline].include?(terminal["cursor_shape"])
156
+ {"scrollback_lines" => 0..1_000_000, "max_bytes_per_frame" => 1..16_777_216,
157
+ "queue_limit_bytes" => 65_536..268_435_456, "resize_debounce_ms" => 0..10_000,
158
+ "min_rows" => 1..1000, "min_cols" => 1..1000}.each do |key, range|
159
+ value = terminal[key]
160
+ raise Error, "invalid terminal.#{key}" unless value.is_a?(Integer) && range.cover?(value)
161
+ end
162
+ end
163
+
164
+ def validate_dock!
165
+ dock = @values["dock"]
166
+ bottom = dock["bottom"] if dock.is_a?(Hash)
167
+ raise Error, "invalid dock.bottom" unless bottom.is_a?(Hash) && bottom["size"].is_a?(Numeric) && bottom["size"].positive? && [true, false].include?(bottom["visible"])
168
+ end
117
169
  end
118
170
  end
@@ -6,9 +6,13 @@ require "shellwords"
6
6
  module Canopus
7
7
  module Terminal
8
8
  class PTY
9
- attr_reader :reader, :writer, :pid, :grid, :vt, :status
9
+ attr_reader :reader, :writer, :pid, :grid, :vt, :status, :initial_cwd, :command_name
10
10
 
11
- def initialize(command: ENV.fetch("SHELL", "/bin/sh"), cwd: Dir.pwd, columns: 80, rows: 24, env: {}, scrollback: 10_000)
11
+ def initialize(command: ENV.fetch("SHELL", "/bin/sh"), cwd: Dir.pwd, columns: 80, rows: 24, env: {}, scrollback: 10_000,
12
+ queue_limit_bytes: 8_388_608)
13
+ raise ArgumentError, "terminal queue limit must be a positive integer" unless queue_limit_bytes.is_a?(Integer) && queue_limit_bytes.positive?
14
+ @initial_cwd = File.expand_path(cwd)
15
+ @command_name = File.basename(Array(command).first.to_s)
12
16
  @grid = Grid.new(columns: columns, rows: rows, scrollback: scrollback)
13
17
  if RUBY_PLATFORM.match?(/mswin|mingw/)
14
18
  require "zaniah/platform/windows/terminal"
@@ -27,38 +31,82 @@ module Canopus
27
31
  @writer.sync = true
28
32
  @vt = VT.new(grid) { |bytes| write(bytes) }
29
33
  resize(columns: columns, rows: rows)
34
+ @queue, @queue_bytes, @queue_limit_bytes = [], 0, queue_limit_bytes
35
+ @queue_lock, @queue_ready = Mutex.new, ConditionVariable.new
36
+ start_reader
30
37
  end
31
38
 
32
39
  # nil means EOF; an empty String means that no data was ready yet.
33
- def read(timeout: 0, max_bytes: 65_536)
40
+ def read(timeout: 0, max_bytes: 65_536, max_seconds: nil)
41
+ raise ArgumentError, "terminal read limit must be a positive integer" unless max_bytes.is_a?(Integer) && max_bytes.positive?
42
+ raise ArgumentError, "terminal read timeout must be nonnegative" unless timeout.is_a?(Numeric) && timeout >= 0
43
+ raise ArgumentError, "terminal parse budget must be nonnegative" unless max_seconds.nil? || max_seconds.is_a?(Numeric) && max_seconds >= 0
34
44
  return nil if @eof
35
45
  if @native
36
46
  data = @native.read_available(limit: max_bytes)
37
47
  data ? vt.feed(data) : @eof = true
38
48
  return data
39
49
  end
40
- return "" unless IO.select([reader], nil, nil, timeout)
41
- data = reader.read_nonblock(max_bytes, exception: false)
42
- return "" if data == :wait_readable
43
- if data.nil?
44
- @eof = true
45
- else
46
- vt.feed(data)
50
+ deadline = max_seconds && Process.clock_gettime(Process::CLOCK_MONOTONIC) + [max_seconds, 0].max
51
+ data = +"".b
52
+ loop do
53
+ break if data.bytesize >= max_bytes || deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
54
+ piece = @queue_lock.synchronize do
55
+ @queue_ready.wait(@queue_lock, timeout) if data.empty? && @queue.empty? && !@reader_eof && timeout.positive?
56
+ if @queue.empty?
57
+ @eof = true if @reader_eof
58
+ nil
59
+ else
60
+ chunk = @queue.shift
61
+ take = [max_bytes - data.bytesize, chunk.bytesize, 16_384].min
62
+ result = chunk.byteslice(0, take)
63
+ @queue.unshift(chunk.byteslice(take..)) if take < chunk.bytesize
64
+ @queue_bytes -= take
65
+ @queue_ready.broadcast
66
+ result
67
+ end
68
+ end
69
+ break unless piece
70
+ vt.feed(piece)
71
+ data << piece
47
72
  end
48
- data
49
- rescue Errno::EIO, EOFError
50
- @eof = true
51
- nil
73
+ @eof && data.empty? ? nil : data
52
74
  end
53
75
 
76
+ def pending? = @native ? false : @queue_lock.synchronize { !@queue.empty? }
77
+
54
78
  def write(bytes) = @native ? @native.write(bytes) : writer.write(bytes)
55
79
  def paste(text) = write(vt.paste(text))
56
80
  def key(name, **modifiers) = write(vt.key(name, **modifiers))
57
81
  def mouse(**event) = write(vt.mouse(**event))
58
82
 
59
83
  def resize(columns:, rows:)
60
- grid.resize(columns: columns, rows: rows)
84
+ dimensions = [columns, rows]
85
+ return self if @pty_size == dimensions
86
+ grid.resize(columns: columns, rows: rows) unless grid.columns == columns && grid.rows == rows
61
87
  @native ? @native.resize(columns, rows) : reader.winsize = [rows, columns]
88
+ @pty_size = dimensions
89
+ self
90
+ end
91
+
92
+ def busy?
93
+ !@native && alive? && reader.tcgetpgrp != pid
94
+ rescue IOError, SystemCallError, NoMethodError
95
+ false
96
+ end
97
+
98
+ def foreground_process_name(now: Process.clock_gettime(Process::CLOCK_MONOTONIC))
99
+ return @foreground_name if @foreground_checked && now - @foreground_checked < 1
100
+ @foreground_checked = now
101
+ group = reader.tcgetpgrp unless @native
102
+ value = if group && group != pid && File.file?("/proc/#{group}/comm")
103
+ File.read("/proc/#{group}/comm")
104
+ elsif group && group != pid
105
+ IO.popen(["ps", "-o", "comm=", "-p", group.to_s], &:read)
106
+ end
107
+ @foreground_name = value&.strip&.then { |name| File.basename(name) unless name.empty? }
108
+ rescue IOError, SystemCallError, NoMethodError
109
+ @foreground_name = nil
62
110
  end
63
111
 
64
112
  def signal(name = "INT")
@@ -81,11 +129,13 @@ module Canopus
81
129
 
82
130
  def close
83
131
  return @native.close if @native
132
+ @closing = true
133
+ @queue_lock.synchronize { @queue_ready.broadcast }
84
134
  signal("HUP") if alive?
85
135
  reader.close unless reader.closed?
86
136
  writer.close unless writer.closed?
87
137
  # A shell may trap HUP: reap it after a short grace period.
88
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.5
138
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
89
139
  while alive? && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
90
140
  sleep 0.01
91
141
  end
@@ -93,11 +143,36 @@ module Canopus
93
143
  signal("KILL")
94
144
  _, @status = Process.waitpid2(pid)
95
145
  end
146
+ @reader_thread&.join(0.2)
96
147
  @eof = true
97
148
  self
98
- rescue Errno::ECHILD
149
+ rescue Errno::ECHILD, IOError
99
150
  self
100
151
  end
152
+
153
+ private
154
+
155
+ def start_reader
156
+ @reader_thread = Thread.new do
157
+ loop do
158
+ data = reader.readpartial(65_536)
159
+ @queue_lock.synchronize do
160
+ @queue_ready.wait(@queue_lock) while !@closing && @queue_bytes >= @queue_limit_bytes
161
+ break if @closing
162
+ @queue << data
163
+ @queue_bytes += data.bytesize
164
+ @queue_ready.broadcast
165
+ end
166
+ end
167
+ rescue Errno::EIO, EOFError, IOError
168
+ nil
169
+ ensure
170
+ @queue_lock.synchronize do
171
+ @reader_eof = true
172
+ @queue_ready.broadcast
173
+ end
174
+ end
175
+ end
101
176
  end
102
177
  end
103
178
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Canopus
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end