Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ Metrics/BlockLength:
- "spec/**/*"
- "*.gemspec"

Metrics/AbcSize:
Max: 18 # Allow slightly higher for interrupt logic

Metrics/MethodLength:
Max: 20 # Allow slightly higher for interrupt logic

# Prefer double quotes (common in gems)
Style/StringLiterals:
EnforcedStyle: double_quotes
Expand Down
48 changes: 48 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.2.0] - 2026-02-02

### Added
- Keyboard interrupt support to skip typewriter animation
- `interrupt: true` or `interrupt: :enter` - Skip with Enter key
- `interrupt: :any` - Skip with any key press
- `interrupt: ["q", "x", " "]` - Skip with specific keys
- When interrupted, remaining text prints immediately
- Non-blocking keyboard input detection using `io/console`

### Changed
- Updated README with interrupt mode documentation and examples
- Adjusted RuboCop metrics configuration for interrupt logic
- **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.

### Technical
- Added `write_interruptible` and `key_matches?` private methods
- Uses `IO.select` for non-blocking input polling (Ruby 3.0+ compatible)
- 100% backwards compatible for all documented usage patterns

## [1.1.0] - 2025-01-XX

### Changed
- Modernized codebase to idiomatic Ruby gem standards
- Added RuboCop linting to development workflow

### Added
- RSpec testing infrastructure
- GitHub Actions CI workflow
- Comprehensive test coverage for timing and output behavior

## [1.0.0] - Earlier

### Added
- Initial typewriter effect implementation
- Basic timing controls (type_rate, punc_rate)
- Line break control

[1.2.0]: https://github.com/CJGlitter/typewrite/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/CJGlitter/typewrite/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/CJGlitter/typewrite/releases/tag/v1.0.0
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,38 @@ Typewrite.write("Your message here!")
```

### Options ###
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.

You can adjust the type rate and pause length with integers and turn off the newlines by passing integers and a boolean respectively.
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.

Example
#### Basic Options

You can adjust the type rate and pause length with numbers and turn off the newlines with a boolean:

```ruby
message = "Here's your message"
Typewrite.write(message, 0.05, 0, false)
```

That would result in a message that types a character every 0.05 seconds with no punctuation pauses and no newlines after each message.

#### Interrupt Mode

You can allow users to skip the typewriter animation by pressing keys during output:

```ruby
# Allow interruption with Enter key (default interrupt behavior)
Typewrite.write("Press ENTER to skip...", interrupt: true)
Typewrite.write("Press ENTER to skip...", interrupt: :enter)

# Allow interruption with any key
Typewrite.write("Press ANY key to skip...", interrupt: :any)

# Allow interruption with specific keys
Typewrite.write("Press Q, X, or SPACE to skip...", interrupt: ["q", "x", " "])
```

When interrupted, the remaining text prints immediately so users see the complete message.

## Development

### Running Tests
Expand Down
71 changes: 70 additions & 1 deletion lib/typewrite.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require_relative "typewrite/version"
require "io/console"

# Prints console messages with a typewriter effect (letter-by-letter).
#
Expand All @@ -20,13 +21,81 @@ module Typewrite
# @param type_rate [Float] seconds between each character (default: 0.1)
# @param punc_rate [Float] extra pause after punctuation (default: 1.5)
# @param line_break [Boolean] add newline at end (default: true)
# @param interrupt [Boolean, Symbol, Array] enable interrupt mode (default: false)
# - false: no interrupt (default)
# - true or :enter: interrupt on Enter key
# - :any: interrupt on any key
# - Array: interrupt on specific keys (e.g., ["q", "x", " "])
# @return [void]
def self.write(input, type_rate = 0.1, punc_rate = 1.5, line_break = true)
def self.write(input, type_rate = 0.1, punc_rate = 1.5, line_break = true, interrupt: false)
return write_simple(input, type_rate, punc_rate, line_break) unless interrupt

write_interruptible(input, type_rate, punc_rate, line_break, interrupt)
end

# Simple non-interruptible write (original behavior)
#
# @param input [String] the message to display
# @param type_rate [Float] seconds between each character
# @param punc_rate [Float] extra pause after punctuation
# @param line_break [Boolean] add newline at end
# @return [void]
private_class_method def self.write_simple(input, type_rate, punc_rate, line_break)
input.each_char do |char|
print char
sleep(punc_rate) if PUNCTUATION.include?(char)
sleep(type_rate)
end
print "\n" if line_break
end

# Interruptible write with keyboard input detection
#
# @param input [String] the message to display
# @param type_rate [Float] seconds between each character
# @param punc_rate [Float] extra pause after punctuation
# @param line_break [Boolean] add newline at end
# @param interrupt_config [Boolean, Symbol, Array] interrupt configuration
# @return [void]
private_class_method def self.write_interruptible(input, type_rate, punc_rate, line_break, interrupt_config)
chars = input.chars
index = 0

$stdin.raw do |io|
while index < chars.length
# Check for keypress (non-blocking)
# rubocop:disable Lint/IncompatibleIoSelectWithFiberScheduler
if IO.select([io], nil, nil, 0)
# rubocop:enable Lint/IncompatibleIoSelectWithFiberScheduler
pressed = io.getc
if key_matches?(pressed, interrupt_config)
# Print remaining text immediately
print chars[index..].join
break
end
end

print chars[index]
sleep(punc_rate) if PUNCTUATION.include?(chars[index])
sleep(type_rate)
index += 1
end
end

print "\n" if line_break
end

# Checks if a pressed key matches the interrupt configuration
#
# @param char [String] the pressed character
# @param config [Boolean, Symbol, Array] interrupt configuration
# @return [Boolean] true if the key should trigger an interrupt
private_class_method def self.key_matches?(char, config)
case config
when true, :enter then ["\r", "\n"].include?(char)
when :any then true
when Array then config.include?(char)
else false
end
end
end
2 changes: 1 addition & 1 deletion lib/typewrite/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Typewrite
VERSION = "1.1.0"
VERSION = "1.2.0"
end
122 changes: 122 additions & 0 deletions spec/typewrite_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,127 @@
expect(sleep_calls.count(1.5)).to eq(1) # pause after .
end
end

context "interrupt functionality" do
before do
allow_any_instance_of(Object).to receive(:sleep)
end

it "works without interrupt parameter (backwards compatibility)" do
expect { described_class.write("hello") }.to output("hello\n").to_stdout
end

it "works with interrupt: false (backwards compatibility)" do
expect { described_class.write("hello", 0.1, 1.5, true, interrupt: false) }.to output("hello\n").to_stdout
end

context "when interrupt is enabled" do
let(:mock_stdin) { instance_double(IO) }

before do
allow($stdin).to receive(:raw).and_yield(mock_stdin)
end

it "outputs full message when no key is pressed (interrupt: true)" do
allow(IO).to receive(:select).and_return(nil)

expect { described_class.write("hello", 0.1, 1.5, true, interrupt: true) }.to output("hello\n").to_stdout
end

it "outputs full message when no key is pressed (interrupt: :enter)" do
allow(IO).to receive(:select).and_return(nil)

expect { described_class.write("hello", 0.1, 1.5, true, interrupt: :enter) }.to output("hello\n").to_stdout
end

it "outputs full message when no key is pressed (interrupt: :any)" do
allow(IO).to receive(:select).and_return(nil)

expect { described_class.write("hello", 0.1, 1.5, true, interrupt: :any) }.to output("hello\n").to_stdout
end

it "outputs full message when no key is pressed (interrupt: array)" do
allow(IO).to receive(:select).and_return(nil)

expect { described_class.write("hello", 0.1, 1.5, true, interrupt: ["q"]) }.to output("hello\n").to_stdout
end

it "prints remaining text immediately when enter is pressed (interrupt: true)" do
call_count = 0
allow(IO).to receive(:select) do
call_count += 1
call_count == 2 ? [mock_stdin] : nil
end
allow(mock_stdin).to receive(:getc).and_return("\n")

expect { described_class.write("hello", 0.1, 1.5, true, interrupt: true) }.to output("hello\n").to_stdout
end

it "prints remaining text immediately when enter is pressed (interrupt: :enter)" do
call_count = 0
allow(IO).to receive(:select) do
call_count += 1
call_count == 2 ? [mock_stdin] : nil
end
allow(mock_stdin).to receive(:getc).and_return("\r")

expect { described_class.write("hello", 0.1, 1.5, true, interrupt: :enter) }.to output("hello\n").to_stdout
end

it "prints remaining text immediately when any key is pressed (interrupt: :any)" do
call_count = 0
allow(IO).to receive(:select) do
call_count += 1
call_count == 2 ? [mock_stdin] : nil
end
allow(mock_stdin).to receive(:getc).and_return("x")

expect { described_class.write("hello", 0.1, 1.5, true, interrupt: :any) }.to output("hello\n").to_stdout
end

it "prints remaining text when matching key is pressed (interrupt: array)" do
call_count = 0
allow(IO).to receive(:select) do
call_count += 1
call_count == 3 ? [mock_stdin] : nil
end
allow(mock_stdin).to receive(:getc).and_return("q")

expect do
described_class.write("hello", 0.1, 1.5, true, interrupt: %w[q x])
end.to output("hello\n").to_stdout
end

it "does not interrupt when non-matching key is pressed (interrupt: array)" do
allow(IO).to receive(:select).and_return([mock_stdin])
allow(mock_stdin).to receive(:getc).and_return("a", "b", "c", "d", "e")

expect { described_class.write("hello", 0.1, 1.5, true, interrupt: ["q"]) }.to output("hello\n").to_stdout
end

it "does not interrupt when non-enter key is pressed (interrupt: :enter)" do
allow(IO).to receive(:select).and_return([mock_stdin])
allow(mock_stdin).to receive(:getc).and_return("x", "y", "z", "a", "b")

expect { described_class.write("hello", 0.1, 1.5, true, interrupt: :enter) }.to output("hello\n").to_stdout
end

it "handles empty string with interrupt enabled" do
expect { described_class.write("", 0.1, 1.5, true, interrupt: true) }.to output("\n").to_stdout
end

it "handles single character with interrupt enabled" do
allow(IO).to receive(:select).and_return(nil)

expect { described_class.write("x", 0.1, 1.5, true, interrupt: true) }.to output("x\n").to_stdout
end

it "respects line_break: false with interrupt" do
allow(IO).to receive(:select).and_return(nil)

expect { described_class.write("hi", 0.1, 1.5, false, interrupt: true) }.to output("hi").to_stdout
end
end
end
end
end