przn 0.3.0 → 0.5.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 +4 -4
- data/README.md +66 -0
- data/Rakefile +5 -5
- data/default_theme.yml +8 -0
- data/exe/przn +21 -4
- data/lib/przn/audience_link.rb +51 -0
- data/lib/przn/controller.rb +16 -2
- data/lib/przn/echoes_client.rb +83 -0
- data/lib/przn/image_util.rb +15 -3
- data/lib/przn/kitty_text.rb +23 -4
- data/lib/przn/parser.rb +90 -20
- data/lib/przn/{pdf_exporter.rb → prawn_pdf_exporter.rb} +26 -14
- data/lib/przn/presenter_renderer.rb +71 -0
- data/lib/przn/renderer.rb +180 -35
- data/lib/przn/screenshot_pdf_exporter.rb +18 -3
- data/lib/przn/slide.rb +25 -0
- data/lib/przn/theme.rb +32 -1
- data/lib/przn/version.rb +1 -1
- data/lib/przn.rb +77 -28
- data/sample/doge.jpg +0 -0
- data/sample/doge.png +0 -0
- data/sample/sample.md +24 -0
- metadata +7 -2
data/lib/przn/parser.rb
CHANGED
|
@@ -14,7 +14,7 @@ module Przn
|
|
|
14
14
|
'xx-large' => 5,
|
|
15
15
|
'xxx-large' => 6,
|
|
16
16
|
'xxxx-large' => 7,
|
|
17
|
-
'1' => 1, '2' => 2, '3' => 3, '4' => 4, '5' => 5, '6' => 6, '7' => 7
|
|
17
|
+
'1' => 1, '2' => 2, '3' => 3, '4' => 4, '5' => 5, '6' => 6, '7' => 7
|
|
18
18
|
}.freeze
|
|
19
19
|
|
|
20
20
|
NAMED_COLORS = {
|
|
@@ -22,9 +22,18 @@ module Przn
|
|
|
22
22
|
'magenta' => 35, 'cyan' => 36, 'white' => 37,
|
|
23
23
|
'bright_red' => 91, 'bright_green' => 92, 'bright_yellow' => 93,
|
|
24
24
|
'bright_blue' => 94, 'bright_magenta' => 95, 'bright_cyan' => 96,
|
|
25
|
-
'bright_white' => 97
|
|
25
|
+
'bright_white' => 97
|
|
26
26
|
}.freeze
|
|
27
27
|
|
|
28
|
+
# HTML-ish attribute, three accepted value forms:
|
|
29
|
+
# key="value" key='value' key=bareword
|
|
30
|
+
# The unquoted token excludes whitespace, `=`, `<`, `>`, `"`, `'` and
|
|
31
|
+
# backtick, matching the spirit of HTML5's unquoted-attribute grammar.
|
|
32
|
+
# `/` is intentionally NOT excluded so paths like `src=path/to/file`
|
|
33
|
+
# work — which means self-closing tags need a space before `/>` when
|
|
34
|
+
# the last attribute is unquoted (`<img src=foo.png />`).
|
|
35
|
+
ATTR_RE_SRC = '\w+=(?:"[^"]*"|\'[^\']*\'|[^\s=<>"\'`]+)'
|
|
36
|
+
|
|
28
37
|
module_function
|
|
29
38
|
|
|
30
39
|
def parse(markdown)
|
|
@@ -35,7 +44,7 @@ module Przn
|
|
|
35
44
|
# Split on h1 headings (Rabbit-compatible)
|
|
36
45
|
def split_slides(markdown)
|
|
37
46
|
chunks = []
|
|
38
|
-
current = +
|
|
47
|
+
current = +''
|
|
39
48
|
in_fence = false
|
|
40
49
|
|
|
41
50
|
markdown.each_line do |line|
|
|
@@ -79,9 +88,20 @@ module Przn
|
|
|
79
88
|
# Slide background (Echoes OSC 7772):
|
|
80
89
|
# <bg color="#..."/> — solid (bg-color)
|
|
81
90
|
# <bg from="#..." to="#..." angle="N"/> — linear gradient (bg-gradient)
|
|
82
|
-
|
|
91
|
+
# Attribute values may be double-quoted, single-quoted, or
|
|
92
|
+
# unquoted (HTML5-ish — see ATTR_RE_SRC).
|
|
93
|
+
when %r{\A\s*<bg((?:\s+#{ATTR_RE_SRC})*)\s*/>\s*\z}o
|
|
83
94
|
blocks << {type: :bg, attrs: parse_xml_attrs(Regexp.last_match(1))}
|
|
84
95
|
|
|
96
|
+
# Absolute-position text:
|
|
97
|
+
# <at x="N" y="N">content</at>
|
|
98
|
+
# {::at x="N" y="N"}content{:/at}
|
|
99
|
+
# Content can include inline markup (size, color, font, bold, …).
|
|
100
|
+
when %r{\A\s*<at((?:\s+#{ATTR_RE_SRC})+)\s*>(.*)</at>\s*\z}o
|
|
101
|
+
blocks << {type: :at, attrs: parse_xml_attrs(Regexp.last_match(1)), content: Regexp.last_match(2)}
|
|
102
|
+
when %r{\A\s*\{::at((?:\s+#{ATTR_RE_SRC})+)\}(.*)\{:/at\}\s*\z}o
|
|
103
|
+
blocks << {type: :at, attrs: parse_xml_attrs(Regexp.last_match(1)), content: Regexp.last_match(2)}
|
|
104
|
+
|
|
85
105
|
# Fenced code block
|
|
86
106
|
when /\A\s*```(\w*)\s*\z/
|
|
87
107
|
lang = Regexp.last_match(1)
|
|
@@ -155,7 +175,7 @@ module Przn
|
|
|
155
175
|
items << {text: Regexp.last_match(2), depth: depth}
|
|
156
176
|
elsif lines[i].match(/\A {2,}(\S.*)/)
|
|
157
177
|
# Continuation line
|
|
158
|
-
items.last[:text] <<
|
|
178
|
+
items.last[:text] << ' ' << Regexp.last_match(1) if items.last
|
|
159
179
|
else
|
|
160
180
|
break
|
|
161
181
|
end
|
|
@@ -176,6 +196,23 @@ module Przn
|
|
|
176
196
|
i -= 1
|
|
177
197
|
blocks << {type: :ordered_list, items: items}
|
|
178
198
|
|
|
199
|
+
# Image, XML form: <img src="path" alt="..." title="..." {:attrs}/>
|
|
200
|
+
# Equivalent to the markdown `{:attrs}` form below
|
|
201
|
+
# — emits the same `:image` block so the renderer handles both
|
|
202
|
+
# identically. `src` is required; all other attributes pass through
|
|
203
|
+
# to `block[:attrs]` (string-keyed, matching markdown's IAL parse) so
|
|
204
|
+
# `relative_height`, `width`, etc. work the same way.
|
|
205
|
+
when %r{\A\s*<img((?:\s+#{ATTR_RE_SRC})+)\s*/>\s*\z}o
|
|
206
|
+
raw = parse_xml_attrs(Regexp.last_match(1))
|
|
207
|
+
path = raw.delete(:src)
|
|
208
|
+
if path
|
|
209
|
+
alt = raw.delete(:alt).to_s
|
|
210
|
+
title = raw.delete(:title)
|
|
211
|
+
attrs = raw.transform_keys(&:to_s)
|
|
212
|
+
normalize_image_attrs!(attrs)
|
|
213
|
+
blocks << {type: :image, path: path, alt: alt, title: title, attrs: attrs}
|
|
214
|
+
end
|
|
215
|
+
|
|
179
216
|
# Image: {:attrs}
|
|
180
217
|
when /\A!\[([^\]]*)\]\((\S+?)(?:\s+"([^"]*)")?\)(.*)/
|
|
181
218
|
alt = Regexp.last_match(1)
|
|
@@ -189,11 +226,12 @@ module Przn
|
|
|
189
226
|
attr_str = rest.sub(/\A\{:?\s*/, '')
|
|
190
227
|
while !attr_str.include?('}') && (i + 1) < lines.size
|
|
191
228
|
i += 1
|
|
192
|
-
attr_str <<
|
|
229
|
+
attr_str << ' ' << lines[i].strip
|
|
193
230
|
end
|
|
194
231
|
attr_str = attr_str.sub(/\}\s*\z/, '')
|
|
195
232
|
parse_image_attrs(attr_str, attrs)
|
|
196
233
|
end
|
|
234
|
+
normalize_image_attrs!(attrs)
|
|
197
235
|
blocks << {type: :image, path: path, alt: alt, title: title, attrs: attrs}
|
|
198
236
|
|
|
199
237
|
# Definition list: term on one line, : definition on next
|
|
@@ -237,12 +275,14 @@ module Przn
|
|
|
237
275
|
attrs.slice(:face, :size, :color)
|
|
238
276
|
end
|
|
239
277
|
|
|
240
|
-
# Generic attribute scanner —
|
|
241
|
-
#
|
|
278
|
+
# Generic attribute scanner — three value forms accepted:
|
|
279
|
+
# key="value" key='value' key=bareword
|
|
280
|
+
# Returns a hash with symbolized keys. Doesn't validate which keys
|
|
281
|
+
# are allowed; callers slice.
|
|
242
282
|
def parse_xml_attrs(str)
|
|
243
283
|
attrs = {}
|
|
244
|
-
str.scan(/(\w+)="([^"]+)
|
|
245
|
-
attrs[key.to_sym] =
|
|
284
|
+
str.scan(/(\w+)=(?:"([^"]*)"|'([^']*)'|([^\s=<>"'`]+))/) do |key, dq, sq, uq|
|
|
285
|
+
attrs[key.to_sym] = dq || sq || uq
|
|
246
286
|
end
|
|
247
287
|
attrs
|
|
248
288
|
end
|
|
@@ -254,6 +294,22 @@ module Przn
|
|
|
254
294
|
end
|
|
255
295
|
end
|
|
256
296
|
|
|
297
|
+
# Rewrite `height="N%"` / `width="N%"` into the canonical
|
|
298
|
+
# `relative_height="N"` / `relative_width="N"` the renderer reads.
|
|
299
|
+
# Values without a `%` suffix pass through unchanged (and are
|
|
300
|
+
# ignored downstream); an explicit `relative_*` already on the
|
|
301
|
+
# block wins so authors can mix forms without surprise.
|
|
302
|
+
def normalize_image_attrs!(attrs)
|
|
303
|
+
if (h = attrs['height']) && (m = h.match(/\A(\d+)%\z/))
|
|
304
|
+
attrs.delete('height')
|
|
305
|
+
attrs['relative_height'] ||= m[1]
|
|
306
|
+
end
|
|
307
|
+
if (w = attrs['width']) && (m = w.match(/\A(\d+)%\z/))
|
|
308
|
+
attrs.delete('width')
|
|
309
|
+
attrs['relative_width'] ||= m[1]
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
257
313
|
def parse_table(lines)
|
|
258
314
|
rows = []
|
|
259
315
|
lines.each do |line|
|
|
@@ -275,22 +331,22 @@ module Przn
|
|
|
275
331
|
segments << [:tag, scanner[2], scanner[1]]
|
|
276
332
|
elsif scanner.scan(/<size=([^>\s]+)>(.*?)<\/size>/)
|
|
277
333
|
segments << [:tag, scanner[2], scanner[1]]
|
|
278
|
-
elsif scanner.scan(
|
|
334
|
+
elsif scanner.scan(%r{<font((?:\s+#{ATTR_RE_SRC})+)\s*>(.*?)</font>}o)
|
|
279
335
|
segments << [:font, scanner[2], parse_font_attrs(scanner[1])]
|
|
280
|
-
elsif scanner.scan(
|
|
336
|
+
elsif scanner.scan(%r{\{::font((?:\s+#{ATTR_RE_SRC})+)\}(.*?)\{:/font\}}o)
|
|
281
337
|
segments << [:font, scanner[2], parse_font_attrs(scanner[1])]
|
|
282
338
|
elsif scanner.scan(/\{::note\}(.*?)\{:\/note\}/)
|
|
283
339
|
segments << [:note, scanner[1]]
|
|
284
340
|
elsif scanner.scan(/<note>(.*?)<\/note>/)
|
|
285
341
|
segments << [:note, scanner[1]]
|
|
286
|
-
elsif scanner.scan(
|
|
342
|
+
elsif scanner.scan('{::wait/}') || scanner.scan(/<wait\s*\/>/)
|
|
287
343
|
# skip wait markers in inline text
|
|
288
|
-
elsif scanner.scan(
|
|
289
|
-
segments << [:text,
|
|
290
|
-
elsif scanner.scan(
|
|
291
|
-
segments << [:text,
|
|
292
|
-
elsif scanner.scan(
|
|
293
|
-
segments << [:text,
|
|
344
|
+
elsif scanner.scan('<')
|
|
345
|
+
segments << [:text, '<']
|
|
346
|
+
elsif scanner.scan('>')
|
|
347
|
+
segments << [:text, '>']
|
|
348
|
+
elsif scanner.scan('&')
|
|
349
|
+
segments << [:text, '&']
|
|
294
350
|
elsif scanner.scan(/`([^`]+)`/)
|
|
295
351
|
segments << [:code, scanner[1]]
|
|
296
352
|
elsif scanner.scan(/\*\*(.+?)\*\*/)
|
|
@@ -304,7 +360,21 @@ module Przn
|
|
|
304
360
|
end
|
|
305
361
|
end
|
|
306
362
|
|
|
307
|
-
segments
|
|
363
|
+
# Coalesce adjacent :text segments. The scanner has to bail to a
|
|
364
|
+
# single-character `.` when it sees `&` so the `<` / `>` /
|
|
365
|
+
# `&` entity matches can run on the next iteration, which
|
|
366
|
+
# leaves a bare `&` as its own segment and fragments the
|
|
367
|
+
# surrounding text. Merging them back together means one OSC 66
|
|
368
|
+
# multicell sequence per typeset run — important for h1 titles
|
|
369
|
+
# under a proportional font, where Echoes pads each run
|
|
370
|
+
# independently and stray segments become visible gaps.
|
|
371
|
+
segments.each_with_object([]) do |seg, acc|
|
|
372
|
+
if seg[0] == :text && acc.last && acc.last[0] == :text
|
|
373
|
+
acc.last[1] = acc.last[1] + seg[1]
|
|
374
|
+
else
|
|
375
|
+
acc << seg
|
|
376
|
+
end
|
|
377
|
+
end
|
|
308
378
|
end
|
|
309
379
|
end
|
|
310
380
|
end
|
|
@@ -24,7 +24,7 @@ module PrawnCJKLineWrap
|
|
|
24
24
|
"[#{CJK_CHARS}]",
|
|
25
25
|
"[#{ews}]+",
|
|
26
26
|
"#{ehy}+[^#{ebc}]*",
|
|
27
|
-
eshy.to_s
|
|
27
|
+
eshy.to_s
|
|
28
28
|
]
|
|
29
29
|
|
|
30
30
|
Regexp.new(patterns.join('|'))
|
|
@@ -32,13 +32,25 @@ module PrawnCJKLineWrap
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
module Przn
|
|
35
|
-
|
|
35
|
+
# Legacy PDF export via Prawn — renders the deck directly into a vector
|
|
36
|
+
# PDF without touching the terminal. Diverges from what's on screen for
|
|
37
|
+
# any feature the live renderer adds (OSC 66 sized text, OSC 7772
|
|
38
|
+
# backgrounds, proportional fonts) but works headlessly.
|
|
39
|
+
def self.export_pdf_prawn(file, output, theme: nil)
|
|
40
|
+
markdown = File.read(file)
|
|
41
|
+
presentation = Parser.parse(markdown)
|
|
42
|
+
base_dir = File.dirname(File.expand_path(file))
|
|
43
|
+
PrawnPdfExporter.new(presentation, base_dir: base_dir, theme: theme).export(output)
|
|
44
|
+
puts "Generated: #{output}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class PrawnPdfExporter
|
|
36
48
|
PAGE_WIDTH = 960
|
|
37
49
|
PAGE_HEIGHT = 540
|
|
38
50
|
DEFAULT_FONT_SIZE = 18
|
|
39
51
|
DEFAULT_SCALE_TO_PT = {
|
|
40
52
|
1 => 10, 2 => 18, 3 => 24, 4 => 32,
|
|
41
|
-
5 => 40, 6 => 48, 7 => 56
|
|
53
|
+
5 => 40, 6 => 48, 7 => 56
|
|
42
54
|
}.freeze
|
|
43
55
|
|
|
44
56
|
DEFAULT_SCALE = Renderer::DEFAULT_SCALE
|
|
@@ -48,7 +60,7 @@ module Przn
|
|
|
48
60
|
'magenta' => 'FF79C6', 'cyan' => '8BE9FD', 'white' => 'F8F8F2',
|
|
49
61
|
'bright_red' => 'FF6E6E', 'bright_green' => '69FF94', 'bright_yellow' => 'FFFFA5',
|
|
50
62
|
'bright_blue' => 'D6ACFF', 'bright_magenta' => 'FF92DF', 'bright_cyan' => 'A4FFFF',
|
|
51
|
-
'bright_white' => 'FFFFFF'
|
|
63
|
+
'bright_white' => 'FFFFFF'
|
|
52
64
|
}.freeze
|
|
53
65
|
|
|
54
66
|
def initialize(presentation, base_dir: '.', theme: nil)
|
|
@@ -56,7 +68,7 @@ module Przn
|
|
|
56
68
|
@base_dir = base_dir
|
|
57
69
|
@theme = theme || Theme.default
|
|
58
70
|
@bg_color = @theme.background && @theme.background[:color]
|
|
59
|
-
@fg_color = @theme.font[:color] ||
|
|
71
|
+
@fg_color = @theme.font[:color] || '000000'
|
|
60
72
|
@code_bg = @theme.colors[:code_bg]
|
|
61
73
|
@dim_color = @theme.colors[:dim]
|
|
62
74
|
@inline_code_color = @theme.colors[:inline_code]
|
|
@@ -73,7 +85,7 @@ module Przn
|
|
|
73
85
|
-> { Dir.glob('/usr/share/fonts/**/NotoSansJP-Regular.ttf').first },
|
|
74
86
|
-> { File.join(Dir.home, 'Library/Fonts/HackGen-Regular.ttf') },
|
|
75
87
|
-> { '/Library/Fonts/Arial Unicode.ttf' },
|
|
76
|
-
-> { '/System/Library/Fonts/Supplemental/Arial Unicode.ttf' }
|
|
88
|
+
-> { '/System/Library/Fonts/Supplemental/Arial Unicode.ttf' }
|
|
77
89
|
].freeze
|
|
78
90
|
|
|
79
91
|
FALLBACK_FONT_FAMILIES = %w[NotoSansCJK NotoSansJP HackGen].freeze
|
|
@@ -84,7 +96,7 @@ module Przn
|
|
|
84
96
|
|
|
85
97
|
pdf = Prawn::Document.new(
|
|
86
98
|
page_size: [PAGE_WIDTH, PAGE_HEIGHT],
|
|
87
|
-
margin: 0
|
|
99
|
+
margin: 0
|
|
88
100
|
)
|
|
89
101
|
|
|
90
102
|
register_fonts(pdf)
|
|
@@ -108,7 +120,7 @@ module Przn
|
|
|
108
120
|
'CJK' => {
|
|
109
121
|
normal: {file: font_path, font: 0},
|
|
110
122
|
bold: {file: font_path.sub('W3', 'W6').then { |p| File.exist?(p) ? p : font_path }, font: 0},
|
|
111
|
-
italic: {file: font_path, font: 0}
|
|
123
|
+
italic: {file: font_path, font: 0}
|
|
112
124
|
}
|
|
113
125
|
)
|
|
114
126
|
else
|
|
@@ -117,7 +129,7 @@ module Przn
|
|
|
117
129
|
'CJK' => {
|
|
118
130
|
normal: font_path,
|
|
119
131
|
bold: bold_path,
|
|
120
|
-
italic: font_path
|
|
132
|
+
italic: font_path
|
|
121
133
|
}
|
|
122
134
|
)
|
|
123
135
|
end
|
|
@@ -144,10 +156,10 @@ module Przn
|
|
|
144
156
|
pdf.font_families.update(family => {
|
|
145
157
|
normal: path,
|
|
146
158
|
bold: bold_path,
|
|
147
|
-
italic: path
|
|
159
|
+
italic: path
|
|
148
160
|
})
|
|
149
161
|
@registered_inline_fonts[family] = true
|
|
150
|
-
rescue
|
|
162
|
+
rescue StandardError
|
|
151
163
|
next
|
|
152
164
|
end
|
|
153
165
|
end
|
|
@@ -218,7 +230,7 @@ module Przn
|
|
|
218
230
|
'Emoji' => { normal: emoji_path, bold: emoji_path, italic: emoji_path }
|
|
219
231
|
)
|
|
220
232
|
pdf.fallback_fonts = ['Emoji']
|
|
221
|
-
rescue
|
|
233
|
+
rescue StandardError
|
|
222
234
|
nil
|
|
223
235
|
end
|
|
224
236
|
|
|
@@ -231,7 +243,7 @@ module Przn
|
|
|
231
243
|
# Only use fonts with glyf outlines (not SBIX/COLR bitmap-only fonts)
|
|
232
244
|
ttf = TTFunk::File.open(path)
|
|
233
245
|
return path if ttf.directory.tables.key?('glyf')
|
|
234
|
-
rescue
|
|
246
|
+
rescue StandardError
|
|
235
247
|
next
|
|
236
248
|
end
|
|
237
249
|
nil
|
|
@@ -609,7 +621,7 @@ module Przn
|
|
|
609
621
|
end
|
|
610
622
|
|
|
611
623
|
def bullet
|
|
612
|
-
@font_registered ? @theme.bullet[:text] :
|
|
624
|
+
@font_registered ? @theme.bullet[:text] : '-'
|
|
613
625
|
end
|
|
614
626
|
|
|
615
627
|
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Przn
|
|
4
|
+
# Drives the laptop-side view in extended-display mode. Reuses the existing
|
|
5
|
+
# Renderer to draw the current slide (notes still rendered dim-inline so the
|
|
6
|
+
# presenter sees them in context), then overlays a three-line strip at the
|
|
7
|
+
# bottom of the terminal: speaker notes summary, next-slide preview, and a
|
|
8
|
+
# footer with the slide counter + elapsed time.
|
|
9
|
+
class PresenterRenderer < Renderer
|
|
10
|
+
def initialize(terminal, presentation:, base_dir: '.', theme: nil)
|
|
11
|
+
super(terminal, base_dir: base_dir, theme: theme, mode: :presenter)
|
|
12
|
+
@presentation = presentation
|
|
13
|
+
@started_at = Time.now
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def render(slide, current:, total:, started_at: nil)
|
|
17
|
+
super(slide, current: current, total: total, started_at: started_at)
|
|
18
|
+
@mutex.synchronize { draw_presenter_strip(current, total) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def draw_presenter_strip(current, total)
|
|
24
|
+
w = @terminal.width
|
|
25
|
+
h = @terminal.height
|
|
26
|
+
slide = @presentation.slides[current]
|
|
27
|
+
notes_text = slide.notes.join(' / ')
|
|
28
|
+
next_title = current + 1 < total ? preview_title(@presentation.slides[current + 1]) : nil
|
|
29
|
+
elapsed = format_elapsed(Time.now - @started_at)
|
|
30
|
+
footer = "Slide #{current + 1} / #{total} #{elapsed}"
|
|
31
|
+
|
|
32
|
+
# When the theme opts into the rabbit/turtle indicator, the parent
|
|
33
|
+
# renderer has already drawn it on rows h-1 and h. Lift the strip up
|
|
34
|
+
# so it doesn't clobber the runner bar; the indicator itself replaces
|
|
35
|
+
# the strip's own footer line (slide #, elapsed time are visible there
|
|
36
|
+
# anyway via rabbit position and turtle position).
|
|
37
|
+
rabbit_mode = !@theme.rabbit.nil?
|
|
38
|
+
notes_row = rabbit_mode ? h - 3 : h - 2
|
|
39
|
+
next_row = rabbit_mode ? h - 2 : h - 1
|
|
40
|
+
|
|
41
|
+
@terminal.move_to(notes_row, 1)
|
|
42
|
+
@terminal.write "#{ANSI[:dim]}Notes: #{truncate_to_width(notes_text, [w - 8, 1].max)}#{ANSI[:reset]}"
|
|
43
|
+
@terminal.move_to(next_row, 1)
|
|
44
|
+
@terminal.write "#{ANSI[:dim]}Next: #{truncate_to_width(next_title || '—', [w - 8, 1].max)}#{ANSI[:reset]}"
|
|
45
|
+
|
|
46
|
+
unless rabbit_mode
|
|
47
|
+
@terminal.move_to(h, 1)
|
|
48
|
+
@terminal.write "#{ANSI[:dim]}#{truncate_to_width(footer, w)}#{ANSI[:reset]}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@terminal.flush
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def preview_title(slide)
|
|
55
|
+
return nil unless slide
|
|
56
|
+
slide.blocks.each do |b|
|
|
57
|
+
case b[:type]
|
|
58
|
+
when :heading, :paragraph then return strip_markup(b[:content].to_s)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def format_elapsed(seconds)
|
|
65
|
+
h = (seconds / 3600).to_i
|
|
66
|
+
m = ((seconds % 3600) / 60).to_i
|
|
67
|
+
s = (seconds % 60).to_i
|
|
68
|
+
format('%02d:%02d:%02d', h, m, s)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|