slideck 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,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