asciinema_win 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,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ # AsciinemaWin - Native Windows Terminal Recorder in Pure Ruby
4
+ #
5
+ # A zero-dependency terminal recording and playback system for Windows.
6
+ # Uses Ruby's built-in Fiddle for Win32 Console API access and integrates
7
+ # Rich-Ruby for terminal rendering. Compatible with asciinema's asciicast v2 format.
8
+ #
9
+ # @example Record a terminal session
10
+ # AsciinemaWin.record("session.cast", title: "My Recording")
11
+ #
12
+ # @example Play back a recording
13
+ # AsciinemaWin.play("session.cast", speed: 1.5)
14
+ #
15
+ # @example Get recording info
16
+ # info = AsciinemaWin.info("session.cast")
17
+ # puts "Duration: #{info[:duration]}s"
18
+
19
+ module AsciinemaWin
20
+ # Base error class for all AsciinemaWin errors
21
+ class Error < StandardError; end
22
+
23
+ # Raised when recording fails
24
+ class RecordingError < Error; end
25
+
26
+ # Raised when playback fails
27
+ class PlaybackError < Error; end
28
+
29
+ # Raised when file format is invalid
30
+ class FormatError < Error; end
31
+
32
+ # Raised when platform is not supported
33
+ class PlatformError < Error; end
34
+
35
+ # Raised when export fails
36
+ class ExportError < Error; end
37
+ end
38
+
39
+ require_relative "rich"
40
+ require_relative "asciinema_win/version"
41
+ require_relative "asciinema_win/screen_buffer"
42
+ require_relative "asciinema_win/asciicast"
43
+ require_relative "asciinema_win/recorder"
44
+ require_relative "asciinema_win/player"
45
+ require_relative "asciinema_win/themes"
46
+ require_relative "asciinema_win/ansi_parser"
47
+ require_relative "asciinema_win/output_organizer"
48
+ require_relative "asciinema_win/export"
49
+ require_relative "asciinema_win/cli"
50
+
51
+ module AsciinemaWin
52
+
53
+ class << self
54
+ # Record a terminal session to a file
55
+ #
56
+ # @param output_path [String] Path to save the recording
57
+ # @param title [String, nil] Recording title
58
+ # @param command [String, nil] Command to record (runs in subprocess)
59
+ # @param idle_time_limit [Float] Maximum idle time between events
60
+ # @param env_vars [Array<String>] Environment variables to capture
61
+ # @yield [Recorder] Optional block for manual recording control
62
+ # @return [void]
63
+ # @raise [RecordingError] If recording fails
64
+ # @raise [PlatformError] If not running on Windows
65
+ #
66
+ # @example Record interactively
67
+ # AsciinemaWin.record("session.cast", title: "Demo") do |rec|
68
+ # # Recording happens until block exits or user presses Ctrl+D
69
+ # end
70
+ #
71
+ # @example Record a command
72
+ # AsciinemaWin.record("session.cast", command: "dir /s")
73
+ def record(output_path, title: nil, command: nil, idle_time_limit: 2.0, env_vars: %w[SHELL TERM], &block)
74
+ ensure_windows!
75
+
76
+ recorder = Recorder.new(
77
+ title: title,
78
+ command: command,
79
+ idle_time_limit: idle_time_limit,
80
+ env_vars: env_vars
81
+ )
82
+
83
+ recorder.record(output_path, &block)
84
+ end
85
+
86
+ # Play back a recording from a file
87
+ #
88
+ # @param input_path [String] Path to the recording file
89
+ # @param speed [Float] Playback speed multiplier (1.0 = normal)
90
+ # @param idle_time_limit [Float, nil] Cap idle time between frames
91
+ # @param pause_on_markers [Boolean] Pause playback at markers
92
+ # @return [void]
93
+ # @raise [PlaybackError] If playback fails
94
+ # @raise [FormatError] If file format is invalid
95
+ #
96
+ # @example Normal playback
97
+ # AsciinemaWin.play("session.cast")
98
+ #
99
+ # @example Fast playback
100
+ # AsciinemaWin.play("session.cast", speed: 2.0)
101
+ def play(input_path, speed: 1.0, idle_time_limit: nil, pause_on_markers: false)
102
+ player = Player.new(
103
+ speed: speed,
104
+ idle_time_limit: idle_time_limit,
105
+ pause_on_markers: pause_on_markers
106
+ )
107
+
108
+ player.play(input_path)
109
+ end
110
+
111
+ # Output recording to stdout without timing (for piping)
112
+ #
113
+ # @param input_path [String] Path to the recording file
114
+ # @return [void]
115
+ # @raise [FormatError] If file format is invalid
116
+ def cat(input_path)
117
+ player = Player.new(speed: Float::INFINITY)
118
+ player.play(input_path)
119
+ end
120
+
121
+ # Get metadata about a recording
122
+ #
123
+ # @param input_path [String] Path to the recording file
124
+ # @return [Hash] Recording metadata including width, height, duration, title
125
+ # @raise [FormatError] If file format is invalid
126
+ #
127
+ # @example
128
+ # info = AsciinemaWin.info("session.cast")
129
+ # puts "Size: #{info[:width]}x#{info[:height]}"
130
+ # puts "Duration: #{info[:duration]}s"
131
+ def info(input_path)
132
+ Asciicast::Reader.info(input_path)
133
+ end
134
+
135
+ # Run the CLI with the given arguments
136
+ #
137
+ # @param args [Array<String>] Command-line arguments
138
+ # @return [Integer] Exit code
139
+ def run(args = ARGV)
140
+ CLI.run(args)
141
+ end
142
+
143
+ private
144
+
145
+ # @raise [PlatformError] If not running on Windows
146
+ # @return [void]
147
+ def ensure_windows!
148
+ return if Gem.win_platform?
149
+
150
+ raise PlatformError, "AsciinemaWin requires Windows. Use the standard asciinema on other platforms."
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "color_triplet"
4
+
5
+ module Rich
6
+ # Color palette definitions for terminal color systems.
7
+ # Provides lookup tables for standard 16-color, 256-color (8-bit),
8
+ # and Windows console color palettes.
9
+ module Palettes
10
+ # Standard 16-color ANSI palette (colors 0-15)
11
+ # These are the typical default colors, but terminals may customize them
12
+ STANDARD_PALETTE = [
13
+ ColorTriplet.new(0, 0, 0), # 0: Black
14
+ ColorTriplet.new(128, 0, 0), # 1: Red
15
+ ColorTriplet.new(0, 128, 0), # 2: Green
16
+ ColorTriplet.new(128, 128, 0), # 3: Yellow
17
+ ColorTriplet.new(0, 0, 128), # 4: Blue
18
+ ColorTriplet.new(128, 0, 128), # 5: Magenta
19
+ ColorTriplet.new(0, 128, 128), # 6: Cyan
20
+ ColorTriplet.new(192, 192, 192), # 7: White
21
+ ColorTriplet.new(128, 128, 128), # 8: Bright Black (Gray)
22
+ ColorTriplet.new(255, 0, 0), # 9: Bright Red
23
+ ColorTriplet.new(0, 255, 0), # 10: Bright Green
24
+ ColorTriplet.new(255, 255, 0), # 11: Bright Yellow
25
+ ColorTriplet.new(0, 0, 255), # 12: Bright Blue
26
+ ColorTriplet.new(255, 0, 255), # 13: Bright Magenta
27
+ ColorTriplet.new(0, 255, 255), # 14: Bright Cyan
28
+ ColorTriplet.new(255, 255, 255) # 15: Bright White
29
+ ].freeze
30
+
31
+ # Windows Console palette (slightly different from ANSI standard)
32
+ WINDOWS_PALETTE = [
33
+ ColorTriplet.new(12, 12, 12), # 0: Black
34
+ ColorTriplet.new(197, 15, 31), # 1: Red
35
+ ColorTriplet.new(19, 161, 14), # 2: Green
36
+ ColorTriplet.new(193, 156, 0), # 3: Yellow
37
+ ColorTriplet.new(0, 55, 218), # 4: Blue
38
+ ColorTriplet.new(136, 23, 152), # 5: Magenta
39
+ ColorTriplet.new(58, 150, 221), # 6: Cyan
40
+ ColorTriplet.new(204, 204, 204), # 7: White
41
+ ColorTriplet.new(118, 118, 118), # 8: Bright Black (Gray)
42
+ ColorTriplet.new(231, 72, 86), # 9: Bright Red
43
+ ColorTriplet.new(22, 198, 12), # 10: Bright Green
44
+ ColorTriplet.new(249, 241, 165), # 11: Bright Yellow
45
+ ColorTriplet.new(59, 120, 255), # 12: Bright Blue
46
+ ColorTriplet.new(180, 0, 158), # 13: Bright Magenta
47
+ ColorTriplet.new(97, 214, 214), # 14: Bright Cyan
48
+ ColorTriplet.new(242, 242, 242) # 15: Bright White
49
+ ].freeze
50
+
51
+ # Generate the 256-color (8-bit) palette
52
+ # Colors 0-15: Standard colors
53
+ # Colors 16-231: 6x6x6 color cube
54
+ # Colors 232-255: Grayscale ramp
55
+ EIGHT_BIT_PALETTE = begin
56
+ palette = []
57
+
58
+ # Colors 0-15: Standard palette
59
+ STANDARD_PALETTE.each { |color| palette << color }
60
+
61
+ # Colors 16-231: 6x6x6 color cube
62
+ # Each component can be 0, 95, 135, 175, 215, or 255
63
+ cube_values = [0, 95, 135, 175, 215, 255]
64
+ (0...6).each do |r|
65
+ (0...6).each do |g|
66
+ (0...6).each do |b|
67
+ palette << ColorTriplet.new(cube_values[r], cube_values[g], cube_values[b])
68
+ end
69
+ end
70
+ end
71
+
72
+ # Colors 232-255: Grayscale ramp (24 shades, excluding black and white)
73
+ (0...24).each do |i|
74
+ gray = 8 + i * 10
75
+ palette << ColorTriplet.new(gray, gray, gray)
76
+ end
77
+
78
+ palette.freeze
79
+ end
80
+
81
+ class << self
82
+ # Find the closest color in a palette
83
+ # @param triplet [ColorTriplet] Color to match
84
+ # @param palette [Array<ColorTriplet>] Palette to search
85
+ # @param start_index [Integer] Starting index in palette
86
+ # @param end_index [Integer] Ending index in palette (exclusive)
87
+ # @return [Integer] Index of closest matching color
88
+ def match_color(triplet, palette: EIGHT_BIT_PALETTE, start_index: 0, end_index: nil)
89
+ end_index ||= palette.length
90
+
91
+ best_index = start_index
92
+ best_distance = Float::INFINITY
93
+
94
+ (start_index...end_index).each do |i|
95
+ distance = triplet.weighted_distance(palette[i])
96
+ if distance < best_distance
97
+ best_distance = distance
98
+ best_index = i
99
+ end
100
+ end
101
+
102
+ best_index
103
+ end
104
+
105
+ # Match to standard 16-color palette
106
+ # @param triplet [ColorTriplet] Color to match
107
+ # @return [Integer] Standard color index (0-15)
108
+ def match_standard(triplet)
109
+ match_color(triplet, palette: STANDARD_PALETTE, start_index: 0, end_index: 16)
110
+ end
111
+
112
+ # Match to 8-bit palette (256 colors)
113
+ # @param triplet [ColorTriplet] Color to match
114
+ # @return [Integer] 8-bit color index (0-255)
115
+ def match_eight_bit(triplet)
116
+ match_color(triplet, palette: EIGHT_BIT_PALETTE, start_index: 0, end_index: 256)
117
+ end
118
+
119
+ # Match to Windows console palette
120
+ # @param triplet [ColorTriplet] Color to match
121
+ # @return [Integer] Windows color index (0-15)
122
+ def match_windows(triplet)
123
+ match_color(triplet, palette: WINDOWS_PALETTE, start_index: 0, end_index: 16)
124
+ end
125
+
126
+ # Get a color from the 8-bit palette
127
+ # @param index [Integer] Color index (0-255)
128
+ # @return [ColorTriplet]
129
+ def get_eight_bit(index)
130
+ EIGHT_BIT_PALETTE[index.clamp(0, 255)]
131
+ end
132
+
133
+ # Get a color from the standard palette
134
+ # @param index [Integer] Color index (0-15)
135
+ # @return [ColorTriplet]
136
+ def get_standard(index)
137
+ STANDARD_PALETTE[index.clamp(0, 15)]
138
+ end
139
+
140
+ # Get a color from the Windows palette
141
+ # @param index [Integer] Color index (0-15)
142
+ # @return [ColorTriplet]
143
+ def get_windows(index)
144
+ WINDOWS_PALETTE[index.clamp(0, 15)]
145
+ end
146
+ end
147
+ end
148
+ end
data/lib/rich/box.rb ADDED
@@ -0,0 +1,342 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rich
4
+ # Box drawing character sets for borders and tables
5
+ class Box
6
+ # @return [String] Top-left corner
7
+ attr_reader :top_left
8
+
9
+ # @return [String] Top-right corner
10
+ attr_reader :top_right
11
+
12
+ # @return [String] Bottom-left corner
13
+ attr_reader :bottom_left
14
+
15
+ # @return [String] Bottom-right corner
16
+ attr_reader :bottom_right
17
+
18
+ # @return [String] Horizontal line
19
+ attr_reader :horizontal
20
+
21
+ # @return [String] Vertical line
22
+ attr_reader :vertical
23
+
24
+ # @return [String] Left T-junction
25
+ attr_reader :left_t
26
+
27
+ # @return [String] Right T-junction
28
+ attr_reader :right_t
29
+
30
+ # @return [String] Top T-junction
31
+ attr_reader :top_t
32
+
33
+ # @return [String] Bottom T-junction
34
+ attr_reader :bottom_t
35
+
36
+ # @return [String] Cross/plus junction
37
+ attr_reader :cross
38
+
39
+ # @return [String] Thick horizontal (for headers)
40
+ attr_reader :thick_horizontal
41
+
42
+ # @return [String] Thick left T-junction
43
+ attr_reader :thick_left_t
44
+
45
+ # @return [String] Thick right T-junction
46
+ attr_reader :thick_right_t
47
+
48
+ # @return [String] Thick cross
49
+ attr_reader :thick_cross
50
+
51
+ def initialize(
52
+ top_left:,
53
+ top_right:,
54
+ bottom_left:,
55
+ bottom_right:,
56
+ horizontal:,
57
+ vertical:,
58
+ left_t: nil,
59
+ right_t: nil,
60
+ top_t: nil,
61
+ bottom_t: nil,
62
+ cross: nil,
63
+ thick_horizontal: nil,
64
+ thick_left_t: nil,
65
+ thick_right_t: nil,
66
+ thick_cross: nil
67
+ )
68
+ @top_left = top_left
69
+ @top_right = top_right
70
+ @bottom_left = bottom_left
71
+ @bottom_right = bottom_right
72
+ @horizontal = horizontal
73
+ @vertical = vertical
74
+ @left_t = left_t || vertical
75
+ @right_t = right_t || vertical
76
+ @top_t = top_t || horizontal
77
+ @bottom_t = bottom_t || horizontal
78
+ @cross = cross || "+"
79
+ @thick_horizontal = thick_horizontal || horizontal
80
+ @thick_left_t = thick_left_t || @left_t
81
+ @thick_right_t = thick_right_t || @right_t
82
+ @thick_cross = thick_cross || @cross
83
+ freeze
84
+ end
85
+
86
+ # Get the top edge
87
+ # @param width [Integer] Width of content
88
+ # @return [String]
89
+ def top_edge(width)
90
+ "#{@top_left}#{@horizontal * [0, width - 2].max}#{@top_right}"
91
+ end
92
+
93
+ # Get the bottom edge
94
+ # @param width [Integer] Width of content
95
+ # @return [String]
96
+ def bottom_edge(width)
97
+ "#{@bottom_left}#{@horizontal * [0, width - 2].max}#{@bottom_right}"
98
+ end
99
+
100
+ # Get the row separator
101
+ # @param width_or_cells [Integer, Array] Total width or array of cell contents
102
+ # @param widths [Array<Integer>, nil] Array of column widths
103
+ # @return [String]
104
+ def row(width_or_cells, widths = nil)
105
+ if widths
106
+ # Table row separator with multiple columns
107
+ parts = widths.map { |w| @horizontal * w }
108
+ "#{@left_t}#{parts.join(@cross)}#{@right_t}"
109
+ else
110
+ # Single column separator
111
+ width = width_or_cells.is_a?(Integer) ? width_or_cells : Cells.cell_len(width_or_cells.to_s)
112
+ "#{@left_t}#{@horizontal * [0, width - 2].max}#{@right_t}"
113
+ end
114
+ end
115
+
116
+ alias top top_edge
117
+ alias bottom bottom_edge
118
+
119
+ # Get a content row
120
+ # @param content [String] Content
121
+ # @param width [Integer] Width to pad to
122
+ # @param align [Symbol] Alignment (:left, :center, :right)
123
+ # @return [String]
124
+ def content_row(content, width, align: :left)
125
+ content_len = Cells.cell_len(content)
126
+ padding = width - content_len
127
+
128
+ case align
129
+ when :center
130
+ left_pad = padding / 2
131
+ right_pad = padding - left_pad
132
+ "#{@vertical}#{' ' * left_pad}#{content}#{' ' * right_pad}#{@vertical}"
133
+ when :right
134
+ "#{@vertical}#{' ' * padding}#{content}#{@vertical}"
135
+ else # :left
136
+ "#{@vertical}#{content}#{' ' * padding}#{@vertical}"
137
+ end
138
+ end
139
+
140
+ # Get header separator (thicker line)
141
+ # @param width [Integer] Width
142
+ # @return [String]
143
+ def header_separator(width)
144
+ "#{@thick_left_t}#{@thick_horizontal * width}#{@thick_right_t}"
145
+ end
146
+
147
+ # Substitute ASCII characters for box characters
148
+ # @return [Box]
149
+ def to_ascii
150
+ ASCII
151
+ end
152
+
153
+ # Check if this is the ASCII box
154
+ # @return [Boolean]
155
+ def ascii?
156
+ self == ASCII
157
+ end
158
+
159
+ # Predefined box styles
160
+ class << self
161
+ # ASCII characters only
162
+ def ascii
163
+ ASCII
164
+ end
165
+
166
+ # Standard Unicode box drawing
167
+ def square
168
+ SQUARE
169
+ end
170
+
171
+ # Rounded corners
172
+ def rounded
173
+ ROUNDED
174
+ end
175
+
176
+ # Heavy/thick lines
177
+ def heavy
178
+ HEAVY
179
+ end
180
+
181
+ # Double lines
182
+ def double
183
+ DOUBLE
184
+ end
185
+
186
+ # Minimal (no corners)
187
+ def minimal
188
+ MINIMAL
189
+ end
190
+
191
+ # Simple horizontal lines only
192
+ def simple
193
+ SIMPLE
194
+ end
195
+
196
+ # No border
197
+ def none
198
+ NONE
199
+ end
200
+ end
201
+
202
+ # ASCII box (works everywhere)
203
+ ASCII = new(
204
+ top_left: "+",
205
+ top_right: "+",
206
+ bottom_left: "+",
207
+ bottom_right: "+",
208
+ horizontal: "-",
209
+ vertical: "|",
210
+ left_t: "+",
211
+ right_t: "+",
212
+ top_t: "+",
213
+ bottom_t: "+",
214
+ cross: "+",
215
+ thick_horizontal: "=",
216
+ thick_left_t: "+",
217
+ thick_right_t: "+",
218
+ thick_cross: "+"
219
+ )
220
+
221
+ # Standard Unicode box
222
+ SQUARE = new(
223
+ top_left: "┌",
224
+ top_right: "┐",
225
+ bottom_left: "└",
226
+ bottom_right: "┘",
227
+ horizontal: "─",
228
+ vertical: "│",
229
+ left_t: "├",
230
+ right_t: "┤",
231
+ top_t: "┬",
232
+ bottom_t: "┴",
233
+ cross: "┼",
234
+ thick_horizontal: "━",
235
+ thick_left_t: "┝",
236
+ thick_right_t: "┥",
237
+ thick_cross: "┿"
238
+ )
239
+
240
+ # Rounded corners
241
+ ROUNDED = new(
242
+ top_left: "╭",
243
+ top_right: "╮",
244
+ bottom_left: "╰",
245
+ bottom_right: "╯",
246
+ horizontal: "─",
247
+ vertical: "│",
248
+ left_t: "├",
249
+ right_t: "┤",
250
+ top_t: "┬",
251
+ bottom_t: "┴",
252
+ cross: "┼",
253
+ thick_horizontal: "━",
254
+ thick_left_t: "┝",
255
+ thick_right_t: "┥",
256
+ thick_cross: "┿"
257
+ )
258
+
259
+ # Heavy/thick box
260
+ HEAVY = new(
261
+ top_left: "┏",
262
+ top_right: "┓",
263
+ bottom_left: "┗",
264
+ bottom_right: "┛",
265
+ horizontal: "━",
266
+ vertical: "┃",
267
+ left_t: "┣",
268
+ right_t: "┫",
269
+ top_t: "┳",
270
+ bottom_t: "┻",
271
+ cross: "╋",
272
+ thick_horizontal: "━",
273
+ thick_left_t: "┣",
274
+ thick_right_t: "┫",
275
+ thick_cross: "╋"
276
+ )
277
+
278
+ # Double line box
279
+ DOUBLE = new(
280
+ top_left: "╔",
281
+ top_right: "╗",
282
+ bottom_left: "╚",
283
+ bottom_right: "╝",
284
+ horizontal: "═",
285
+ vertical: "║",
286
+ left_t: "╠",
287
+ right_t: "╣",
288
+ top_t: "╦",
289
+ bottom_t: "╩",
290
+ cross: "╬",
291
+ thick_horizontal: "═",
292
+ thick_left_t: "╠",
293
+ thick_right_t: "╣",
294
+ thick_cross: "╬"
295
+ )
296
+
297
+ # Minimal (dashes, no corners)
298
+ MINIMAL = new(
299
+ top_left: " ",
300
+ top_right: " ",
301
+ bottom_left: " ",
302
+ bottom_right: " ",
303
+ horizontal: "─",
304
+ vertical: " ",
305
+ left_t: " ",
306
+ right_t: " ",
307
+ top_t: "─",
308
+ bottom_t: "─",
309
+ cross: "─"
310
+ )
311
+
312
+ # Simple (just horizontal lines)
313
+ SIMPLE = new(
314
+ top_left: "",
315
+ top_right: "",
316
+ bottom_left: "",
317
+ bottom_right: "",
318
+ horizontal: "─",
319
+ vertical: "",
320
+ left_t: "",
321
+ right_t: "",
322
+ top_t: "",
323
+ bottom_t: "",
324
+ cross: ""
325
+ )
326
+
327
+ # No border at all
328
+ NONE = new(
329
+ top_left: "",
330
+ top_right: "",
331
+ bottom_left: "",
332
+ bottom_right: "",
333
+ horizontal: "",
334
+ vertical: "",
335
+ left_t: "",
336
+ right_t: "",
337
+ top_t: "",
338
+ bottom_t: "",
339
+ cross: ""
340
+ )
341
+ end
342
+ end