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.
- checksums.yaml +7 -0
- data/CLAUDE.md +33 -0
- data/Echoes.app/Contents/Info.plist +16 -0
- data/Echoes.app/Contents/MacOS/Echoes +50 -0
- data/EchoesEmbed.app/Contents/Info.plist +16 -0
- data/EchoesEmbed.app/Contents/MacOS/EchoesEmbed +41 -0
- data/LICENSE.txt +21 -0
- data/README.md +133 -0
- data/Rakefile +45 -0
- data/exe/echoes +15 -0
- data/lib/echoes/cell.rb +54 -0
- data/lib/echoes/client.rb +96 -0
- data/lib/echoes/configuration.rb +135 -0
- data/lib/echoes/copy_mode.rb +545 -0
- data/lib/echoes/cursor.rb +18 -0
- data/lib/echoes/editor.rb +225 -0
- data/lib/echoes/embedded_shell.rb +360 -0
- data/lib/echoes/embedded_shell_helper.rb +265 -0
- data/lib/echoes/gui.rb +2861 -0
- data/lib/echoes/installer.rb +95 -0
- data/lib/echoes/objc.rb +188 -0
- data/lib/echoes/pane.rb +1122 -0
- data/lib/echoes/pane_tree.rb +194 -0
- data/lib/echoes/parser.rb +821 -0
- data/lib/echoes/preferences.rb +45 -0
- data/lib/echoes/screen.rb +1468 -0
- data/lib/echoes/sixel_decoder.rb +221 -0
- data/lib/echoes/tab.rb +152 -0
- data/lib/echoes/terminal.rb +124 -0
- data/lib/echoes/version.rb +5 -0
- data/lib/echoes.rb +37 -0
- data/sig/echoes.rbs +4 -0
- metadata +123 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Echoes
|
|
4
|
+
class PaneTree
|
|
5
|
+
class SplitNode
|
|
6
|
+
attr_accessor :direction, :ratio, :left, :right
|
|
7
|
+
|
|
8
|
+
# direction: :vertical (left/right split with vertical divider)
|
|
9
|
+
# :horizontal (top/bottom split with horizontal divider)
|
|
10
|
+
# ratio: 0.0..1.0, fraction allocated to left/top child
|
|
11
|
+
def initialize(direction:, ratio: 0.5, left:, right:)
|
|
12
|
+
@direction = direction
|
|
13
|
+
@ratio = ratio
|
|
14
|
+
@left = left
|
|
15
|
+
@right = right
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class PaneNode
|
|
20
|
+
attr_accessor :pane
|
|
21
|
+
|
|
22
|
+
def initialize(pane)
|
|
23
|
+
@pane = pane
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
attr_reader :root
|
|
28
|
+
attr_accessor :active_pane
|
|
29
|
+
|
|
30
|
+
def initialize(pane)
|
|
31
|
+
@root = PaneNode.new(pane)
|
|
32
|
+
@active_pane = pane
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Split the active pane in the given direction, returning the new pane
|
|
36
|
+
def split(pane, direction, new_pane)
|
|
37
|
+
node = find_node(@root, pane)
|
|
38
|
+
return nil unless node
|
|
39
|
+
|
|
40
|
+
old_node = PaneNode.new(pane)
|
|
41
|
+
new_node = PaneNode.new(new_pane)
|
|
42
|
+
split_node = SplitNode.new(direction: direction, left: old_node, right: new_node)
|
|
43
|
+
|
|
44
|
+
replace_node(node, split_node)
|
|
45
|
+
@active_pane = new_pane
|
|
46
|
+
new_pane
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Remove a pane, promoting its sibling
|
|
50
|
+
def remove(pane)
|
|
51
|
+
return nil if single_pane?
|
|
52
|
+
|
|
53
|
+
parent = find_parent(@root, pane)
|
|
54
|
+
return nil unless parent
|
|
55
|
+
|
|
56
|
+
sibling = if pane_in_subtree?(parent.left, pane)
|
|
57
|
+
parent.right
|
|
58
|
+
else
|
|
59
|
+
parent.left
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
replace_node(parent, sibling)
|
|
63
|
+
|
|
64
|
+
if @active_pane == pane
|
|
65
|
+
@active_pane = panes.first
|
|
66
|
+
end
|
|
67
|
+
pane
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Calculate layout rectangles for all panes
|
|
71
|
+
# Returns [{pane:, x:, y:, w:, h:}, ...]
|
|
72
|
+
def layout(x, y, w, h)
|
|
73
|
+
layout_node(@root, x, y, w, h)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Flat list of all panes (in-order traversal)
|
|
77
|
+
def panes
|
|
78
|
+
collect_panes(@root)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Cycle to next pane
|
|
82
|
+
def next_pane(current)
|
|
83
|
+
list = panes
|
|
84
|
+
idx = list.index(current)
|
|
85
|
+
return list.first unless idx
|
|
86
|
+
list[(idx + 1) % list.size]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Cycle to previous pane
|
|
90
|
+
def prev_pane(current)
|
|
91
|
+
list = panes
|
|
92
|
+
idx = list.index(current)
|
|
93
|
+
return list.last unless idx
|
|
94
|
+
list[(idx - 1) % list.size]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def single_pane?
|
|
98
|
+
@root.is_a?(PaneNode)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def pane_count
|
|
102
|
+
panes.size
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def find_node(node, pane)
|
|
108
|
+
case node
|
|
109
|
+
when PaneNode
|
|
110
|
+
node if node.pane == pane
|
|
111
|
+
when SplitNode
|
|
112
|
+
find_node(node.left, pane) || find_node(node.right, pane)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def find_parent(node, pane)
|
|
117
|
+
return nil unless node.is_a?(SplitNode)
|
|
118
|
+
|
|
119
|
+
if (node.left.is_a?(PaneNode) && node.left.pane == pane) ||
|
|
120
|
+
(node.right.is_a?(PaneNode) && node.right.pane == pane)
|
|
121
|
+
return node
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
find_parent(node.left, pane) || find_parent(node.right, pane)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def pane_in_subtree?(node, pane)
|
|
128
|
+
case node
|
|
129
|
+
when PaneNode
|
|
130
|
+
node.pane == pane
|
|
131
|
+
when SplitNode
|
|
132
|
+
pane_in_subtree?(node.left, pane) || pane_in_subtree?(node.right, pane)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def replace_node(target, replacement)
|
|
137
|
+
if target == @root
|
|
138
|
+
@root = replacement
|
|
139
|
+
return
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
parent = find_parent_of_node(@root, target)
|
|
143
|
+
return unless parent
|
|
144
|
+
|
|
145
|
+
if parent.left == target
|
|
146
|
+
parent.left = replacement
|
|
147
|
+
else
|
|
148
|
+
parent.right = replacement
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def find_parent_of_node(node, target)
|
|
153
|
+
return nil unless node.is_a?(SplitNode)
|
|
154
|
+
|
|
155
|
+
if node.left == target || node.right == target
|
|
156
|
+
return node
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
find_parent_of_node(node.left, target) || find_parent_of_node(node.right, target)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def layout_node(node, x, y, w, h)
|
|
163
|
+
case node
|
|
164
|
+
when PaneNode
|
|
165
|
+
[{pane: node.pane, x: x, y: y, w: w, h: h}]
|
|
166
|
+
when SplitNode
|
|
167
|
+
if node.direction == :vertical
|
|
168
|
+
left_w = (w * node.ratio).to_i
|
|
169
|
+
right_w = w - left_w
|
|
170
|
+
layout_node(node.left, x, y, left_w, h) +
|
|
171
|
+
layout_node(node.right, x + left_w, y, right_w, h)
|
|
172
|
+
else # :horizontal
|
|
173
|
+
top_h = (h * node.ratio).to_i
|
|
174
|
+
bottom_h = h - top_h
|
|
175
|
+
layout_node(node.left, x, y, w, top_h) +
|
|
176
|
+
layout_node(node.right, x, y + top_h, w, bottom_h)
|
|
177
|
+
end
|
|
178
|
+
else
|
|
179
|
+
[]
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def collect_panes(node)
|
|
184
|
+
case node
|
|
185
|
+
when PaneNode
|
|
186
|
+
[node.pane]
|
|
187
|
+
when SplitNode
|
|
188
|
+
collect_panes(node.left) + collect_panes(node.right)
|
|
189
|
+
else
|
|
190
|
+
[]
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|