slideck 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slideck
4
+ # Responsible for rendering slides
5
+ #
6
+ # @api private
7
+ class Renderer
8
+ # The terminal cursor
9
+ #
10
+ # @example
11
+ # renderer.cursor
12
+ #
13
+ # @return [TTY::Cursor]
14
+ #
15
+ # @api public
16
+ attr_reader :cursor
17
+
18
+ # Create a Renderer instance
19
+ #
20
+ # @param [Slideck::Converter] converter
21
+ # the markdown to terminal output converter
22
+ # @param [Strings::ANSI] ansi
23
+ # the ansi codes handler
24
+ # @param [TTY::Cursor] cursor
25
+ # the cursor navigation
26
+ # @param [Integer] width
27
+ # the screen width
28
+ # @param [Integer] height
29
+ # the screen height
30
+ #
31
+ # @api public
32
+ def initialize(converter, ansi, cursor, width: nil, height: nil)
33
+ @converter = converter
34
+ @ansi = ansi
35
+ @cursor = cursor
36
+ @width = width
37
+ @height = height
38
+
39
+ freeze
40
+ end
41
+
42
+ # Create a Renderer with a new screen size
43
+ #
44
+ # @example
45
+ # renderer.resize(200, 50)
46
+ #
47
+ # @param [Integer] width
48
+ # the screen width
49
+ # @param [Integer] height
50
+ # the screen height
51
+ #
52
+ # @return [Slideck::Renderer]
53
+ #
54
+ # @api public
55
+ def resize(width, height)
56
+ self.class.new(@converter, @ansi, @cursor, width: width, height: height)
57
+ end
58
+
59
+ # Render a slide
60
+ #
61
+ # @example
62
+ # renderer.render(metadata, slide, 1, 5)
63
+ #
64
+ # @param [Slideck::Metadata] metadata
65
+ # the global metadata
66
+ # @param [Hash{Symbol => Hash, String}, nil] slide
67
+ # the current slide to render
68
+ # @param [Integer] current_num
69
+ # the current slide number
70
+ # @param [Integer] num_of_slides
71
+ # the number of slides
72
+ #
73
+ # @return [String]
74
+ #
75
+ # @api public
76
+ def render(metadata, slide, current_num, num_of_slides)
77
+ slide_metadata = slide && slide[:metadata]
78
+ [].tap do |out|
79
+ out << render_content(metadata, slide) if slide
80
+ out << render_footer(metadata, slide_metadata)
81
+ out << render_pager(metadata, slide_metadata,
82
+ current_num, num_of_slides)
83
+ end.join
84
+ end
85
+
86
+ # Clear terminal screen
87
+ #
88
+ # @example
89
+ # renderer.clear
90
+ #
91
+ # @return [String]
92
+ #
93
+ # @api public
94
+ def clear
95
+ cursor.clear_screen + cursor.move_to(0, 0)
96
+ end
97
+
98
+ private
99
+
100
+ # Render slide content
101
+ #
102
+ # @param [Slideck::Metadata] metadata
103
+ # the global metadata
104
+ # @param [Hash{Symbol => Hash, String}] slide
105
+ # the slide to render
106
+ #
107
+ # @return [String]
108
+ #
109
+ # @api private
110
+ def render_content(metadata, slide)
111
+ alignment, margin, symbols, theme =
112
+ *select_metadata(metadata, slide[:metadata], :align, :margin,
113
+ :symbols, :theme)
114
+ converted = convert_markdown(slide[:content], margin, symbols, theme)
115
+
116
+ render_section(converted.lines, alignment, margin)
117
+ end
118
+
119
+ # Render footer
120
+ #
121
+ # @param [Slideck::Metadata] metadata
122
+ # the global metadata
123
+ # @param [Slideck::Metadata] slide_metadata
124
+ # the slide metadata
125
+ #
126
+ # @return [String]
127
+ #
128
+ # @api private
129
+ def render_footer(metadata, slide_metadata)
130
+ footer_metadata = pick_metadata(metadata, slide_metadata, :footer)
131
+ return if (text = footer_metadata[:text]).empty?
132
+
133
+ alignment = footer_metadata[:align] || metadata.footer[:align]
134
+ margin, symbols, theme =
135
+ *select_metadata(metadata, slide_metadata, :margin, :symbols, :theme)
136
+ converted = convert_markdown(text, margin, symbols, theme).chomp
137
+
138
+ render_section(converted.lines, alignment, margin)
139
+ end
140
+
141
+ # Render pager
142
+ #
143
+ # @param [Slideck::Metadata] metadata
144
+ # the global metadata
145
+ # @param [Slideck::Metadata] slide_metadata
146
+ # the slide metadata
147
+ # @param [Integer] current_num
148
+ # the current slide number
149
+ # @param [Integer] num_of_slides
150
+ # the number of slides
151
+ #
152
+ # @return [String]
153
+ #
154
+ # @api private
155
+ def render_pager(metadata, slide_metadata, current_num, num_of_slides)
156
+ pager_metadata = pick_metadata(metadata, slide_metadata, :pager)
157
+ return if (text = pager_metadata[:text]).empty?
158
+
159
+ alignment = pager_metadata[:align] || metadata.pager[:align]
160
+ margin, symbols, theme =
161
+ *select_metadata(metadata, slide_metadata, :margin, :symbols, :theme)
162
+ formatted_text = format(text, page: current_num, total: num_of_slides)
163
+ converted = convert_markdown(formatted_text, margin, symbols, theme).chomp
164
+
165
+ render_section(converted.lines, alignment, margin)
166
+ end
167
+
168
+ # Select configuration(s) by name(s) from metadata
169
+ #
170
+ # @param [Slideck::Metadata] metadata
171
+ # the global metadata
172
+ # @param [Slideck::Metadata] slide_metadata
173
+ # the slide metadata
174
+ # @param [Array<Symbol>] names
175
+ # the configuration names
176
+ #
177
+ # @return [Array<Object>]
178
+ #
179
+ # @api private
180
+ def select_metadata(metadata, slide_metadata, *names)
181
+ names.each_with_object([]) do |name, selected|
182
+ selected << pick_metadata(metadata, slide_metadata, name)
183
+ end
184
+ end
185
+
186
+ # Pick configuration by name from metadata
187
+ #
188
+ # @param [Slideck::Metadata] metadata
189
+ # the global metadata
190
+ # @param [Slideck::Metadata] slide_metadata
191
+ # the slide metadata
192
+ # @param [Symbol] name
193
+ # the configuration name
194
+ #
195
+ # @return [Hash, Slideck::Alignment, Slideck::Margin, String, Symbol]
196
+ #
197
+ # @api private
198
+ def pick_metadata(metadata, slide_metadata, name)
199
+ slide_metadata_item = slide_metadata && slide_metadata.send(name)
200
+ slide_metadata_item || metadata.send(name)
201
+ end
202
+
203
+ # Render section with aligned lines
204
+ #
205
+ # @param [Array<String>] lines
206
+ # the lines to align
207
+ # @param [Slideck::Alignment] alignment
208
+ # the section alignment
209
+ # @param [Slideck::Margin] margin
210
+ # the slide margin
211
+ #
212
+ # @return [String]
213
+ #
214
+ # @api private
215
+ def render_section(lines, alignment, margin)
216
+ max_line = max_line_length(lines)
217
+ left = find_left_column(alignment.horizontal, margin, max_line)
218
+ top = find_top_row(alignment.vertical, margin, lines.size)
219
+
220
+ lines.map.with_index do |line, i|
221
+ cursor.move_to(left, top + i) + line
222
+ end.join
223
+ end
224
+
225
+ # Find a left column
226
+ #
227
+ # @param [String] alignment
228
+ # the horizontal alignment
229
+ # @param [Slideck::Margin] margin
230
+ # the slide margin
231
+ # @param [Integer] content_length
232
+ # the maximum content length
233
+ #
234
+ # @return [Integer]
235
+ #
236
+ # @api private
237
+ def find_left_column(alignment, margin, content_length)
238
+ case alignment
239
+ when "left"
240
+ margin.left
241
+ when "center"
242
+ margin.left + ((slide_width(margin) - content_length) / 2)
243
+ when "right"
244
+ margin.left + (slide_width(margin) - content_length)
245
+ end
246
+ end
247
+
248
+ # Find a top row
249
+ #
250
+ # @param [String] alignment
251
+ # the vertical alignment
252
+ # @param [Slideck::Margin] margin
253
+ # the slide margin
254
+ # @param [Integer] num_of_lines
255
+ # the number of content lines
256
+ #
257
+ # @return [Integer]
258
+ #
259
+ # @api private
260
+ def find_top_row(alignment, margin, num_of_lines)
261
+ case alignment
262
+ when "top"
263
+ margin.top
264
+ when "center"
265
+ margin.top + ((slide_height(margin) - num_of_lines) / 2)
266
+ when "bottom"
267
+ margin.top + (slide_height(margin) - num_of_lines)
268
+ end
269
+ end
270
+
271
+ # Convert markdown content to terminal output
272
+ #
273
+ # @param [String] content
274
+ # the content to convert to terminal output
275
+ # @param [Slideck::Margin] margin
276
+ # the slide margin
277
+ # @param [Hash, String, Symbol] symbols
278
+ # the converted content symbols
279
+ # @param [Hash{Symbol => Array, String, Symbol}] theme
280
+ # the converted content theme
281
+ #
282
+ # @return [String]
283
+ #
284
+ # @api private
285
+ def convert_markdown(content, margin, symbols, theme)
286
+ @converter.convert(
287
+ content, symbols: symbols, theme: theme, width: slide_width(margin))
288
+ end
289
+
290
+ # Find maximum line length
291
+ #
292
+ # @param [Array<String>] lines
293
+ # the lines to search through
294
+ #
295
+ # @return [Integer]
296
+ #
297
+ # @api private
298
+ def max_line_length(lines)
299
+ lines.map { |line| @ansi.sanitize(line).size }.max
300
+ end
301
+
302
+ # Calculate slide height
303
+ #
304
+ # @param [Slideck::Margin] margin
305
+ # the slide margin
306
+ #
307
+ # @return [Integer]
308
+ #
309
+ # @api private
310
+ def slide_height(margin)
311
+ @height - margin.top - margin.bottom
312
+ end
313
+
314
+ # Calculate slide width
315
+ #
316
+ # @param [Slideck::Margin] margin
317
+ # the slide margin
318
+ #
319
+ # @return [Integer]
320
+ #
321
+ # @api private
322
+ def slide_width(margin)
323
+ @width - margin.left - margin.right
324
+ end
325
+ end # Renderer
326
+ end # Slideck
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "listen"
4
+ require "strings-ansi"
5
+ require "strscan"
6
+ require "tty-cursor"
7
+ require "tty-markdown"
8
+ require "tty-reader"
9
+ require "yaml"
10
+
11
+ require_relative "alignment"
12
+ require_relative "converter"
13
+ require_relative "loader"
14
+ require_relative "margin"
15
+ require_relative "metadata"
16
+ require_relative "metadata_converter"
17
+ require_relative "metadata_defaults"
18
+ require_relative "metadata_parser"
19
+ require_relative "metadata_wrapper"
20
+ require_relative "parser"
21
+ require_relative "presenter"
22
+ require_relative "renderer"
23
+ require_relative "tracker"
24
+ require_relative "transformer"
25
+
26
+ module Slideck
27
+ # Parse and display slides
28
+ #
29
+ # @api private
30
+ class Runner
31
+ # Create a Runner instance
32
+ #
33
+ # @example
34
+ # Slideck::Runner.new(TTY::Screen, $stdin, $stdout, {})
35
+ #
36
+ # @param [TTY::Screen] screen
37
+ # the terminal screen size
38
+ # @param [IO] input
39
+ # the input stream
40
+ # @param [IO] output
41
+ # the output stream
42
+ # @param [Hash] env
43
+ # the environment variables
44
+ #
45
+ # @api public
46
+ def initialize(screen, input, output, env)
47
+ @screen = screen
48
+ @input = input
49
+ @output = output
50
+ @env = env
51
+ end
52
+
53
+ # Run the slides in a terminal
54
+ #
55
+ # @example
56
+ # runner.run("slides.md", color: :always, watch: true)
57
+ #
58
+ # @param [String] filename
59
+ # the filename with slides
60
+ # @param [String, Symbol] color
61
+ # the color display out of always, auto or never
62
+ # @param [Boolean] watch
63
+ # whether to watch for changes in a filename
64
+ #
65
+ # @return [void]
66
+ #
67
+ # @api public
68
+ def run(filename, color: nil, watch: nil)
69
+ transformer = build_transformer
70
+ presenter = build_presenter(color) { transformer.read(filename) }
71
+
72
+ if watch
73
+ listener = build_listener(filename) { presenter.reload.render }
74
+ listener.start
75
+ end
76
+
77
+ presenter.start
78
+ ensure
79
+ listener && listener.stop
80
+ end
81
+
82
+ private
83
+
84
+ # Build transformer
85
+ #
86
+ # @return [Slideck::Transformer]
87
+ #
88
+ # @api private
89
+ def build_transformer
90
+ loader = Loader.new(::File)
91
+ Transformer.new(loader, build_parser, build_metadata_wrapper)
92
+ end
93
+
94
+ # Build parser
95
+ #
96
+ # @return [Slideck::Parser]
97
+ #
98
+ # @api private
99
+ def build_parser
100
+ metadata_parser = MetadataParser.new(
101
+ ::YAML, permitted_classes: [Symbol], symbolize_names: true)
102
+ Parser.new(::StringScanner, metadata_parser)
103
+ end
104
+
105
+ # Build metadata wrapper
106
+ #
107
+ # @return [Slideck::MetadataWrapper]
108
+ #
109
+ # @api private
110
+ def build_metadata_wrapper
111
+ metadata_converter = MetadataConverter.new(Alignment, Margin)
112
+ metadata_defaults = MetadataDefaults.new(Alignment, Margin)
113
+ MetadataWrapper.new(Metadata, metadata_converter, metadata_defaults)
114
+ end
115
+
116
+ # Build presenter
117
+ #
118
+ # @param [String, Symbol] color
119
+ # the color display out of always, auto or never
120
+ # @param [Proc] reloader
121
+ # the metadata and slides reloader
122
+ #
123
+ # @return [Slideck::Presenter]
124
+ #
125
+ # @api private
126
+ def build_presenter(color, &reloader)
127
+ reader = TTY::Reader.new(input: @input, output: @output, env: @env,
128
+ interrupt: :exit)
129
+ converter = Converter.new(TTY::Markdown, color: color)
130
+ renderer = Renderer.new(converter, Strings::ANSI, TTY::Cursor,
131
+ width: @screen.width, height: @screen.height)
132
+ tracker = Tracker.for(0)
133
+ Presenter.new(reader, renderer, tracker, @screen, @output, &reloader)
134
+ end
135
+
136
+ # Build a listener for changes in a filename
137
+ #
138
+ # @param [String] filename
139
+ # the filename with slides
140
+ #
141
+ # @return [Listen::Listener]
142
+ #
143
+ # @api private
144
+ def build_listener(filename)
145
+ watched_dir = File.expand_path(File.dirname(filename))
146
+ watched_file = File.expand_path(filename)
147
+ Listen.to(watched_dir) do |changed_files, _, _|
148
+ yield if changed_files.include?(watched_file)
149
+ end
150
+ end
151
+ end # Runner
152
+ end # Slideck
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slideck
4
+ # Responsible for tracking current slide number
5
+ #
6
+ # @api private
7
+ class Tracker
8
+ # Create a Tracker instance with the current slide set to zero
9
+ #
10
+ # @example
11
+ # Slideck::Tracker.for(11)
12
+ #
13
+ # @param [Integer] total
14
+ # the total number of slides
15
+ #
16
+ # @return [Slideck::Tracker]
17
+ #
18
+ # @api public
19
+ def self.for(total)
20
+ new(0, total)
21
+ end
22
+
23
+ # The current slide number
24
+ #
25
+ # @example
26
+ # tracker.current
27
+ #
28
+ # @return [Integer]
29
+ #
30
+ # @api public
31
+ attr_reader :current
32
+
33
+ # The total number of slides
34
+ #
35
+ # @example
36
+ # tracker.total
37
+ #
38
+ # @return [Integer]
39
+ #
40
+ # @api public
41
+ attr_reader :total
42
+
43
+ # Create a Tracker instance
44
+ #
45
+ # @example
46
+ # Slideck::Tracker.new(0, 11)
47
+ #
48
+ # @param [Integer] current
49
+ # the current slide number
50
+ # @param [Integer] total
51
+ # the total number of slides
52
+ #
53
+ # @api public
54
+ def initialize(current, total)
55
+ @current = current
56
+ @total = total
57
+
58
+ freeze
59
+ end
60
+
61
+ # Move to the next slide
62
+ #
63
+ # @example
64
+ # tracker = tracker.next
65
+ #
66
+ # @return [Slideck::Tracker]
67
+ #
68
+ # @api public
69
+ def next
70
+ return self if current >= total - 1
71
+
72
+ self.class.new(current + 1, total)
73
+ end
74
+
75
+ # Move to the previous slide
76
+ #
77
+ # @example
78
+ # tracker = tracker.previous
79
+ #
80
+ # @return [Slideck::Tracker]
81
+ #
82
+ # @api public
83
+ def previous
84
+ return self if current.zero?
85
+
86
+ self.class.new(current - 1, total)
87
+ end
88
+
89
+ # Move to the first slide
90
+ #
91
+ # @example
92
+ # tracker = tracker.first
93
+ #
94
+ # @return [Slideck::Tracker]
95
+ #
96
+ # @api public
97
+ def first
98
+ self.class.new(0, total)
99
+ end
100
+
101
+ # Move to the last slide
102
+ #
103
+ # @example
104
+ # tracker = tracker.last
105
+ #
106
+ # @return [Slideck::Tracker]
107
+ #
108
+ # @api public
109
+ def last
110
+ self.class.new(total - 1, total)
111
+ end
112
+
113
+ # Go to a specific slide number
114
+ #
115
+ # @example
116
+ # tracker = tracker.go_to(5)
117
+ #
118
+ # @param [Integer] slide_no
119
+ # the slide number
120
+ #
121
+ # @return [Slideck::Tracker]
122
+ #
123
+ # @api public
124
+ def go_to(slide_no)
125
+ return self if slide_no < 0 || total - 1 < slide_no
126
+
127
+ self.class.new(slide_no, total)
128
+ end
129
+
130
+ # Resize to the new total
131
+ #
132
+ # @example
133
+ # tracker = tracker.resize(10)
134
+ #
135
+ # @param [Integer] new_total
136
+ # the new total
137
+ #
138
+ # @return [Slideck::Tracker]
139
+ #
140
+ # @api public
141
+ def resize(new_total)
142
+ return self if new_total < 0 || total == new_total
143
+
144
+ self.class.new(reset_current(new_total), new_total)
145
+ end
146
+
147
+ private
148
+
149
+ # Reset current
150
+ #
151
+ # @param [Integer] new_total
152
+ # the new total
153
+ #
154
+ # @return [Integer]
155
+ #
156
+ # @api private
157
+ def reset_current(new_total)
158
+ if current < new_total
159
+ current
160
+ elsif new_total.zero?
161
+ 0
162
+ else
163
+ new_total - 1
164
+ end
165
+ end
166
+ end # Tracker
167
+ end # Slideck
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slideck
4
+ # Responsible for transforming file content into metadata and slides
5
+ #
6
+ # @api private
7
+ class Transformer
8
+ # Create a Transformer instance
9
+ #
10
+ # @example
11
+ # Transformer.new(loader, parser, metadata_wrapper)
12
+ #
13
+ # @param [Slideck::Loader] loader
14
+ # the file loader
15
+ # @param [Slideck::Parser] parser
16
+ # the file content parser
17
+ # @param [Slideck::MetadataWrapper] metadata_wrapper
18
+ # the metadata wrapper
19
+ #
20
+ # @api public
21
+ def initialize(loader, parser, metadata_wrapper)
22
+ @loader = loader
23
+ @parser = parser
24
+ @metadata_wrapper = metadata_wrapper
25
+ end
26
+
27
+ # Read metadata and slides from a file
28
+ #
29
+ # @example
30
+ # transformer.read("slides.md")
31
+ #
32
+ # @param [String] filename
33
+ # the filename to read metadata and slides from
34
+ #
35
+ # @return [Array<Slideck::Metadata, Array<Hash>>]
36
+ #
37
+ # @api public
38
+ def read(filename)
39
+ @metadata_wrapper.wrap(@parser.parse(@loader.load(filename)))
40
+ end
41
+ end # Transformer
42
+ end # Slideck
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slideck
4
+ VERSION = "0.1.0"
5
+ end # Slideck
data/lib/slideck.rb ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-screen"
4
+
5
+ require_relative "slideck/cli"
6
+ require_relative "slideck/errors"
7
+ require_relative "slideck/runner"
8
+ require_relative "slideck/version"
9
+
10
+ # Present Markdown-powered slide decks in a terminal
11
+ module Slideck
12
+ # Run slides deck
13
+ #
14
+ # @example
15
+ # Slideck.run
16
+ #
17
+ # @param [Array<String>] cmd_args
18
+ # the command arguments
19
+ # @param [Hash{String => String}] env
20
+ # the environment variables
21
+ #
22
+ # @return [void]
23
+ #
24
+ # @api public
25
+ def self.run(cmd_args = ARGV, env = ENV)
26
+ runner = Runner.new(TTY::Screen, $stdin, $stdout, env)
27
+ cli = CLI.new(runner, $stdout, $stderr)
28
+ cli.start(cmd_args, env)
29
+ end
30
+ end # Slideck