Skip to content

Commit e5a40b2

Browse files
authored
feat: Add keyboard interrupt support to typewriter animation (#4)
* feat: Add keyboard interrupt support to typewriter animation Add ability for users to skip typewriter animation by pressing keys during output. This provides better UX for users who want to see content faster. Changes: - Add interrupt parameter to Typewrite.write() accepting :enter, :any, or array of specific keys - Implement non-blocking keyboard input detection using io/console - Add write_interruptible() and key_matches?() private methods - When interrupted, remaining text prints immediately Technical details: - Uses io.wait_readable(0) for non-blocking keyboard polling - Maintains backwards compatibility via keyword argument (interrupt: false) - Terminal state automatically restored via $stdin.raw block Testing: - Add 15 new RSpec tests covering all interrupt modes - All existing tests pass (100% backwards compatible) - Update RuboCop config to allow slightly higher metrics for interrupt logic Documentation: - Update README with interrupt mode examples and usage - Bump version 1.1.0 → 1.2.0 (minor: new feature) * fix: Use IO.select for Ruby 3.0/3.1 compatibility Replace io.wait_readable(0) with IO.select([io], nil, nil, 0) to support Ruby 3.0 and 3.1, as wait_readable was only added in Ruby 3.2. The gem requires >= 3.0.0, so we need to use IO.select which works across all supported Ruby versions. Disabled RuboCop's IncompatibleIoSelectWithFiberScheduler warning for this specific use case. Changes: - Revert to IO.select in write_interruptible method - Update all test mocks to use IO.select instead of wait_readable - Add rubocop:disable comment for backwards compatibility * docs: Note keyword argument syntax no longer supported in CHANGELOG
1 parent 18427f4 commit e5a40b2

6 files changed

Lines changed: 270 additions & 5 deletions

File tree

.rubocop.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ Metrics/BlockLength:
1414
- "spec/**/*"
1515
- "*.gemspec"
1616

17+
Metrics/AbcSize:
18+
Max: 18 # Allow slightly higher for interrupt logic
19+
20+
Metrics/MethodLength:
21+
Max: 20 # Allow slightly higher for interrupt logic
22+
1723
# Prefer double quotes (common in gems)
1824
Style/StringLiterals:
1925
EnforcedStyle: double_quotes

CHANGELOG.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## [1.2.0] - 2026-02-02
9+
10+
### Added
11+
- Keyboard interrupt support to skip typewriter animation
12+
- `interrupt: true` or `interrupt: :enter` - Skip with Enter key
13+
- `interrupt: :any` - Skip with any key press
14+
- `interrupt: ["q", "x", " "]` - Skip with specific keys
15+
- When interrupted, remaining text prints immediately
16+
- Non-blocking keyboard input detection using `io/console`
17+
18+
### Changed
19+
- Updated README with interrupt mode documentation and examples
20+
- Adjusted RuboCop metrics configuration for interrupt logic
21+
- **Note:** Undocumented keyword argument syntax (e.g., `type_rate: 0.05`) is no longer supported due to Ruby 3.0+ keyword argument separation. All documented positional argument usage remains fully compatible.
22+
23+
### Technical
24+
- Added `write_interruptible` and `key_matches?` private methods
25+
- Uses `IO.select` for non-blocking input polling (Ruby 3.0+ compatible)
26+
- 100% backwards compatible for all documented usage patterns
27+
28+
## [1.1.0] - 2025-01-XX
29+
30+
### Changed
31+
- Modernized codebase to idiomatic Ruby gem standards
32+
- Added RuboCop linting to development workflow
33+
34+
### Added
35+
- RSpec testing infrastructure
36+
- GitHub Actions CI workflow
37+
- Comprehensive test coverage for timing and output behavior
38+
39+
## [1.0.0] - Earlier
40+
41+
### Added
42+
- Initial typewriter effect implementation
43+
- Basic timing controls (type_rate, punc_rate)
44+
- Line break control
45+
46+
[1.2.0]: https://github.com/CJGlitter/typewrite/compare/v1.1.0...v1.2.0
47+
[1.1.0]: https://github.com/CJGlitter/typewrite/compare/v1.0.0...v1.1.0
48+
[1.0.0]: https://github.com/CJGlitter/typewrite/releases/tag/v1.0.0

README.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,38 @@ Typewrite.write("Your message here!")
1818
```
1919

2020
### Options ###
21-
The variable options include type rate, pause length, and whether or not a newline is desired at the end of each message. By default, the type rate is one character per 0.1 second with a 1.5 second pause after each punctuation character `['.','?','!']` with a new line after each message.
2221

23-
You can adjust the type rate and pause length with integers and turn off the newlines by passing integers and a boolean respectively.
22+
The variable options include type rate, pause length, line breaks, and interrupt behavior. By default, the type rate is one character per 0.1 second with a 1.5 second pause after each punctuation character `['.','?','!']` with a new line after each message.
2423

25-
Example
24+
#### Basic Options
25+
26+
You can adjust the type rate and pause length with numbers and turn off the newlines with a boolean:
2627

2728
```ruby
2829
message = "Here's your message"
2930
Typewrite.write(message, 0.05, 0, false)
3031
```
32+
3133
That would result in a message that types a character every 0.05 seconds with no punctuation pauses and no newlines after each message.
3234

35+
#### Interrupt Mode
36+
37+
You can allow users to skip the typewriter animation by pressing keys during output:
38+
39+
```ruby
40+
# Allow interruption with Enter key (default interrupt behavior)
41+
Typewrite.write("Press ENTER to skip...", interrupt: true)
42+
Typewrite.write("Press ENTER to skip...", interrupt: :enter)
43+
44+
# Allow interruption with any key
45+
Typewrite.write("Press ANY key to skip...", interrupt: :any)
46+
47+
# Allow interruption with specific keys
48+
Typewrite.write("Press Q, X, or SPACE to skip...", interrupt: ["q", "x", " "])
49+
```
50+
51+
When interrupted, the remaining text prints immediately so users see the complete message.
52+
3353
## Development
3454

3555
### Running Tests

lib/typewrite.rb

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require_relative "typewrite/version"
4+
require "io/console"
45

56
# Prints console messages with a typewriter effect (letter-by-letter).
67
#
@@ -20,13 +21,81 @@ module Typewrite
2021
# @param type_rate [Float] seconds between each character (default: 0.1)
2122
# @param punc_rate [Float] extra pause after punctuation (default: 1.5)
2223
# @param line_break [Boolean] add newline at end (default: true)
24+
# @param interrupt [Boolean, Symbol, Array] enable interrupt mode (default: false)
25+
# - false: no interrupt (default)
26+
# - true or :enter: interrupt on Enter key
27+
# - :any: interrupt on any key
28+
# - Array: interrupt on specific keys (e.g., ["q", "x", " "])
2329
# @return [void]
24-
def self.write(input, type_rate = 0.1, punc_rate = 1.5, line_break = true)
30+
def self.write(input, type_rate = 0.1, punc_rate = 1.5, line_break = true, interrupt: false)
31+
return write_simple(input, type_rate, punc_rate, line_break) unless interrupt
32+
33+
write_interruptible(input, type_rate, punc_rate, line_break, interrupt)
34+
end
35+
36+
# Simple non-interruptible write (original behavior)
37+
#
38+
# @param input [String] the message to display
39+
# @param type_rate [Float] seconds between each character
40+
# @param punc_rate [Float] extra pause after punctuation
41+
# @param line_break [Boolean] add newline at end
42+
# @return [void]
43+
private_class_method def self.write_simple(input, type_rate, punc_rate, line_break)
2544
input.each_char do |char|
2645
print char
2746
sleep(punc_rate) if PUNCTUATION.include?(char)
2847
sleep(type_rate)
2948
end
3049
print "\n" if line_break
3150
end
51+
52+
# Interruptible write with keyboard input detection
53+
#
54+
# @param input [String] the message to display
55+
# @param type_rate [Float] seconds between each character
56+
# @param punc_rate [Float] extra pause after punctuation
57+
# @param line_break [Boolean] add newline at end
58+
# @param interrupt_config [Boolean, Symbol, Array] interrupt configuration
59+
# @return [void]
60+
private_class_method def self.write_interruptible(input, type_rate, punc_rate, line_break, interrupt_config)
61+
chars = input.chars
62+
index = 0
63+
64+
$stdin.raw do |io|
65+
while index < chars.length
66+
# Check for keypress (non-blocking)
67+
# rubocop:disable Lint/IncompatibleIoSelectWithFiberScheduler
68+
if IO.select([io], nil, nil, 0)
69+
# rubocop:enable Lint/IncompatibleIoSelectWithFiberScheduler
70+
pressed = io.getc
71+
if key_matches?(pressed, interrupt_config)
72+
# Print remaining text immediately
73+
print chars[index..].join
74+
break
75+
end
76+
end
77+
78+
print chars[index]
79+
sleep(punc_rate) if PUNCTUATION.include?(chars[index])
80+
sleep(type_rate)
81+
index += 1
82+
end
83+
end
84+
85+
print "\n" if line_break
86+
end
87+
88+
# Checks if a pressed key matches the interrupt configuration
89+
#
90+
# @param char [String] the pressed character
91+
# @param config [Boolean, Symbol, Array] interrupt configuration
92+
# @return [Boolean] true if the key should trigger an interrupt
93+
private_class_method def self.key_matches?(char, config)
94+
case config
95+
when true, :enter then ["\r", "\n"].include?(char)
96+
when :any then true
97+
when Array then config.include?(char)
98+
else false
99+
end
100+
end
32101
end

lib/typewrite/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# frozen_string_literal: true
22

33
module Typewrite
4-
VERSION = "1.1.0"
4+
VERSION = "1.2.0"
55
end

spec/typewrite_spec.rb

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,5 +71,127 @@
7171
expect(sleep_calls.count(1.5)).to eq(1) # pause after .
7272
end
7373
end
74+
75+
context "interrupt functionality" do
76+
before do
77+
allow_any_instance_of(Object).to receive(:sleep)
78+
end
79+
80+
it "works without interrupt parameter (backwards compatibility)" do
81+
expect { described_class.write("hello") }.to output("hello\n").to_stdout
82+
end
83+
84+
it "works with interrupt: false (backwards compatibility)" do
85+
expect { described_class.write("hello", 0.1, 1.5, true, interrupt: false) }.to output("hello\n").to_stdout
86+
end
87+
88+
context "when interrupt is enabled" do
89+
let(:mock_stdin) { instance_double(IO) }
90+
91+
before do
92+
allow($stdin).to receive(:raw).and_yield(mock_stdin)
93+
end
94+
95+
it "outputs full message when no key is pressed (interrupt: true)" do
96+
allow(IO).to receive(:select).and_return(nil)
97+
98+
expect { described_class.write("hello", 0.1, 1.5, true, interrupt: true) }.to output("hello\n").to_stdout
99+
end
100+
101+
it "outputs full message when no key is pressed (interrupt: :enter)" do
102+
allow(IO).to receive(:select).and_return(nil)
103+
104+
expect { described_class.write("hello", 0.1, 1.5, true, interrupt: :enter) }.to output("hello\n").to_stdout
105+
end
106+
107+
it "outputs full message when no key is pressed (interrupt: :any)" do
108+
allow(IO).to receive(:select).and_return(nil)
109+
110+
expect { described_class.write("hello", 0.1, 1.5, true, interrupt: :any) }.to output("hello\n").to_stdout
111+
end
112+
113+
it "outputs full message when no key is pressed (interrupt: array)" do
114+
allow(IO).to receive(:select).and_return(nil)
115+
116+
expect { described_class.write("hello", 0.1, 1.5, true, interrupt: ["q"]) }.to output("hello\n").to_stdout
117+
end
118+
119+
it "prints remaining text immediately when enter is pressed (interrupt: true)" do
120+
call_count = 0
121+
allow(IO).to receive(:select) do
122+
call_count += 1
123+
call_count == 2 ? [mock_stdin] : nil
124+
end
125+
allow(mock_stdin).to receive(:getc).and_return("\n")
126+
127+
expect { described_class.write("hello", 0.1, 1.5, true, interrupt: true) }.to output("hello\n").to_stdout
128+
end
129+
130+
it "prints remaining text immediately when enter is pressed (interrupt: :enter)" do
131+
call_count = 0
132+
allow(IO).to receive(:select) do
133+
call_count += 1
134+
call_count == 2 ? [mock_stdin] : nil
135+
end
136+
allow(mock_stdin).to receive(:getc).and_return("\r")
137+
138+
expect { described_class.write("hello", 0.1, 1.5, true, interrupt: :enter) }.to output("hello\n").to_stdout
139+
end
140+
141+
it "prints remaining text immediately when any key is pressed (interrupt: :any)" do
142+
call_count = 0
143+
allow(IO).to receive(:select) do
144+
call_count += 1
145+
call_count == 2 ? [mock_stdin] : nil
146+
end
147+
allow(mock_stdin).to receive(:getc).and_return("x")
148+
149+
expect { described_class.write("hello", 0.1, 1.5, true, interrupt: :any) }.to output("hello\n").to_stdout
150+
end
151+
152+
it "prints remaining text when matching key is pressed (interrupt: array)" do
153+
call_count = 0
154+
allow(IO).to receive(:select) do
155+
call_count += 1
156+
call_count == 3 ? [mock_stdin] : nil
157+
end
158+
allow(mock_stdin).to receive(:getc).and_return("q")
159+
160+
expect do
161+
described_class.write("hello", 0.1, 1.5, true, interrupt: %w[q x])
162+
end.to output("hello\n").to_stdout
163+
end
164+
165+
it "does not interrupt when non-matching key is pressed (interrupt: array)" do
166+
allow(IO).to receive(:select).and_return([mock_stdin])
167+
allow(mock_stdin).to receive(:getc).and_return("a", "b", "c", "d", "e")
168+
169+
expect { described_class.write("hello", 0.1, 1.5, true, interrupt: ["q"]) }.to output("hello\n").to_stdout
170+
end
171+
172+
it "does not interrupt when non-enter key is pressed (interrupt: :enter)" do
173+
allow(IO).to receive(:select).and_return([mock_stdin])
174+
allow(mock_stdin).to receive(:getc).and_return("x", "y", "z", "a", "b")
175+
176+
expect { described_class.write("hello", 0.1, 1.5, true, interrupt: :enter) }.to output("hello\n").to_stdout
177+
end
178+
179+
it "handles empty string with interrupt enabled" do
180+
expect { described_class.write("", 0.1, 1.5, true, interrupt: true) }.to output("\n").to_stdout
181+
end
182+
183+
it "handles single character with interrupt enabled" do
184+
allow(IO).to receive(:select).and_return(nil)
185+
186+
expect { described_class.write("x", 0.1, 1.5, true, interrupt: true) }.to output("x\n").to_stdout
187+
end
188+
189+
it "respects line_break: false with interrupt" do
190+
allow(IO).to receive(:select).and_return(nil)
191+
192+
expect { described_class.write("hi", 0.1, 1.5, false, interrupt: true) }.to output("hi").to_stdout
193+
end
194+
end
195+
end
74196
end
75197
end

0 commit comments

Comments
 (0)