przn 0.1.5

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,611 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Przn
4
+ class Renderer
5
+ ANSI = {
6
+ bold: "\e[1m",
7
+ italic: "\e[3m",
8
+ reverse: "\e[7m",
9
+ strikethrough: "\e[9m",
10
+ dim: "\e[2m",
11
+ cyan: "\e[36m",
12
+ gray_bg: "\e[48;5;236m",
13
+ reset: "\e[0m",
14
+ }.freeze
15
+
16
+ DEFAULT_SCALE = 2
17
+
18
+ def initialize(terminal, base_dir: '.', theme: nil)
19
+ @terminal = terminal
20
+ @base_dir = base_dir
21
+ @theme = theme
22
+ end
23
+
24
+ def render(slide, current:, total:)
25
+ @terminal.clear
26
+ w = @terminal.width
27
+ h = @terminal.height
28
+
29
+ row = if current == 0
30
+ content_height = calculate_height(slide.blocks, w)
31
+ usable_height = h - 1
32
+ [(usable_height - content_height) / 2 + 1, 1].max
33
+ else
34
+ 2
35
+ end
36
+
37
+ pending_align = nil
38
+ slide.blocks.each do |block|
39
+ if block[:type] == :align
40
+ pending_align = block[:align]
41
+ else
42
+ row = render_block(block, w, row, align: pending_align)
43
+ pending_align = nil
44
+ end
45
+ end
46
+
47
+ status = " #{current + 1} / #{total} "
48
+ @terminal.move_to(h, w - status.size)
49
+ @terminal.write "#{ANSI[:dim]}#{status}#{ANSI[:reset]}"
50
+
51
+ @terminal.flush
52
+ end
53
+
54
+ private
55
+
56
+ def render_block(block, width, row, align: nil)
57
+ case block[:type]
58
+ when :heading then render_heading(block, width, row)
59
+ when :paragraph then render_paragraph(block, width, row, align: align)
60
+ when :code_block then render_code_block(block, width, row)
61
+ when :unordered_list then render_unordered_list(block, width, row)
62
+ when :ordered_list then render_ordered_list(block, width, row)
63
+ when :definition_list then render_definition_list(block, width, row)
64
+ when :blockquote then render_blockquote(block, width, row)
65
+ when :table then render_table(block, width, row)
66
+ when :image then render_image(block, width, row)
67
+ when :blank then row + DEFAULT_SCALE
68
+ else row + 1
69
+ end
70
+ end
71
+
72
+ def render_heading(block, width, row)
73
+ text = block[:content]
74
+
75
+ if block[:level] == 1
76
+ scale = KittyText::HEADING_SCALES[1]
77
+ visible_width = display_width(text) * scale
78
+ pad = [(width - visible_width) / 2, 0].max
79
+ @terminal.move_to(row, pad + 1)
80
+ @terminal.write "#{ANSI[:bold]}#{KittyText.sized(text, s: scale)}#{ANSI[:reset]}"
81
+ row + scale + 4
82
+ else
83
+ left = content_left(width)
84
+ prefix = "・"
85
+ prefix_w = display_width(prefix)
86
+ max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
87
+ segments = Parser.parse_inline(text)
88
+ wrapped = wrap_segments(segments, max_w)
89
+
90
+ wrapped.each_with_index do |line_segs, li|
91
+ @terminal.move_to(row, left)
92
+ if li == 0
93
+ @terminal.write "#{KittyText.sized(prefix, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
94
+ else
95
+ @terminal.write "#{KittyText.sized(" " * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
96
+ end
97
+ row += DEFAULT_SCALE
98
+ end
99
+ row
100
+ end
101
+ end
102
+
103
+ def render_paragraph(block, width, row, align: nil)
104
+ text = block[:content]
105
+ scale = max_inline_scale(text) || DEFAULT_SCALE
106
+ left = content_left(width)
107
+
108
+ if align
109
+ vis = visible_width_scaled(text, scale)
110
+ left = compute_pad(width, vis, align)
111
+ end
112
+
113
+ max_w = max_text_width(width, left, scale)
114
+ segments = Parser.parse_inline(text)
115
+ wrapped = wrap_segments(segments, max_w)
116
+
117
+ wrapped.each do |line_segs|
118
+ @terminal.move_to(row, left + 1)
119
+ @terminal.write render_segments_scaled(line_segs, scale)
120
+ row += scale
121
+ end
122
+ row
123
+ end
124
+
125
+ def render_code_block(block, width, row)
126
+ code_lines = block[:content].lines.map(&:chomp)
127
+ return row + DEFAULT_SCALE if code_lines.empty?
128
+
129
+ left = content_left(width)
130
+ max_content_w = max_text_width(width, left, DEFAULT_SCALE) - 4
131
+ max_len = code_lines.map { |l| display_width(l) }.max
132
+ box_content_w = [max_len, max_content_w].min
133
+
134
+ code_lines.each do |code_line|
135
+ truncated = truncate_to_width(code_line, box_content_w)
136
+ padded = pad_to_width(truncated, box_content_w)
137
+ @terminal.move_to(row, left + 1)
138
+ @terminal.write "#{ANSI[:gray_bg]}#{KittyText.sized(" #{padded} ", s: DEFAULT_SCALE)}#{ANSI[:reset]}"
139
+ row += DEFAULT_SCALE
140
+ end
141
+
142
+ row
143
+ end
144
+
145
+ def render_unordered_list(block, width, row)
146
+ left = content_left(width)
147
+ block[:items].each do |item|
148
+ depth = item[:depth] || 0
149
+ indent = " " * depth
150
+ prefix = "#{indent}・"
151
+ prefix_w = display_width(prefix)
152
+ max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
153
+
154
+ segments = Parser.parse_inline(item[:text])
155
+ wrapped = wrap_segments(segments, max_w)
156
+
157
+ wrapped.each_with_index do |line_segs, li|
158
+ @terminal.move_to(row, left)
159
+ if li == 0
160
+ @terminal.write "#{KittyText.sized(prefix, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
161
+ else
162
+ @terminal.write "#{KittyText.sized(" " * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
163
+ end
164
+ row += DEFAULT_SCALE
165
+ end
166
+ row += 1
167
+ end
168
+ row
169
+ end
170
+
171
+ def render_ordered_list(block, width, row)
172
+ left = content_left(width)
173
+ block[:items].each_with_index do |item, i|
174
+ depth = item[:depth] || 0
175
+ indent = " " * depth
176
+ prefix = "#{indent}#{i + 1}. "
177
+ prefix_w = display_width(prefix)
178
+ max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
179
+
180
+ segments = Parser.parse_inline(item[:text])
181
+ wrapped = wrap_segments(segments, max_w)
182
+
183
+ wrapped.each_with_index do |line_segs, li|
184
+ @terminal.move_to(row, left)
185
+ if li == 0
186
+ @terminal.write "#{KittyText.sized(prefix, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
187
+ else
188
+ @terminal.write "#{KittyText.sized(" " * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
189
+ end
190
+ row += DEFAULT_SCALE
191
+ end
192
+ row += 1
193
+ end
194
+ row
195
+ end
196
+
197
+ def render_definition_list(block, width, row)
198
+ left = content_left(width)
199
+ max_w = max_text_width(width, left, DEFAULT_SCALE)
200
+
201
+ segments = Parser.parse_inline(block[:term])
202
+ wrapped = wrap_segments(segments, max_w)
203
+ wrapped.each do |line_segs|
204
+ @terminal.move_to(row, left)
205
+ @terminal.write "#{ANSI[:bold]}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}#{ANSI[:reset]}"
206
+ row += DEFAULT_SCALE
207
+ end
208
+
209
+ def_max_w = [max_w - 4, 1].max
210
+ block[:definition].each_line do |line|
211
+ segments = Parser.parse_inline(line.chomp)
212
+ wrapped = wrap_segments(segments, def_max_w)
213
+ wrapped.each do |line_segs|
214
+ @terminal.move_to(row, left + 4)
215
+ @terminal.write render_segments_scaled(line_segs, DEFAULT_SCALE)
216
+ row += DEFAULT_SCALE
217
+ end
218
+ end
219
+ row
220
+ end
221
+
222
+ def render_blockquote(block, width, row)
223
+ left = content_left(width)
224
+ prefix = "| "
225
+ prefix_w = display_width(prefix)
226
+ max_w = max_text_width(width, left + 1, DEFAULT_SCALE) - prefix_w
227
+
228
+ block[:content].each_line do |line|
229
+ text = line.chomp
230
+ segments = [[:text, text]]
231
+ wrapped = wrap_segments(segments, max_w)
232
+
233
+ wrapped.each_with_index do |line_segs, li|
234
+ @terminal.move_to(row, left + 1)
235
+ p = li == 0 ? prefix : " " * prefix_w
236
+ @terminal.write "#{ANSI[:dim]}#{KittyText.sized(p, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}#{ANSI[:reset]}"
237
+ row += DEFAULT_SCALE
238
+ end
239
+ end
240
+ row
241
+ end
242
+
243
+ def render_table(block, width, row)
244
+ left = content_left(width)
245
+ all_rows = [block[:header]] + block[:rows]
246
+ col_widths = Array.new(block[:header]&.size || 0, 0)
247
+ all_rows.each do |cells|
248
+ cells&.each_with_index do |cell, ci|
249
+ col_widths[ci] = [col_widths[ci] || 0, display_width(cell)].max
250
+ end
251
+ end
252
+
253
+ all_rows.each_with_index do |cells, ri|
254
+ next unless cells
255
+
256
+ @terminal.move_to(row, left)
257
+ line = cells.each_with_index.map { |cell, ci|
258
+ pad_to_width(cell, col_widths[ci] || 0)
259
+ }.join(" | ")
260
+ if ri == 0
261
+ @terminal.write "#{ANSI[:bold]}#{KittyText.sized(line, s: DEFAULT_SCALE)}#{ANSI[:reset]}"
262
+ else
263
+ @terminal.write KittyText.sized(line, s: DEFAULT_SCALE)
264
+ end
265
+ row += DEFAULT_SCALE
266
+
267
+ if ri == 0
268
+ @terminal.move_to(row, left)
269
+ @terminal.write KittyText.sized(col_widths.map { |w| "-" * w }.join("--+--"), s: DEFAULT_SCALE)
270
+ row += DEFAULT_SCALE
271
+ end
272
+ end
273
+ row
274
+ end
275
+
276
+ def render_image(block, width, row)
277
+ path = resolve_image_path(block[:path])
278
+ return row + DEFAULT_SCALE unless File.exist?(path)
279
+
280
+ img_size = ImageUtil.image_size(path)
281
+ return row + DEFAULT_SCALE unless img_size
282
+
283
+ img_w, img_h = img_size
284
+ cell_w, cell_h = @terminal.cell_pixel_size
285
+
286
+ available_rows = @terminal.height - row - 2
287
+ left = content_left(width)
288
+ available_cols = width - left * 2
289
+
290
+ if (rh = block[:attrs]['relative_height'])
291
+ target_rows = (@terminal.height * rh.to_i / 100.0).to_i
292
+ available_rows = [target_rows, available_rows].min
293
+ end
294
+
295
+ # Calculate target cell size maintaining aspect ratio
296
+ img_cell_w = img_w.to_f / cell_w
297
+ img_cell_h = img_h.to_f / cell_h
298
+ scale = [available_cols / img_cell_w, available_rows / img_cell_h, 1.0].min
299
+ target_cols = (img_cell_w * scale).to_i
300
+ target_rows = (img_cell_h * scale).to_i
301
+ target_cols = [target_cols, 1].max
302
+ target_rows = [target_rows, 1].max
303
+
304
+ x = [(width - target_cols) / 2, 0].max
305
+
306
+ if ImageUtil.kitty_terminal?
307
+ data = ImageUtil.kitty_icat(path, cols: target_cols, rows: target_rows, x: x, y: row - 1)
308
+ @terminal.write data if data && !data.empty?
309
+ elsif ImageUtil.sixel_available?
310
+ @terminal.move_to(row, x + 1)
311
+ target_pixel_w = target_cols * cell_w
312
+ target_pixel_h = target_rows * cell_h
313
+ sixel = ImageUtil.sixel_encode(path, width: target_pixel_w, height: target_pixel_h)
314
+ @terminal.write sixel if sixel && !sixel.empty?
315
+ end
316
+
317
+ row + target_rows
318
+ end
319
+
320
+ def resolve_image_path(path)
321
+ return path if File.absolute_path?(path) == path
322
+ File.expand_path(path, @base_dir)
323
+ end
324
+
325
+ def content_left(width)
326
+ width / 16
327
+ end
328
+
329
+ def max_text_width(terminal_width, left_col, scale)
330
+ (terminal_width - left_col) / scale
331
+ end
332
+
333
+ def compute_pad(width, content_width, align)
334
+ case align
335
+ when :right then [(width - content_width - 2), 0].max
336
+ when :center then [(width - content_width) / 2, 0].max
337
+ else content_left(width)
338
+ end
339
+ end
340
+
341
+ def render_inline(text)
342
+ Parser.parse_inline(text).map { |segment|
343
+ type = segment[0]
344
+ content = segment[1]
345
+ case type
346
+ when :tag then render_tag(content, segment[2])
347
+ when :note then "#{ANSI[:dim]}#{content}#{ANSI[:reset]}"
348
+ when :bold then "#{ANSI[:bold]}#{content}#{ANSI[:reset]}"
349
+ when :italic then "#{ANSI[:italic]}#{content}#{ANSI[:reset]}"
350
+ when :strikethrough then "#{ANSI[:strikethrough]}#{content}#{ANSI[:reset]}"
351
+ when :code then "#{ANSI[:gray_bg]} #{content} #{ANSI[:reset]}"
352
+ when :text then content
353
+ end
354
+ }.join
355
+ end
356
+
357
+ def render_tag(text, tag_name)
358
+ if (scale = Parser::SIZE_SCALES[tag_name])
359
+ KittyText.sized(text, s: scale)
360
+ elsif Parser::NAMED_COLORS.key?(tag_name)
361
+ "#{color_code(tag_name)}#{text}#{ANSI[:reset]}"
362
+ else
363
+ text
364
+ end
365
+ end
366
+
367
+ def render_segments_scaled(segments, para_scale)
368
+ segments.map { |segment|
369
+ type = segment[0]
370
+ content = segment[1]
371
+ case type
372
+ when :tag
373
+ tag_name = segment[2]
374
+ if (scale = Parser::SIZE_SCALES[tag_name])
375
+ KittyText.sized(content, s: scale)
376
+ elsif Parser::NAMED_COLORS.key?(tag_name)
377
+ "#{color_code(tag_name)}#{KittyText.sized(content, s: para_scale)}#{ANSI[:reset]}"
378
+ else
379
+ KittyText.sized(content, s: para_scale)
380
+ end
381
+ when :note then "#{ANSI[:dim]}#{KittyText.sized(content, s: para_scale)}#{ANSI[:reset]}"
382
+ when :bold then "#{ANSI[:bold]}#{KittyText.sized(content, s: para_scale)}#{ANSI[:reset]}"
383
+ when :italic then "#{ANSI[:italic]}#{KittyText.sized(content, s: para_scale)}#{ANSI[:reset]}"
384
+ when :strikethrough then "#{ANSI[:strikethrough]}#{KittyText.sized(content, s: para_scale)}#{ANSI[:reset]}"
385
+ when :code then "#{ANSI[:gray_bg]}#{KittyText.sized(" #{content} ", s: para_scale)}#{ANSI[:reset]}"
386
+ when :text then KittyText.sized(content, s: para_scale)
387
+ end
388
+ }.join
389
+ end
390
+
391
+ def render_inline_scaled(text, para_scale)
392
+ render_segments_scaled(Parser.parse_inline(text), para_scale)
393
+ end
394
+
395
+ # Wrap parsed inline segments into lines that fit within max_width display units
396
+ def wrap_segments(segments, max_width)
397
+ return [segments] if max_width <= 0
398
+
399
+ lines = [[]]
400
+ width = 0
401
+
402
+ segments.each do |seg|
403
+ content = seg[1] || ""
404
+ seg_w = display_width(content)
405
+
406
+ if width + seg_w <= max_width
407
+ lines.last << seg
408
+ width += seg_w
409
+ next
410
+ end
411
+
412
+ remaining = content
413
+ loop do
414
+ space = max_width - width
415
+ if space <= 0
416
+ lines << []
417
+ width = 0
418
+ space = max_width
419
+ end
420
+
421
+ chunk, remaining = split_by_display_width(remaining, space)
422
+ lines.last << [seg[0], chunk, *Array(seg[2..])]
423
+ width += display_width(chunk)
424
+
425
+ break unless remaining
426
+ lines << []
427
+ width = 0
428
+ end
429
+ end
430
+
431
+ lines
432
+ end
433
+
434
+ def split_by_display_width(text, max_width)
435
+ w = 0
436
+ text.each_char.with_index do |c, i|
437
+ cw = display_width(c)
438
+ if w + cw > max_width && w > 0
439
+ return [text[0...i], text[i..]]
440
+ end
441
+ w += cw
442
+ end
443
+ [text, nil]
444
+ end
445
+
446
+ def truncate_to_width(text, max_width)
447
+ w = 0
448
+ text.each_char.with_index do |c, i|
449
+ cw = display_width(c)
450
+ return text[0...i] if w + cw > max_width
451
+ w += cw
452
+ end
453
+ text
454
+ end
455
+
456
+ def pad_to_width(text, target_width)
457
+ current = display_width(text)
458
+ text + " " * [target_width - current, 0].max
459
+ end
460
+
461
+ def max_inline_scale(text)
462
+ max = 0
463
+ text.scan(/\{::tag\s+name="([^"]+)"\}/) do
464
+ scale = Parser::SIZE_SCALES[$1]
465
+ max = scale if scale && scale > max
466
+ end
467
+ max > 0 ? max : nil
468
+ end
469
+
470
+ def visible_width_scaled(text, default_scale)
471
+ Parser.parse_inline(text).sum { |segment|
472
+ type = segment[0]
473
+ content = segment[1]
474
+ case type
475
+ when :tag
476
+ scale = Parser::SIZE_SCALES[segment[2]] || default_scale
477
+ display_width(content) * scale
478
+ else
479
+ display_width(content) * default_scale
480
+ end
481
+ }
482
+ end
483
+
484
+ def color_code(color)
485
+ if (code = Parser::NAMED_COLORS[color])
486
+ "\e[#{code}m"
487
+ elsif color.match?(/\A[0-9a-fA-F]{6}\z/)
488
+ r, g, b = color.scan(/../).map { |h| h.to_i(16) }
489
+ "\e[38;2;#{r};#{g};#{b}m"
490
+ else
491
+ ""
492
+ end
493
+ end
494
+
495
+ def visible_length(text)
496
+ display_width(strip_markup(text))
497
+ end
498
+
499
+ def display_width(str)
500
+ str.each_char.sum { |c|
501
+ o = c.ord
502
+ if o >= 0x1100 &&
503
+ (o <= 0x115f ||
504
+ o == 0x2329 || o == 0x232a ||
505
+ (o >= 0x2e80 && o <= 0x303e) ||
506
+ (o >= 0x3040 && o <= 0x33bf) ||
507
+ (o >= 0x3400 && o <= 0x4dbf) ||
508
+ (o >= 0x4e00 && o <= 0xa4cf) ||
509
+ (o >= 0xac00 && o <= 0xd7a3) ||
510
+ (o >= 0xf900 && o <= 0xfaff) ||
511
+ (o >= 0xfe30 && o <= 0xfe6f) ||
512
+ (o >= 0xff00 && o <= 0xff60) ||
513
+ (o >= 0xffe0 && o <= 0xffe6) ||
514
+ (o >= 0x20000 && o <= 0x2fffd) ||
515
+ (o >= 0x30000 && o <= 0x3fffd))
516
+ 2
517
+ else
518
+ 1
519
+ end
520
+ }
521
+ end
522
+
523
+ def strip_markup(text)
524
+ text
525
+ .gsub(/\{::tag\s+name="[^"]+"\}(.*?)\{:\/tag\}/, '\1')
526
+ .gsub(/\{::note\}(.*?)\{:\/note\}/, '\1')
527
+ .gsub(/\{::wait\/\}/, '')
528
+ .gsub(/\*\*(.+?)\*\*/, '\1')
529
+ .gsub(/\*(.+?)\*/, '\1')
530
+ .gsub(/~~(.+?)~~/, '\1')
531
+ .gsub(/`([^`]+)`/, '\1')
532
+ end
533
+
534
+ def calculate_height(blocks, width)
535
+ blocks.sum { |b| block_height(b, width) }
536
+ end
537
+
538
+ def block_height(block, width)
539
+ s = DEFAULT_SCALE
540
+ left = content_left(width)
541
+ max_w = max_text_width(width, left, s)
542
+
543
+ case block[:type]
544
+ when :heading
545
+ scale = KittyText::HEADING_SCALES[block[:level]] || s
546
+ if block[:level] == 1
547
+ scale + 4
548
+ else
549
+ lines_count(block[:content], [max_w - 2, 1].max) * scale
550
+ end
551
+ when :paragraph
552
+ para_scale = max_inline_scale(block[:content]) || s
553
+ lines_count(block[:content], [max_text_width(width, left, para_scale), 1].max) * para_scale
554
+ when :code_block
555
+ [block[:content].lines.size * s, s].max
556
+ when :unordered_list
557
+ block[:items].sum { |item|
558
+ prefix_w = (item[:depth] || 0) * 2 + 2
559
+ lines_count(item[:text], [max_w - prefix_w, 1].max) * s
560
+ }
561
+ when :ordered_list
562
+ block[:items].size * s
563
+ when :definition_list
564
+ term_lines = lines_count(block[:term], [max_w, 1].max)
565
+ def_lines = block[:definition].lines.sum { |l| lines_count(l.chomp, [max_w - 4, 1].max) }
566
+ (term_lines + def_lines) * s
567
+ when :blockquote
568
+ block[:content].lines.sum { |l| lines_count(l.chomp, [max_w - 3, 1].max) } * s
569
+ when :table
570
+ ((block[:header] ? 2 : 0) + block[:rows].size) * s
571
+ when :image
572
+ image_block_height(block, width)
573
+ when :align
574
+ 0
575
+ when :blank
576
+ s
577
+ else
578
+ s
579
+ end
580
+ end
581
+
582
+ def lines_count(text, max_width)
583
+ vis_w = display_width(strip_markup(text))
584
+ return 1 if vis_w <= max_width
585
+ (vis_w.to_f / max_width).ceil
586
+ end
587
+
588
+ def image_block_height(block, width)
589
+ path = resolve_image_path(block[:path])
590
+ img_size = ImageUtil.image_size(path)
591
+ return DEFAULT_SCALE unless img_size
592
+
593
+ img_w, img_h = img_size
594
+ cell_w, cell_h = @terminal.cell_pixel_size
595
+ h = @terminal.height
596
+
597
+ left = content_left(width)
598
+ available_cols = width - left * 2
599
+ available_rows = h / 2
600
+
601
+ if (rh = block[:attrs]['relative_height'])
602
+ available_rows = (h * rh.to_i / 100.0).to_i
603
+ end
604
+
605
+ img_cell_w = img_w.to_f / cell_w
606
+ img_cell_h = img_h.to_f / cell_h
607
+ scale = [available_cols / img_cell_w, available_rows / img_cell_h, 1.0].min
608
+ [(img_cell_h * scale).ceil, 1].max
609
+ end
610
+ end
611
+ end
data/lib/przn/slide.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Przn
4
+ class Slide
5
+ attr_reader :blocks
6
+
7
+ def initialize(blocks)
8
+ @blocks = blocks.freeze
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/console'
4
+
5
+ module Przn
6
+ class Terminal
7
+ def initialize(input: $stdin, output: $stdout)
8
+ @in = input
9
+ @out = output
10
+ end
11
+
12
+ def width = @in.winsize[1]
13
+ def height = @in.winsize[0]
14
+
15
+ def cell_pixel_size
16
+ @cell_pixel_size ||= query_cell_pixel_size || [10, 20]
17
+ end
18
+
19
+ def raw(&block) = @in.raw(&block)
20
+ def getch = @in.getch
21
+
22
+ def write(str)
23
+ @out.write(str)
24
+ end
25
+
26
+ def flush
27
+ @out.flush
28
+ end
29
+
30
+ def clear
31
+ write "\e[2J\e[H"
32
+ end
33
+
34
+ def move_to(row, col)
35
+ write "\e[#{row};#{col}H"
36
+ end
37
+
38
+ def hide_cursor
39
+ write "\e[?25l"
40
+ end
41
+
42
+ def show_cursor
43
+ write "\e[?25h"
44
+ end
45
+
46
+ def enter_alt_screen
47
+ write "\e[?1049h"
48
+ end
49
+
50
+ def leave_alt_screen
51
+ write "\e[?1049l"
52
+ end
53
+
54
+ private
55
+
56
+ def query_cell_pixel_size
57
+ buf = "\0" * 8
58
+ [0x40087468, 0x5413].each do |ioctl_code|
59
+ begin
60
+ @in.ioctl(ioctl_code, buf)
61
+ rows, cols, xpixel, ypixel = buf.unpack('SSSS')
62
+ if xpixel > 0 && ypixel > 0 && rows > 0 && cols > 0
63
+ return [xpixel / cols, ypixel / rows]
64
+ end
65
+ rescue
66
+ next
67
+ end
68
+ end
69
+ nil
70
+ end
71
+ end
72
+ end