terminal-layout 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.
@@ -0,0 +1,527 @@
1
+ require 'pry'
2
+ require 'ansi_string'
3
+ require 'ostruct'
4
+
5
+ module TerminalLayout
6
+ Dimension = Struct.new(:width, :height)
7
+ Position = Struct.new(:x, :y)
8
+
9
+ module EventEmitter
10
+ def _callbacks
11
+ @_callbacks ||= Hash.new { |h, k| h[k] = [] }
12
+ end
13
+
14
+ def on(type, *args, &blk)
15
+ _callbacks[type] << blk
16
+ self
17
+ end
18
+
19
+ def unsubscribe
20
+ _callbacks.clear
21
+ end
22
+
23
+ def emit(type, *args)
24
+ _callbacks[type].each do |blk|
25
+ blk.call(*args)
26
+ end
27
+ end
28
+ end
29
+
30
+ class RenderObject
31
+ include EventEmitter
32
+
33
+ attr_accessor :box, :style, :children, :content, :parent
34
+
35
+ def initialize(box, parent:, content:nil, style:{x:nil, y:nil}, renderer:nil)
36
+ @box = box
37
+ @content = ANSIString.new(content)
38
+ @children = []
39
+ @parent = parent
40
+ @renderer = renderer
41
+ @style = style
42
+ style[:x] || style[:x] = 0
43
+ style[:y] || style[:y] = 0
44
+
45
+ @box.update_computed(style)
46
+ end
47
+
48
+ def offset
49
+ offset_x = self.x
50
+ offset_y = self.y
51
+ _parent = @parent
52
+ loop do
53
+ break unless _parent
54
+ offset_x += _parent.x
55
+ offset_y += _parent.y
56
+ _parent = _parent.parent
57
+ end
58
+ Position.new(offset_x, offset_y)
59
+ end
60
+
61
+ def starting_x_for_current_y
62
+ children.map do |child|
63
+ next unless child.float == :left || child.display == :inline
64
+ next unless child.y && child.y <= @current_y && (child.y + child.height - 1) >= @current_y
65
+
66
+ [child.x + child.width, x].max
67
+ end.compact.max || 0
68
+ end
69
+
70
+ def ending_x_for_current_y
71
+ children.map do |child|
72
+ next unless child.float == :right
73
+ next unless child.y && child.y <= @current_y && (child.y + child.height - 1) >= @current_y
74
+
75
+ [child.x, width].min
76
+ end.compact.min || self.width || @box.width
77
+ end
78
+
79
+ %w(width height display x y float).each do |method|
80
+ define_method(method){ style[method.to_sym] }
81
+
82
+ define_method("#{method}=") do |value|
83
+ style[method.to_sym] = value
84
+ @box.computed[method] = value
85
+ end
86
+ end
87
+
88
+ def position
89
+ Position.new(x, y)
90
+ end
91
+
92
+ def size
93
+ Dimension.new(width, height)
94
+ end
95
+
96
+ def width
97
+ style[:width]
98
+ end
99
+
100
+ def height
101
+ style[:height]
102
+ end
103
+
104
+ def inspect
105
+ to_s
106
+ end
107
+
108
+ def to_str
109
+ to_s
110
+ end
111
+
112
+ def to_s
113
+ "<#{self.class.name} position=(#{x},#{y}) dimensions=#{width}x#{height} content=#{content}/>"
114
+ end
115
+
116
+ def render
117
+ # Rather than worry about a 2-dimensional space we're going to cheat
118
+ # and convert everything to a single point.
119
+ result = height.times.map { |n| (" " * width) }.join
120
+ result = ANSIString.new(result)
121
+
122
+ if content && content.length > 0
123
+ result[0...content.length] = content.dup.to_s
124
+ end
125
+
126
+ children.each do |child|
127
+ rendered_content = child.render
128
+
129
+ # Find the single point where this child's content should be placed.
130
+ # (child.y * width): make sure we take into account the row we're on
131
+ # plus (child.y): make sure take into account the number of newlines
132
+ x = child.x + (child.y * width)
133
+ result[x...(x+rendered_content.length)] = rendered_content
134
+ end
135
+
136
+ result
137
+ end
138
+
139
+ def layout
140
+ self.children = []
141
+ @current_x = 0
142
+ @current_y = 0
143
+ if @box.display == :block && @box.content.length > 0
144
+ ending_x = ending_x_for_current_y
145
+ available_width = ending_x - @current_x
146
+ new_parent = Box.new(content: nil, style: @box.style.dup.merge(width: available_width))
147
+ inline_box = Box.new(content: @box.content, style: {display: :inline})
148
+ new_parent.children = [inline_box].concat @box.children
149
+ children2crawl = [new_parent]
150
+ else
151
+ children2crawl = @box.children
152
+ end
153
+
154
+ children2crawl.each do |cbox|
155
+ if cbox.display == :float
156
+ next if cbox.width.to_i == 0
157
+
158
+ render_object = layout_float cbox
159
+ cbox.height = render_object.height
160
+
161
+ next if cbox.height.to_i == 0
162
+
163
+ self.children << render_object
164
+ elsif cbox.display == :block
165
+ if children.last && children.last.display == :inline && @current_x != 0
166
+ @current_x = 0
167
+ @current_y += 1
168
+ end
169
+
170
+ @current_x = starting_x_for_current_y
171
+ available_width = ending_x_for_current_y - @current_x
172
+
173
+ if cbox.width && cbox.width > available_width
174
+ @current_y += 1
175
+ @current_x = starting_x_for_current_y
176
+ available_width = ending_x_for_current_y - @current_x
177
+ end
178
+
179
+ render_object = render_object_for(cbox, content:nil, style: {width: (cbox.width || available_width)})
180
+ render_object.layout
181
+ render_object.x = @current_x
182
+ render_object.y = @current_y
183
+
184
+ if cbox.height
185
+ render_object.height = cbox.height
186
+ end
187
+
188
+ next if [nil, 0].include?(render_object.width) || [nil, 0].include?(render_object.height)
189
+
190
+ @current_x = 0
191
+ @current_y += [render_object.height, 1].max
192
+
193
+ self.children << render_object
194
+ elsif cbox.display == :inline
195
+ @current_x = starting_x_for_current_y if @current_x == 0
196
+ available_width = ending_x_for_current_y - @current_x
197
+
198
+ content_i = 0
199
+ content = ""
200
+
201
+ loop do
202
+ partial_content = cbox.content[content_i...(content_i + available_width)]
203
+ chars_needed = partial_content.length
204
+ self.children << render_object_for(cbox, content:partial_content, style: {display: :inline, x:@current_x, y: @current_y, width:chars_needed, height:1})
205
+
206
+ content_i += chars_needed
207
+
208
+ if chars_needed >= available_width
209
+ @current_y += 1
210
+ @current_x = starting_x_for_current_y
211
+ available_width = ending_x_for_current_y - @current_x
212
+ elsif chars_needed == 0
213
+ break
214
+ else
215
+ @current_x += chars_needed
216
+ end
217
+
218
+ break if content_i >= cbox.content.length
219
+ end
220
+ end
221
+ end
222
+
223
+ if !height
224
+ if children.length >= 2
225
+ last_child = children.max{ |child| child.y }
226
+ self.height = last_child.y + last_child.height
227
+ elsif children.length == 1
228
+ self.height = self.children.first.height
229
+ else
230
+ self.height = @box.height || 0
231
+ end
232
+ end
233
+
234
+ self.children
235
+ end
236
+
237
+ def layout_float(fbox)
238
+ # only allow the float to be as wide as its parent
239
+ # - first check is the box itself, was it assigned a width?
240
+ if @box.width && fbox.width > width
241
+ fbox.width = width
242
+ end
243
+
244
+ if fbox.float == :left
245
+ # if we cannot fit on this line, go to the next
246
+ if @current_x + fbox.width > width
247
+ @current_x = 0
248
+ @current_y += 1
249
+ end
250
+
251
+ fbox.x = @current_x
252
+ fbox.y = @current_y
253
+
254
+ render_object = render_object_for(fbox, content: fbox.content, style: {height: fbox.height})
255
+ render_object.layout
256
+
257
+ @current_x += fbox.width
258
+ return render_object
259
+ elsif fbox.float == :right
260
+ # loop in case there are left floats on the left as we move down rows
261
+ loop do
262
+ starting_x = starting_x_for_current_y
263
+ available_width = ending_x_for_current_y - starting_x
264
+
265
+ # if we cannot fit on this line, go to the next
266
+ width_needed = fbox.width
267
+ if width_needed > available_width
268
+ @current_x = 0
269
+ @current_y += 1
270
+ else
271
+ break
272
+ end
273
+ end
274
+
275
+ @current_x = ending_x_for_current_y - fbox.width
276
+ fbox.x = @current_x
277
+ fbox.y = @current_y
278
+
279
+ render_object = render_object_for(fbox, content: fbox.content, style: {height: fbox.height})
280
+ render_object.layout
281
+
282
+ # reset X back to what it should be
283
+ @current_x = starting_x_for_current_y
284
+ return render_object
285
+ end
286
+ end
287
+
288
+ def render_object_for(cbox, content:nil, style:{})
289
+ case cbox.display
290
+ when :block
291
+ BlockRenderObject.new(cbox, parent: self, content: content, style: {width:@box.width}.merge(style), renderer:@renderer)
292
+ when :inline
293
+ InlineRenderObject.new(cbox, parent: self, content: content, style: style, renderer:@renderer)
294
+ when :float
295
+ FloatRenderObject.new(cbox, parent: self, content: content, style: {x: @current_x, y: @current_y, float: cbox.float}.merge(style), renderer:@renderer)
296
+ end
297
+ end
298
+ end
299
+
300
+ class RenderTree < RenderObject
301
+ end
302
+
303
+ class BlockRenderObject < RenderObject
304
+ def initialize(*args)
305
+ super
306
+ style.has_key?(:display) || style[:display] = :block
307
+ style.has_key?(:width) || style[:width] = @box.width
308
+ end
309
+ end
310
+
311
+ class FloatRenderObject < BlockRenderObject
312
+ end
313
+
314
+ class InlineRenderObject < RenderObject
315
+ end
316
+
317
+ class Box
318
+ include EventEmitter
319
+
320
+ attr_accessor :style, :children, :content, :computed
321
+
322
+ def initialize(style:{}, children:[], content:"")
323
+ @style = style
324
+ @children = children
325
+ @content = ANSIString.new(content)
326
+ @computed = {}
327
+
328
+ initialize_defaults
329
+
330
+ @children.each do |child|
331
+ child.on(:content_changed) do |*args|
332
+ emit :child_changed
333
+ end
334
+
335
+ child.on(:child_changed) do |*args|
336
+ emit :child_changed
337
+ end
338
+
339
+ child.on(:cursor_position_changed) do |*args|
340
+ emit :cursor_position_changed
341
+ end
342
+ end
343
+ end
344
+
345
+ %w(width height display x y float).each do |method|
346
+ define_method(method){ style[method.to_sym] }
347
+ define_method("#{method}="){ |value| style[method.to_sym] = value }
348
+ end
349
+
350
+ def content=(str)
351
+ old = @content
352
+ @content = ANSIString.new(str)
353
+ emit :content_changed, old, @content
354
+ end
355
+
356
+ def position
357
+ Position.new(x, y)
358
+ end
359
+
360
+ def size
361
+ Dimension.new(width, height)
362
+ end
363
+
364
+ def width
365
+ style[:width]
366
+ end
367
+
368
+ def height
369
+ style[:height]
370
+ end
371
+
372
+ def inspect
373
+ to_s
374
+ end
375
+
376
+ def to_str
377
+ to_s
378
+ end
379
+
380
+ def to_s
381
+ "<Box##{object_id} position=(#{x},#{y}) dimensions=#{width}x#{height} display=#{display.inspect} content=#{content}/>"
382
+ end
383
+
384
+ def update_computed(style)
385
+ @computed.merge!(style)
386
+ end
387
+
388
+ private
389
+
390
+ def initialize_defaults
391
+ style.has_key?(:display) || style[:display] = :block
392
+ end
393
+ end
394
+
395
+
396
+ class InputBox < Box
397
+ attr_accessor :cursor_position
398
+
399
+ def initialize(*args)
400
+ super
401
+ @cursor_offset_x = 0
402
+ @cursor_position = OpenStruct.new(x: 0, y: 0)
403
+ end
404
+
405
+ def content=(str)
406
+ old = @content
407
+ @content = ANSIString.new(str)
408
+ emit :content_changed, old, @content
409
+ end
410
+
411
+ def position=(position)
412
+ @cursor_offset_x = position
413
+ @cursor_position.x = @cursor_offset_x + @computed[:x]
414
+ emit :cursor_position_changed, nil, @cursor_position.x
415
+ end
416
+
417
+ def update_computed(style)
418
+ @computed.merge!(style)
419
+ @cursor_position.x = style[:x] + @cursor_offset_x
420
+ @cursor_position.y = style[:y]
421
+ end
422
+ end
423
+
424
+ require 'terminfo'
425
+ require 'termios'
426
+ require 'highline/system_extensions'
427
+ class TerminalRenderer
428
+ include HighLine::SystemExtensions
429
+ include EventEmitter
430
+
431
+ attr_reader :term_info
432
+
433
+ def initialize(output: $stdout)
434
+ @output = output
435
+ @term_info = TermInfo.new ENV["TERM"], @output
436
+ @x, @y = 0, 0
437
+ end
438
+
439
+ def render_cursor(input_box)
440
+ move_up_n_rows @y
441
+ move_to_beginning_of_row
442
+
443
+ cursor_position = input_box.cursor_position
444
+ cursor_x = cursor_position.x
445
+ cursor_y = cursor_position.y
446
+
447
+ # TODO: make this work when lines wrap
448
+ if cursor_x < 0
449
+ cursor_x = terminal_width
450
+ cursor_y -= 1
451
+ elsif cursor_x >= terminal_width
452
+ end
453
+
454
+ move_right_n_characters cursor_position.x
455
+ move_down_n_rows cursor_position.y
456
+
457
+ @y = input_box.cursor_position.y
458
+ end
459
+
460
+ def render(object)
461
+ dumb_render(object)
462
+ end
463
+
464
+ def reset
465
+ @y = 0
466
+ end
467
+
468
+ def dumb_render(object)
469
+ @output.print @term_info.control_string "civis"
470
+ move_up_n_rows @y
471
+ move_to_beginning_of_row
472
+
473
+ loop do
474
+ break unless object.parent
475
+ object = object.parent
476
+ end
477
+
478
+ object_width = object.width
479
+ clear_screen_down
480
+
481
+ rendered_content = object.render
482
+ printable_content = rendered_content.sub(/\s*\Z/m, '')
483
+
484
+ printable_content.lines.each do |line|
485
+ move_to_beginning_of_row
486
+ @output.puts line
487
+ end
488
+ move_to_beginning_of_row
489
+
490
+ # calculate lines drawn so we know where we are
491
+ lines_drawn = (printable_content.length / object_width.to_f).ceil
492
+ @y = lines_drawn
493
+
494
+ input_box = find_input_box(object.box)
495
+
496
+ render_cursor(input_box)
497
+
498
+ @output.print @term_info.control_string "cnorm"
499
+ end
500
+
501
+ def find_input_box(dom_node)
502
+ dom_node.children.detect do |child|
503
+ child.is_a?(InputBox) || find_input_box(child)
504
+ end
505
+ end
506
+
507
+ def clear_to_beginning_of_line ; term_info.control "el1" ; end
508
+ def clear_screen ; term_info.control "clear" ; end
509
+ def clear_screen_down ; term_info.control "ed" ; end
510
+ def move_to_beginning_of_row ; move_to_column 0 ; end
511
+ def move_left ; move_left_n_characters 1 ; end
512
+ def move_left_n_characters(n) ; n.times { term_info.control "cub1" } ; end
513
+ def move_right_n_characters(n) ; n.times { term_info.control "cuf1" } ; end
514
+ def move_to_column_and_row(column, row) ; term_info.control "cup", column, row ; end
515
+ def move_to_column(n) ; term_info.control "hpa", n ; end
516
+ def move_up_n_rows(n) ; n.times { term_info.control "cuu1" } ; end
517
+ def move_down_n_rows(n) ; n.times { term_info.control "cud1" } ; end
518
+
519
+ def terminal_width
520
+ terminal_size[0]
521
+ end
522
+
523
+ def terminal_height
524
+ terminal_size[1]
525
+ end
526
+ end
527
+ end