terminal-layout 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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