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,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