echoes 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.
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Echoes
4
+ class SixelDecoder
5
+ attr_reader :width, :height
6
+
7
+ def initialize(params = [])
8
+ @background_mode = params[1] || 0
9
+ @color_registers = default_color_registers
10
+ @current_color = 0
11
+ @cursor_x = 0
12
+ @cursor_y = 0
13
+ @width = 0
14
+ @height = 0
15
+ @pixels = {}
16
+ @declared_width = 0
17
+ @declared_height = 0
18
+ end
19
+
20
+ def decode(data)
21
+ i = 0
22
+ len = data.bytesize
23
+
24
+ while i < len
25
+ byte = data.getbyte(i)
26
+
27
+ case byte
28
+ when 0x21 # '!' repeat
29
+ i, count, ch = parse_repeat(data, i + 1)
30
+ paint_sixel(ch, count) if ch
31
+ when 0x22 # '"' raster attributes
32
+ i, _, _, ph, pv = parse_raster_attributes(data, i + 1)
33
+ @declared_width = ph if ph > 0
34
+ @declared_height = pv if pv > 0
35
+ when 0x23 # '#' color
36
+ i = parse_color(data, i + 1)
37
+ when 0x24 # '$' graphics CR
38
+ @cursor_x = 0
39
+ i += 1
40
+ when 0x2D # '-' graphics newline
41
+ @cursor_x = 0
42
+ @cursor_y += 6
43
+ i += 1
44
+ when 0x3F..0x7E # sixel character
45
+ paint_sixel(byte, 1)
46
+ i += 1
47
+ else
48
+ i += 1
49
+ end
50
+ end
51
+
52
+ @width = @declared_width if @declared_width > @width
53
+ @height = @declared_height if @declared_height > @height
54
+ self
55
+ end
56
+
57
+ def to_rgba
58
+ buf = "\x00".b * (@width * @height * 4)
59
+ transparent = @background_mode == 1
60
+
61
+ @pixels.each do |(x, y), color|
62
+ next if x >= @width || y >= @height
63
+
64
+ offset = (y * @width + x) * 4
65
+ buf.setbyte(offset, color[0])
66
+ buf.setbyte(offset + 1, color[1])
67
+ buf.setbyte(offset + 2, color[2])
68
+ buf.setbyte(offset + 3, 255)
69
+ end
70
+
71
+ unless transparent
72
+ # Set alpha=255 for unset pixels (background fill)
73
+ (@width * @height).times do |i|
74
+ offset = i * 4
75
+ buf.setbyte(offset + 3, 255) if buf.getbyte(offset + 3) == 0
76
+ end
77
+ end
78
+
79
+ buf
80
+ end
81
+
82
+ private
83
+
84
+ def default_color_registers
85
+ {
86
+ 0 => [0, 0, 0],
87
+ 1 => [51, 51, 204],
88
+ 2 => [204, 51, 51],
89
+ 3 => [51, 204, 51],
90
+ 4 => [204, 51, 204],
91
+ 5 => [51, 204, 204],
92
+ 6 => [204, 204, 51],
93
+ 7 => [135, 135, 135],
94
+ 8 => [68, 68, 68],
95
+ 9 => [84, 84, 255],
96
+ 10 => [255, 84, 84],
97
+ 11 => [84, 255, 84],
98
+ 12 => [255, 84, 255],
99
+ 13 => [84, 255, 255],
100
+ 14 => [255, 255, 84],
101
+ 15 => [255, 255, 255],
102
+ }
103
+ end
104
+
105
+ def paint_sixel(byte, count)
106
+ bits = byte - 0x3F
107
+ color = @color_registers[@current_color] || [0, 0, 0]
108
+
109
+ 6.times do |bit|
110
+ next unless (bits >> bit) & 1 == 1
111
+
112
+ y = @cursor_y + bit
113
+ count.times do |dx|
114
+ x = @cursor_x + dx
115
+ @pixels[[x, y]] = color
116
+ @width = x + 1 if x + 1 > @width
117
+ end
118
+ @height = y + 1 if y + 1 > @height
119
+ end
120
+
121
+ @cursor_x += count
122
+ @width = @cursor_x if @cursor_x > @width
123
+ end
124
+
125
+ def parse_repeat(data, i)
126
+ len = data.bytesize
127
+ num_str = +""
128
+ while i < len && data.getbyte(i) >= 0x30 && data.getbyte(i) <= 0x39
129
+ num_str << data.getbyte(i).chr
130
+ i += 1
131
+ end
132
+ count = num_str.empty? ? 1 : num_str.to_i
133
+ ch = nil
134
+ if i < len
135
+ byte = data.getbyte(i)
136
+ ch = byte if byte >= 0x3F && byte <= 0x7E
137
+ i += 1
138
+ end
139
+ [i, count, ch]
140
+ end
141
+
142
+ def parse_raster_attributes(data, i)
143
+ values, i = parse_numeric_params(data, i)
144
+ [i, values[0] || 0, values[1] || 0, values[2] || 0, values[3] || 0]
145
+ end
146
+
147
+ def parse_color(data, i)
148
+ values, i = parse_numeric_params(data, i)
149
+
150
+ if values.size == 1
151
+ @current_color = values[0]
152
+ elsif values.size >= 5
153
+ pc, pu, px, py, pz = values
154
+ if pu == 1
155
+ @color_registers[pc] = hls_to_rgb(px, py, pz)
156
+ else
157
+ @color_registers[pc] = [
158
+ (px * 255 / 100.0).round.clamp(0, 255),
159
+ (py * 255 / 100.0).round.clamp(0, 255),
160
+ (pz * 255 / 100.0).round.clamp(0, 255),
161
+ ]
162
+ end
163
+ @current_color = pc
164
+ end
165
+ i
166
+ end
167
+
168
+ def parse_numeric_params(data, i)
169
+ values = []
170
+ num_str = +""
171
+ len = data.bytesize
172
+ while i < len
173
+ byte = data.getbyte(i)
174
+ if byte >= 0x30 && byte <= 0x39
175
+ num_str << byte.chr
176
+ i += 1
177
+ elsif byte == 0x3B
178
+ values << (num_str.empty? ? 0 : num_str.to_i)
179
+ num_str = +""
180
+ i += 1
181
+ else
182
+ values << (num_str.empty? ? 0 : num_str.to_i)
183
+ break
184
+ end
185
+ end
186
+ [values, i]
187
+ end
188
+
189
+ def hls_to_rgb(h, l, s)
190
+ hh = h / 360.0
191
+ ll = l / 100.0
192
+ ss = s / 100.0
193
+
194
+ if ss == 0
195
+ v = (ll * 255).round.clamp(0, 255)
196
+ return [v, v, v]
197
+ end
198
+
199
+ q = ll < 0.5 ? ll * (1.0 + ss) : ll + ss - ll * ss
200
+ p = 2.0 * ll - q
201
+
202
+ r = hue_to_rgb(p, q, hh + 1.0 / 3)
203
+ g = hue_to_rgb(p, q, hh)
204
+ b = hue_to_rgb(p, q, hh - 1.0 / 3)
205
+
206
+ [(r * 255).round.clamp(0, 255),
207
+ (g * 255).round.clamp(0, 255),
208
+ (b * 255).round.clamp(0, 255)]
209
+ end
210
+
211
+ def hue_to_rgb(p, q, t)
212
+ t += 1.0 if t < 0
213
+ t -= 1.0 if t > 1
214
+ return p + (q - p) * 6.0 * t if t < 1.0 / 6
215
+ return q if t < 1.0 / 2
216
+ return p + (q - p) * (2.0 / 3 - t) * 6.0 if t < 2.0 / 3
217
+
218
+ p
219
+ end
220
+ end
221
+ end
data/lib/echoes/tab.rb ADDED
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Echoes
4
+ class Tab
5
+ attr_reader :pane_tree
6
+ attr_accessor :title
7
+
8
+ def initialize(command:, rows:, cols:, cwd: nil, embedded: false, editor_file: nil)
9
+ @command = command
10
+ @rows = rows
11
+ @cols = cols
12
+ @embedded = embedded
13
+ pane = Pane.new(command: command, rows: rows, cols: cols, cwd: cwd,
14
+ embedded: embedded, editor_file: editor_file)
15
+ @pane_tree = PaneTree.new(pane)
16
+ @title = pane.title
17
+ end
18
+
19
+ # --- Active pane delegation (backward compatibility) ---
20
+
21
+ def active_pane
22
+ @pane_tree.active_pane
23
+ end
24
+
25
+ def screen
26
+ active_pane.screen
27
+ end
28
+
29
+ def parser
30
+ active_pane.parser
31
+ end
32
+
33
+ def pty_read
34
+ active_pane.pty_read
35
+ end
36
+
37
+ def pty_write
38
+ active_pane.pty_write
39
+ end
40
+
41
+ def pty_pid
42
+ active_pane.pty_pid
43
+ end
44
+
45
+ def write_input(bytes)
46
+ active_pane.write_input(bytes)
47
+ end
48
+
49
+ def submit_line(line)
50
+ active_pane.submit_line(line)
51
+ end
52
+
53
+ def read_available_output(max = 16384)
54
+ active_pane.read_available_output(max)
55
+ end
56
+
57
+ def scroll_offset
58
+ active_pane.scroll_offset
59
+ end
60
+
61
+ def scroll_offset=(val)
62
+ active_pane.scroll_offset = val
63
+ end
64
+
65
+ def scroll_accum
66
+ active_pane.scroll_accum
67
+ end
68
+
69
+ def scroll_accum=(val)
70
+ active_pane.scroll_accum = val
71
+ end
72
+
73
+ # --- Pane operations ---
74
+
75
+ def split_vertical
76
+ new_pane = create_pane
77
+ layout = @pane_tree.layout(0, 0, @cols, @rows)
78
+ current_rect = layout.find { |r| r[:pane] == active_pane }
79
+ if current_rect
80
+ half_cols = current_rect[:w] / 2
81
+ active_pane.resize(current_rect[:h], half_cols)
82
+ new_pane.resize(current_rect[:h], current_rect[:w] - half_cols)
83
+ end
84
+ @pane_tree.split(active_pane, :vertical, new_pane)
85
+ new_pane
86
+ end
87
+
88
+ def split_horizontal
89
+ new_pane = create_pane
90
+ layout = @pane_tree.layout(0, 0, @cols, @rows)
91
+ current_rect = layout.find { |r| r[:pane] == active_pane }
92
+ if current_rect
93
+ half_rows = current_rect[:h] / 2
94
+ active_pane.resize(half_rows, current_rect[:w])
95
+ new_pane.resize(current_rect[:h] - half_rows, current_rect[:w])
96
+ end
97
+ @pane_tree.split(active_pane, :horizontal, new_pane)
98
+ new_pane
99
+ end
100
+
101
+ def close_active_pane
102
+ return false if @pane_tree.single_pane?
103
+
104
+ pane = active_pane
105
+ @pane_tree.remove(pane)
106
+ pane.close
107
+ resize_panes
108
+ true
109
+ end
110
+
111
+ def next_pane
112
+ @pane_tree.active_pane = @pane_tree.next_pane(active_pane)
113
+ end
114
+
115
+ def prev_pane
116
+ @pane_tree.active_pane = @pane_tree.prev_pane(active_pane)
117
+ end
118
+
119
+ def panes
120
+ @pane_tree.panes
121
+ end
122
+
123
+ # --- Lifecycle ---
124
+
125
+ def alive?
126
+ panes.any?(&:alive?)
127
+ end
128
+
129
+ def resize(rows, cols)
130
+ @rows = rows
131
+ @cols = cols
132
+ resize_panes
133
+ end
134
+
135
+ def close
136
+ panes.each(&:close)
137
+ end
138
+
139
+ private
140
+
141
+ def create_pane
142
+ Pane.new(command: @command, rows: @rows, cols: @cols)
143
+ end
144
+
145
+ def resize_panes
146
+ layout = @pane_tree.layout(0, 0, @cols, @rows)
147
+ layout.each do |rect|
148
+ rect[:pane].resize(rect[:h], rect[:w])
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pty'
4
+ require 'io/console'
5
+
6
+ module Echoes
7
+ class Terminal
8
+ attr_reader :screen
9
+
10
+ def initialize(command: Echoes.config.shell, rows: nil, cols: nil)
11
+ size = IO.console&.winsize || [24, 80]
12
+ @rows = rows || size[0]
13
+ @cols = cols || size[1]
14
+ @command = command
15
+ @screen = Screen.new(rows: @rows, cols: @cols)
16
+ @parser = Parser.new(@screen, writer: ->(s) { @write_io&.write(s) rescue nil })
17
+ end
18
+
19
+ def run
20
+ PTY.spawn(@command) do |read_io, write_io, pid|
21
+ @read_io = read_io
22
+ @write_io = write_io
23
+ @pid = pid
24
+
25
+ @read_io.winsize = [@rows, @cols]
26
+
27
+ setup_signal_handlers
28
+
29
+ STDIN.raw do
30
+ reader = Thread.new { read_loop }
31
+ write_loop
32
+ reader.kill
33
+ end
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def read_loop
40
+ loop do
41
+ data = @read_io.read_nonblock(4096)
42
+ @parser.feed(data)
43
+ render
44
+ rescue IO::WaitReadable
45
+ IO.select([@read_io])
46
+ retry
47
+ rescue EOFError, Errno::EIO
48
+ break
49
+ end
50
+ end
51
+
52
+ def write_loop
53
+ loop do
54
+ data = STDIN.read_nonblock(4096)
55
+ @write_io.write(data)
56
+ rescue IO::WaitReadable
57
+ IO.select([STDIN])
58
+ retry
59
+ rescue EOFError, Errno::EIO
60
+ break
61
+ end
62
+ end
63
+
64
+ def render
65
+ buf = +"\e[H"
66
+ last_fg = nil
67
+ last_bg = nil
68
+ last_bold = false
69
+ last_underline = false
70
+ last_inverse = false
71
+
72
+ @screen.grid.each_with_index do |row, r|
73
+ row.each do |cell|
74
+ if cell.fg != last_fg || cell.bg != last_bg || cell.bold != last_bold ||
75
+ cell.underline != last_underline || cell.inverse != last_inverse
76
+ codes = [0]
77
+ codes << 1 if cell.bold
78
+ codes << 2 if cell.faint
79
+ codes << 3 if cell.italic
80
+ codes << 4 if cell.underline
81
+ codes << 5 if cell.blink
82
+ codes << 7 if cell.inverse
83
+ codes << 8 if cell.concealed
84
+ codes << 9 if cell.strikethrough
85
+ if cell.fg.is_a?(Array)
86
+ codes.push(38, 2, *cell.fg)
87
+ elsif cell.fg
88
+ codes << (cell.fg < 8 ? cell.fg + 30 : cell.fg - 8 + 90)
89
+ end
90
+ if cell.bg.is_a?(Array)
91
+ codes.push(48, 2, *cell.bg)
92
+ elsif cell.bg
93
+ codes << (cell.bg < 8 ? cell.bg + 40 : cell.bg - 8 + 100)
94
+ end
95
+ buf << "\e[#{codes.join(';')}m"
96
+ last_fg = cell.fg
97
+ last_bg = cell.bg
98
+ last_bold = cell.bold
99
+ last_underline = cell.underline
100
+ last_inverse = cell.inverse
101
+ end
102
+ buf << cell.char
103
+ end
104
+ buf << "\r\n" unless r == @screen.rows - 1
105
+ end
106
+
107
+ buf << "\e[0m"
108
+ buf << "\e[#{@screen.cursor.row + 1};#{@screen.cursor.col + 1}H"
109
+ buf << (@screen.cursor.visible ? "\e[?25h" : "\e[?25l")
110
+ STDOUT.write(buf)
111
+ end
112
+
113
+ def setup_signal_handlers
114
+ Signal.trap(:WINCH) do
115
+ if IO.console
116
+ @rows, @cols = IO.console.winsize
117
+ @screen.resize(@rows, @cols)
118
+ @read_io.winsize = [@rows, @cols]
119
+ render
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Echoes
4
+ VERSION = "0.2.0"
5
+ end
data/lib/echoes.rb ADDED
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # When invoked outside Bundler (e.g. from the .app launcher with bare
4
+ # `ruby exe/echoes`), `require 'rubish'` and `require 'rvim'` would
5
+ # pick up gem-installed copies that may lag the path-pinned source
6
+ # in the Gemfile and lack methods echoes calls. Prepend the sibling
7
+ # repo lib paths so the source versions win over Rubygems' default
8
+ # activation. The Gemfile already encodes the same sibling assumption
9
+ # via `path: "../rubish"` / `path: "../rvim"`, so this is just the
10
+ # Bundler-less mirror of that.
11
+ %w[rubish rvim].each do |sibling|
12
+ lib = File.expand_path("../../#{sibling}/lib", __dir__)
13
+ $LOAD_PATH.unshift(lib) if File.directory?(lib) && !$LOAD_PATH.include?(lib)
14
+ end
15
+
16
+ require_relative "echoes/version"
17
+ require_relative "echoes/configuration"
18
+ require_relative "echoes/cell"
19
+ require_relative "echoes/cursor"
20
+ require_relative "echoes/screen"
21
+ require_relative "echoes/parser"
22
+ require_relative "echoes/copy_mode"
23
+ require_relative "echoes/pane"
24
+ require_relative "echoes/pane_tree"
25
+ require_relative "echoes/tab"
26
+ require_relative "echoes/sixel_decoder"
27
+ require_relative "echoes/terminal"
28
+ require_relative "echoes/objc"
29
+ require_relative "echoes/preferences"
30
+ require_relative "echoes/client"
31
+ require_relative "echoes/gui"
32
+
33
+ module Echoes
34
+ class Error < StandardError; end
35
+ end
36
+
37
+ Echoes.load_config
data/sig/echoes.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Echoes
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,123 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: echoes
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Akira Matsuda
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: syslog
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ -
17
+ - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: "0"
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ -
25
+ - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: "0"
28
+ - !ruby/object:Gem::Dependency
29
+ name: fiddle
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ -
33
+ - ">="
34
+ - !ruby/object:Gem::Version
35
+ version: "0"
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ -
41
+ - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: "0"
44
+ description: |
45
+ Echoes is a pure-Ruby macOS terminal emulator with first-class
46
+ integrations for rubish (in-process shell) and rvim (in-process
47
+ vim editor) panes, plus a private OSC namespace for in-pane
48
+ Ruby tools that want to drive UI features (gradient backgrounds,
49
+ rectangular fills, proportional-font text) other terminals
50
+ can't. Written in pure Ruby on top of AppKit via Fiddle.
51
+ email:
52
+ - ronnie@dio.jp
53
+ executables:
54
+ - echoes
55
+ extensions: []
56
+ extra_rdoc_files: []
57
+ files:
58
+ - CLAUDE.md
59
+ - Echoes.app/Contents/Info.plist
60
+ - Echoes.app/Contents/MacOS/Echoes
61
+ - EchoesEmbed.app/Contents/Info.plist
62
+ - EchoesEmbed.app/Contents/MacOS/EchoesEmbed
63
+ - LICENSE.txt
64
+ - README.md
65
+ - Rakefile
66
+ - exe/echoes
67
+ - lib/echoes.rb
68
+ - lib/echoes/cell.rb
69
+ - lib/echoes/client.rb
70
+ - lib/echoes/configuration.rb
71
+ - lib/echoes/copy_mode.rb
72
+ - lib/echoes/cursor.rb
73
+ - lib/echoes/editor.rb
74
+ - lib/echoes/embedded_shell.rb
75
+ - lib/echoes/embedded_shell_helper.rb
76
+ - lib/echoes/gui.rb
77
+ - lib/echoes/installer.rb
78
+ - lib/echoes/objc.rb
79
+ - lib/echoes/pane.rb
80
+ - lib/echoes/pane_tree.rb
81
+ - lib/echoes/parser.rb
82
+ - lib/echoes/preferences.rb
83
+ - lib/echoes/screen.rb
84
+ - lib/echoes/sixel_decoder.rb
85
+ - lib/echoes/tab.rb
86
+ - lib/echoes/terminal.rb
87
+ - lib/echoes/version.rb
88
+ - sig/echoes.rbs
89
+ homepage: "https://github.com/amatsuda/echoes"
90
+ licenses:
91
+ - MIT
92
+ metadata:
93
+ homepage_uri: "https://github.com/amatsuda/echoes"
94
+ source_code_uri: "https://github.com/amatsuda/echoes"
95
+ post_install_message: |
96
+ To launch Echoes from Spotlight / Dock / Cmd-Space, run:
97
+
98
+ echoes install
99
+
100
+ This drops thin Echoes.app and EchoesEmbed.app shortcuts in
101
+ ~/Applications/ that exec into the real gem-bundled launchers.
102
+ Re-run `echoes install` after each `gem update echoes` to refresh
103
+ the shortcuts; `echoes uninstall` removes them.
104
+ rdoc_options: []
105
+ require_paths:
106
+ - lib
107
+ required_ruby_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ -
110
+ - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: 3.2.0
113
+ required_rubygems_version: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ -
116
+ - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: "0"
119
+ requirements: []
120
+ rubygems_version: 4.1.0.dev
121
+ specification_version: 4
122
+ summary: A pure-Ruby AppKit-based macOS terminal emulator.
123
+ test_files: []