keisanjaku 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 58531155e91da64cf07fa9ad169ffee269730111d701b934a278299038881ca2
4
+ data.tar.gz: 027372ea33da151f6fe8b7e2f7565f5d76dd8fa060eb8f17514661f22dad730d
5
+ SHA512:
6
+ metadata.gz: 619c4bbc53e672ca79d717cc148bd134d833361eda0502104e9577c8f2b09a3b21b2b21e56821d354407d95e0b1efda1a5796f093854d0a43db7f520251ae3ea
7
+ data.tar.gz: 7e61d7255ae0af07d6414b1dbdb0a31d1870c02ae207caba7b93305f87bcc2b68293eac7fa9b9d738a7ad0a57a3ebc12e3346c53eebc8878a939548d6f7cfc5a
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # keisanjaku
2
+
3
+ `keisanjaku` is a terminal slide rule simulator. It runs on Ruby's standard
4
+ library only and provides ANSI text rendering, an interactive sandbox, expression
5
+ demos, and drill practice.
6
+
7
+ ## Requirements
8
+
9
+ - Ruby 3.x or later
10
+ - A terminal with at least 100 columns is recommended
11
+ - No external gems are required
12
+
13
+ ## Usage
14
+
15
+ ```sh
16
+ exe/keisanjaku --version
17
+ exe/keisanjaku --render-only --slide=0.3 --cursor=0.477 --width=100
18
+ exe/keisanjaku --no-anim --demo='2*3'
19
+ exe/keisanjaku --sandbox
20
+ exe/keisanjaku --drill --tolerance=1.0
21
+ ```
22
+
23
+ ## Installation
24
+
25
+ Build and install the gem locally:
26
+
27
+ ```sh
28
+ gem build keisanjaku.gemspec
29
+ gem install keisanjaku-0.1.0.gem
30
+ ```
31
+
32
+ Publish to RubyGems after signing in with `gem signin`:
33
+
34
+ ```sh
35
+ gem push keisanjaku-0.1.0.gem
36
+ ```
37
+
38
+ In interactive modes, omitting `--width=N` makes the display follow the current
39
+ terminal width. Unix-like terminals use `SIGWINCH`; Windows terminals use a
40
+ polling fallback. Use `NO_COLOR=1` or `--no-color` for plain monochrome output.
41
+
42
+ The expression parser accepts:
43
+
44
+ - Multiplication: `2*3` or `2×3`
45
+ - Division: `6/2` or `6÷2`
46
+ - Powers: `2^2`, `2^3`
47
+ - Functions: `sqrt(2)`, `cbrt(8)`, `log(2)`, `sin(30)`, `cos(60)`, `tan(30)`
48
+
49
+ ## Example Output
50
+
51
+ ```text
52
+ +--------------------------------------------------------------------------------------------+
53
+ K|| | | | || || | | | | | | | | | || | | | | | || || | | ||
54
+ |1 2 3 4 56 78 9 1 2 3 4 5 6 7 891 2 3 4 5 6 78 9 1 |
55
+ A|| | | | | | | | | | | | | | | | | | | | ||
56
+ |1 2 3 4 5 6 7 8 9 1 2 3 4 5 6 7 8 9 1 |
57
+ +--------------------------------------------------------------------------------------------+
58
+ B|........................|.............|........|.....|....|...|..|..|.|.....................|
59
+ |........................1.............2........3.....4....5...6..7..8.9.....................|
60
+ CI|........................|...|..:..|.:.|..:..|...|....:....|.....:......|...........:........|
61
+ |........................1...9.....8...7.....6...5.........4............3....................|
62
+ C|........................|....................|............:........|.......:.....|....:...|.|
63
+ |........................1....................2....................3.............4............|
64
+ +--------------------------------------------------------------------------------------------+
65
+ D|| : | : | : | : | : | : | : | : | : | : ||
66
+ |1 2 3 4 5 6 7 8 9 1|
67
+ L|| | | | | | | | | | ||
68
+ |0 .1 .2 .3 .4 .5 .6 .7 .8 .9 1|
69
+ +--------------------------------------------------------------------------------------------+
70
+ cursor=0.4770 slide=+0.3000 face=front K=26.977 A=8.995 B=2.2594 CI=6.6527 C=1.5 D=2.9997 L=0.477
71
+ ```
72
+
73
+ ## Controls
74
+
75
+ Sandbox:
76
+
77
+ - `h` / `l`: move the slide left or right in coarse steps
78
+ - `H` / `L`, or `,` / `.`: move the slide in fine steps
79
+ - `c` / `j` / `k`: switch arrow-key control between the cursor and slide
80
+ - `Left` / `Right`: move the selected target
81
+ - `Tab`: flip between the front and back slide faces
82
+ - `?`: toggle the value readout
83
+ - `r`: reset
84
+ - `q`: quit
85
+
86
+ Drill:
87
+
88
+ - Choose the problem range at startup, for example `multiply,sin`
89
+ - Press `Enter` or `o` for the slide-rule workspace and answer entry
90
+ - Type a numeric answer in the workspace and press `Enter` to submit it
91
+ - `g`: show the worked demo
92
+ - `s`: skip the workspace and answer as plain text
93
+ - Correct answers report whether the exact operation sequence matched the model
94
+ steps, including scale, value, and side information such as `MOVE_CURSOR(D, 2)`
95
+
96
+ Demo:
97
+
98
+ - `Enter` / `Space`: advance one step
99
+ - `b`: go back one step
100
+ - `a`: toggle autoplay
101
+ - `1` / `2` / `3`: change autoplay speed
102
+ - `q`: quit
103
+
104
+ ## Principle
105
+
106
+ The C and D scales are modeled with positions based on `log10(x)`.
107
+ Multiplication is addition of distances: align the C scale index with a value on
108
+ the D scale, move the cursor to the other factor on the C scale, and read the
109
+ mantissa of the product on the D scale.
110
+
111
+ When the result would fall outside the frame, use the opposite slide index to
112
+ wrap the operation. A slide rule reads mantissas, so the final power-of-ten place
113
+ must be determined separately. Demo mode prints that place-value explanation at
114
+ the end.
115
+
116
+ ## Tests
117
+
118
+ ```sh
119
+ bundle install
120
+ rake test
121
+ ruby -w -Ilib -rkeisanjaku -e 'puts Keisanjaku::VERSION'
122
+ ```
123
+
124
+ ## License
125
+
126
+ MIT. See [LICENSE.txt](LICENSE.txt).
data/exe/keisanjaku ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4
+ require "keisanjaku/app"
5
+
6
+ exit Keisanjaku::App.new(ARGV).run
@@ -0,0 +1,79 @@
1
+ module Keisanjaku
2
+ module ANSI
3
+ module_function
4
+
5
+ CODES = {
6
+ reset: 0,
7
+ red: 31,
8
+ yellow: 33,
9
+ cyan_bg: 46,
10
+ bold: 1
11
+ }.freeze
12
+ AMBIGUOUS_WIDE_CHARS = "×÷±−√°·".chars.freeze
13
+
14
+ def enabled?(color)
15
+ color && ENV["NO_COLOR"].nil?
16
+ end
17
+
18
+ def paint(text, *styles, color: true)
19
+ return text unless enabled?(color)
20
+
21
+ codes = styles.map { |style| CODES.fetch(style) }.join(";")
22
+ "\e[#{codes}m#{text}\e[0m"
23
+ end
24
+
25
+ def clear_screen
26
+ "\e[2J\e[H"
27
+ end
28
+
29
+ def hide_cursor
30
+ "\e[?25l"
31
+ end
32
+
33
+ def show_cursor
34
+ "\e[?25h"
35
+ end
36
+
37
+ def strip(text)
38
+ text.gsub(/\e\[[0-9;?]*[A-Za-z]/, "")
39
+ end
40
+
41
+ def visible_width(text)
42
+ strip(text).each_char.sum { |char| wide?(char) ? 2 : 1 }
43
+ end
44
+
45
+ def fit(text, width)
46
+ stripped_width = visible_width(text)
47
+ return text + (" " * (width - stripped_width)) if stripped_width < width
48
+ return text if stripped_width == width
49
+
50
+ truncate(text, width)
51
+ end
52
+
53
+ def truncate(text, width)
54
+ output = +""
55
+ used = 0
56
+ strip(text).each_char do |char|
57
+ char_width = wide?(char) ? 2 : 1
58
+ break if used + char_width > width
59
+
60
+ output << char
61
+ used += char_width
62
+ end
63
+ output << (" " * (width - used))
64
+ end
65
+
66
+ def wide?(char)
67
+ code = char.ord
68
+ (0x1100..0x115F).cover?(code) ||
69
+ (0x2E80..0xA4CF).cover?(code) ||
70
+ (0xAC00..0xD7A3).cover?(code) ||
71
+ (0xF900..0xFAFF).cover?(code) ||
72
+ (0xFE10..0xFE19).cover?(code) ||
73
+ (0xFE30..0xFE6F).cover?(code) ||
74
+ (0xFF00..0xFF60).cover?(code) ||
75
+ (0xFFE0..0xFFE6).cover?(code) ||
76
+ AMBIGUOUS_WIDE_CHARS.include?(char)
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,144 @@
1
+ require "optparse"
2
+ require "io/console"
3
+ require_relative "version"
4
+ require_relative "renderer"
5
+ require_relative "parser"
6
+ require_relative "planner"
7
+ require_relative "engine"
8
+ require_relative "terminal_viewport"
9
+ require_relative "modes/sandbox"
10
+ require_relative "modes/demo"
11
+ require_relative "modes/drill"
12
+
13
+ module Keisanjaku
14
+ class App
15
+ def initialize(argv, input: $stdin, output: $stdout, error: $stderr)
16
+ @argv = argv.dup
17
+ @input = input
18
+ @output = output
19
+ @error = error
20
+ @options = {
21
+ color: ENV["NO_COLOR"].nil?,
22
+ animate: true,
23
+ width: nil,
24
+ tolerance: 1.0,
25
+ slide: 0.0,
26
+ cursor: 0.0,
27
+ face: :front
28
+ }
29
+ end
30
+
31
+ def run
32
+ parse_options
33
+ return print_version if @options[:version]
34
+ return render_only if @options[:render_only]
35
+ return key_test if @options[:key_test]
36
+ return run_demo(@options[:demo]) if @options[:demo]
37
+ return run_drill if @options[:drill]
38
+ return run_sandbox if @options[:sandbox]
39
+
40
+ expression = @argv.join(" ").strip
41
+ return run_demo(expression) unless expression.empty?
42
+
43
+ run_menu
44
+ rescue ParseError, ScaleError, OptionParser::ParseError => e
45
+ @error.puts "keisanjaku: #{e.message}"
46
+ 1
47
+ end
48
+
49
+ private
50
+
51
+ def parse_options
52
+ parser = OptionParser.new do |opts|
53
+ opts.banner = "Usage: keisanjaku [options] [expression]"
54
+ opts.on("--version", "Print version") { @options[:version] = true }
55
+ opts.on("--no-color", "Disable ANSI colors") { @options[:color] = false }
56
+ opts.on("--no-anim", "Disable animation") { @options[:animate] = false }
57
+ opts.on("--width=N", Integer, "Render width") { |value| @options[:width] = value }
58
+ opts.on("--tolerance=X", Float, "Drill tolerance percent") { |value| @options[:tolerance] = value }
59
+ opts.on("--render-only", "Render one frame and exit") { @options[:render_only] = true }
60
+ opts.on("--slide=X", Float, "Initial slide offset") { |value| @options[:slide] = value }
61
+ opts.on("--cursor=X", Float, "Initial cursor position") { |value| @options[:cursor] = value }
62
+ opts.on("--face=FACE", "front or back") { |value| @options[:face] = value.to_sym }
63
+ opts.on("--key-test", "Print decoded key names") { @options[:key_test] = true }
64
+ opts.on("--demo=EXPR", "Run demo for expression") { |value| @options[:demo] = value }
65
+ opts.on("--sandbox", "Start sandbox mode") { @options[:sandbox] = true }
66
+ opts.on("--drill", "Start drill mode") { @options[:drill] = true }
67
+ end
68
+ parser.parse!(@argv)
69
+ end
70
+
71
+ def print_version
72
+ @output.puts "keisanjaku #{VERSION}"
73
+ 0
74
+ end
75
+
76
+ def render_only
77
+ state = RuleState.new(slide: @options[:slide], cursor: @options[:cursor], face: @options[:face])
78
+ Renderer.render(state, width: @options[:width] || terminal_width, color: @options[:color]).each { |line| @output.puts line }
79
+ 0
80
+ end
81
+
82
+ def key_test
83
+ reader = Input.new(@input)
84
+ Input.with_raw(input: @input, output: @output) do
85
+ loop do
86
+ key = reader.read_key
87
+ Input.raw_puts(@output, key.inspect)
88
+ break if key == "q" || key == :ctrl_c
89
+ end
90
+ end
91
+ 0
92
+ end
93
+
94
+ def run_demo(expression)
95
+ mode = Modes::Demo.new(expression, width: @options[:width], color: @options[:color], animate: @options[:animate], output: @output)
96
+ if @input.tty? && @options[:animate]
97
+ mode.run_interactive(input: @input)
98
+ else
99
+ mode.run_noninteractive
100
+ end
101
+ 0
102
+ end
103
+
104
+ def run_sandbox
105
+ Modes::Sandbox.new(width: @options[:width], color: @options[:color], output: @output).run(input: @input)
106
+ 0
107
+ end
108
+
109
+ def run_drill
110
+ Modes::Drill.new(
111
+ width: @options[:width],
112
+ color: @options[:color],
113
+ tolerance_percent: @options[:tolerance],
114
+ output: @output
115
+ ).run(input: @input)
116
+ 0
117
+ end
118
+
119
+ def run_menu
120
+ return run_sandbox unless @input.tty?
121
+
122
+ @output.puts "keisanjaku"
123
+ @output.puts "1 Sandbox"
124
+ @output.puts "2 Demo"
125
+ @output.puts "3 Drill"
126
+ @output.print "> "
127
+ choice = @input.gets&.strip
128
+ case choice
129
+ when "1" then run_sandbox
130
+ when "2"
131
+ @output.print "expr> "
132
+ run_demo(@input.gets&.strip.to_s)
133
+ when "3" then run_drill
134
+ else 0
135
+ end
136
+ end
137
+
138
+ def terminal_width
139
+ IO.console&.winsize&.last || Renderer::DEFAULT_WIDTH
140
+ rescue StandardError
141
+ Renderer::DEFAULT_WIDTH
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,118 @@
1
+ require_relative "ansi"
2
+ require_relative "parser"
3
+ require_relative "planner"
4
+ require_relative "procedure"
5
+
6
+ module Keisanjaku
7
+ Question = Struct.new(:expression, :prompt, :true_value, :operation, keyword_init: true)
8
+
9
+ class QuestionGenerator
10
+ OPERATIONS = %i[multiply divide square cube sqrt log sin tan].freeze
11
+
12
+ def initialize(random: Random.new)
13
+ @random = random
14
+ end
15
+
16
+ def next_question(operation: OPERATIONS.sample(random: @random))
17
+ operation = operation.to_sym
18
+ raise ArgumentError, "unknown operation: #{operation}" unless OPERATIONS.include?(operation)
19
+
20
+ case operation
21
+ when :multiply
22
+ a = sample_mantissa
23
+ b = sample_mantissa
24
+ build("#{a}×#{b}", "#{a} × #{b} を求めよ", operation)
25
+ when :divide
26
+ a = sample_mantissa
27
+ b = sample_mantissa
28
+ build("#{a}÷#{b}", "#{a} ÷ #{b} を求めよ", operation)
29
+ when :square
30
+ a = sample_mantissa(max: 9.0)
31
+ build("#{a}^2", "#{a} の二乗を求めよ", operation)
32
+ when :cube
33
+ a = sample_mantissa(max: 9.0)
34
+ build("#{a}^3", "#{a} の三乗を求めよ", operation)
35
+ when :sqrt
36
+ a = sample_mantissa(max: 9.0)
37
+ build("sqrt(#{a})", "#{a} の平方根を求めよ", operation)
38
+ when :log
39
+ a = sample_mantissa
40
+ build("log(#{a})", "#{a} の常用対数を求めよ", operation)
41
+ when :sin
42
+ angle = @random.rand(6..89)
43
+ build("sin(#{angle})", "sin(#{angle}) を求めよ", operation)
44
+ when :tan
45
+ angle = @random.rand(6..45)
46
+ build("tan(#{angle})", "tan(#{angle}) を求めよ", operation)
47
+ else
48
+ raise ArgumentError, "unknown operation: #{operation}"
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def build(expression, prompt, operation)
55
+ ast = Parser.parse(expression)
56
+ plan = Planner.compile(ast, expression: expression)
57
+ Question.new(expression: expression, prompt: prompt, true_value: plan.true_value, operation: operation)
58
+ end
59
+
60
+ def sample_mantissa(max: 9.9)
61
+ (@random.rand(10..(max * 10).floor) / 10.0).round(1)
62
+ end
63
+ end
64
+
65
+ class Judge
66
+ def initialize(tolerance_percent: 1.0)
67
+ @tolerance_percent = Float(tolerance_percent)
68
+ end
69
+
70
+ def correct?(answer, true_value)
71
+ relative_error_percent(answer, true_value).abs <= @tolerance_percent + 1e-12
72
+ end
73
+
74
+ def relative_error_percent(answer, true_value)
75
+ ((Float(answer) - Float(true_value)) / Float(true_value)) * 100.0
76
+ end
77
+ end
78
+
79
+ class OperationSelector
80
+ LABELS = {
81
+ multiply: "乗算",
82
+ divide: "除算",
83
+ square: "二乗",
84
+ cube: "三乗",
85
+ sqrt: "平方根",
86
+ log: "対数",
87
+ sin: "正弦",
88
+ tan: "正接"
89
+ }.freeze
90
+
91
+ def self.parse(selection)
92
+ text = selection.to_s.strip
93
+ return QuestionGenerator::OPERATIONS if text.empty? || text == "all"
94
+
95
+ operations = text.split(/[,\s]+/).filter_map do |token|
96
+ operation = token.to_sym
97
+ operation if QuestionGenerator::OPERATIONS.include?(operation)
98
+ end
99
+ operations.empty? ? QuestionGenerator::OPERATIONS : operations.uniq
100
+ end
101
+
102
+ def self.menu
103
+ LABELS.map { |key, label| "#{key}=#{label}" }.join(" ")
104
+ end
105
+
106
+ def self.menu_lines(width)
107
+ LABELS.map { |key, label| "#{key}=#{label}" }.each_with_object([]) do |item, lines|
108
+ candidate = lines.empty? || lines.last.empty? ? item : "#{lines.last} #{item}"
109
+ if lines.empty? || ANSI.visible_width(candidate) > width
110
+ lines << item
111
+ else
112
+ lines[-1] = candidate
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ end
@@ -0,0 +1,98 @@
1
+ require_relative "planner"
2
+
3
+ module Keisanjaku
4
+ class Engine
5
+ attr_reader :plan, :index
6
+
7
+ def initialize(plan, initial_state: RuleState.new, animate: true)
8
+ @plan = plan
9
+ @initial_state = initial_state.dup
10
+ @history = [@initial_state.dup]
11
+ @index = 0
12
+ @animate = animate
13
+ end
14
+
15
+ def state
16
+ @history[@index]
17
+ end
18
+
19
+ def done?
20
+ @index >= plan.steps.length
21
+ end
22
+
23
+ def step_forward
24
+ return nil if done?
25
+
26
+ next_state = state.dup
27
+ step = plan.steps[@index]
28
+ self.class.apply_step(next_state, step)
29
+ @history = @history.take(@index + 1)
30
+ @history << next_state
31
+ @index += 1
32
+ step
33
+ end
34
+
35
+ def step_back
36
+ return nil if @index.zero?
37
+
38
+ @index -= 1
39
+ plan.steps[@index]
40
+ end
41
+
42
+ def run_to_end
43
+ step_forward until done?
44
+ state
45
+ end
46
+
47
+ def frames_for(step, from:, to:, count: 8)
48
+ return [to] unless @animate && movement_step?(step)
49
+
50
+ (1..count).map do |n|
51
+ ratio = n.to_f / count
52
+ RuleState.new(
53
+ slide: from.slide + (to.slide - from.slide) * ratio,
54
+ cursor: from.cursor + (to.cursor - from.cursor) * ratio,
55
+ face: to.face
56
+ )
57
+ end
58
+ end
59
+
60
+ def summary
61
+ error = if plan.true_value.zero?
62
+ 0.0
63
+ else
64
+ ((plan.read_value - plan.true_value) / plan.true_value * 100.0).abs
65
+ end
66
+ {
67
+ read_value: plan.read_value,
68
+ true_value: plan.true_value,
69
+ error_percent: error,
70
+ place_explanation: plan.place_explanation
71
+ }
72
+ end
73
+
74
+ def self.apply_step(state, step)
75
+ case step.primitive
76
+ when :move_cursor
77
+ state.move_cursor_to(step.scale, step.value)
78
+ when :move_slide_index
79
+ state.align_slide_index(step.side)
80
+ when :move_slide_to
81
+ state.align_slide_value(step.scale, step.value)
82
+ when :flip_slide
83
+ state.flip!
84
+ when :read
85
+ state
86
+ else
87
+ raise ArgumentError, "unknown step primitive: #{step.primitive}"
88
+ end
89
+ state
90
+ end
91
+
92
+ private
93
+
94
+ def movement_step?(step)
95
+ %i[move_cursor move_slide_index move_slide_to flip_slide].include?(step.primitive)
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,78 @@
1
+ require "io/console"
2
+ require_relative "ansi"
3
+
4
+ module Keisanjaku
5
+ class Input
6
+ CRLF = "\r\n"
7
+
8
+ CSI_MAP = {
9
+ "\e[A" => :up,
10
+ "\e[B" => :down,
11
+ "\e[C" => :right,
12
+ "\e[D" => :left,
13
+ "\e[H" => :home,
14
+ "\e[F" => :end,
15
+ "\e[3~" => :delete
16
+ }.freeze
17
+
18
+ SIMPLE_MAP = {
19
+ "\r" => :enter,
20
+ "\n" => :enter,
21
+ "\t" => :tab,
22
+ "\u007F" => :backspace,
23
+ "\e" => :escape,
24
+ " " => :space
25
+ }.freeze
26
+
27
+ def initialize(io = $stdin)
28
+ @io = io
29
+ end
30
+
31
+ def read_key(timeout: nil)
32
+ return nil if timeout && !@io.wait_readable(timeout)
33
+
34
+ first = @io.getch
35
+ return nil unless first
36
+ return decode_sequence(first) unless first == "\e"
37
+
38
+ sequence = first.dup
39
+ while @io.wait_readable(0.01)
40
+ sequence << @io.getch
41
+ break if CSI_MAP.key?(sequence) || sequence.match?(/\A\e\[[0-9;]*[~A-Za-z]\z/)
42
+ end
43
+ decode_sequence(sequence)
44
+ end
45
+
46
+ def self.decode_sequence(sequence)
47
+ return CSI_MAP[sequence] if CSI_MAP.key?(sequence)
48
+ return SIMPLE_MAP[sequence] if SIMPLE_MAP.key?(sequence)
49
+ return :ctrl_c if sequence == "\u0003"
50
+
51
+ sequence
52
+ end
53
+
54
+ def decode_sequence(sequence)
55
+ self.class.decode_sequence(sequence)
56
+ end
57
+
58
+ def self.with_raw(input: $stdin, output: $stdout, clear_on_exit: true)
59
+ output.print ANSI.hide_cursor
60
+ input.raw do
61
+ yield
62
+ ensure
63
+ output.print ANSI.clear_screen if clear_on_exit
64
+ output.print ANSI.show_cursor
65
+ output.flush
66
+ end
67
+ end
68
+
69
+ def self.raw_puts(output, value = "")
70
+ output.print value.to_s
71
+ output.print CRLF
72
+ end
73
+
74
+ def self.raw_print_lines(output, lines)
75
+ lines.each { |line| raw_puts(output, line) }
76
+ end
77
+ end
78
+ end