sangi 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,151 @@
1
+ module Sangi
2
+ module Renderer
3
+ class AsciiRenderer
4
+ LABEL_WIDTH = 7
5
+ CELL_WIDTH = CellRenderer::CELL_WIDTH
6
+
7
+ def initialize(config)
8
+ @config = config
9
+ @cell_renderer = CellRenderer.new(config)
10
+ end
11
+
12
+ def render(step, total_steps:)
13
+ lines = []
14
+ lines.concat(header_lines(step, total_steps))
15
+ lines << ""
16
+ lines.concat(message_lines(step))
17
+ lines << ""
18
+ lines.concat(board_lines(step.board))
19
+ lines << ""
20
+ lines.concat(value_lines(step.board))
21
+ lines << ""
22
+ lines << "keys: n next / p prev / r reset / e end / a auto / q quit / ? help"
23
+ lines.join("\n") + "\n"
24
+ end
25
+
26
+ private
27
+
28
+ def header_lines(step, total_steps)
29
+ state = step.numeric_state
30
+ [
31
+ "expr: #{state[:source]}",
32
+ "normalized: #{state[:normalized]}",
33
+ "result: #{state[:result_value]}",
34
+ "step: #{step.index}/#{total_steps}",
35
+ "mode: #{@config.mode} zero: #{@config.zero_mode} sign: #{@config.sign_mode}"
36
+ ]
37
+ end
38
+
39
+ def message_lines(step)
40
+ message = selected_message(step)
41
+ lines = ["Step #{step.index}: #{step.title}"]
42
+ lines << message if message && !message.empty?
43
+ lines
44
+ end
45
+
46
+ def selected_message(step)
47
+ case @config.explain_mode
48
+ when :none then nil
49
+ when :learn then step.learn_message || step.brief_message
50
+ else step.brief_message
51
+ end
52
+ end
53
+
54
+ def board_lines(board)
55
+ places = (0...board.place_count).to_a.reverse
56
+ lines = [place_header(places), border(places.size)]
57
+ %i[a b work].each do |row_name|
58
+ row = board.row(row_name)
59
+ row_cells = render_row_cells(row, places)
60
+ row_cells.each_with_index do |cell_line_parts, index|
61
+ label = index.zero? ? row_label(row, board.highlight) : ""
62
+ lines << format("%-#{LABEL_WIDTH}s", label) + "|" + cell_line_parts.join("|") + "|"
63
+ end
64
+ lines << border(places.size)
65
+ end
66
+ lines
67
+ end
68
+
69
+ def render_row_cells(row, places)
70
+ slash_place = slash_place_for(row)
71
+ rendered = places.map do |place|
72
+ @cell_renderer.render(
73
+ row.cells.fetch(place),
74
+ row_sign: row.sign,
75
+ slash: slash_place == place
76
+ )
77
+ end
78
+ CellRenderer::CELL_HEIGHT.times.map do |line_index|
79
+ parts = rendered.map { |cell_lines| cell_lines[line_index] }
80
+ color_negative_row?(row) ? parts.map { |part| colorize(part) } : parts
81
+ end
82
+ end
83
+
84
+ def place_header(places)
85
+ format("%-#{LABEL_WIDTH}s", "") +
86
+ places.map { |place| center(place_label(place), CELL_WIDTH) }.join(" ")
87
+ end
88
+
89
+ def border(place_count)
90
+ format("%-#{LABEL_WIDTH}s", "") + "+" + (("-" * CELL_WIDTH) + "+") * place_count
91
+ end
92
+
93
+ def row_label(row, highlight)
94
+ label = case row.name
95
+ when :a then "A"
96
+ when :b then "B"
97
+ else "Work"
98
+ end
99
+ label += "-" if show_modern_negative_marker?(row)
100
+ label += " *" if highlight && highlight[:row] == row.name
101
+ label
102
+ end
103
+
104
+ def value_lines(board)
105
+ [
106
+ "values:",
107
+ format(" A: %s", board.row(:a).value),
108
+ format(" B: %s", board.row(:b).value),
109
+ format(" Work: %s", board.row(:work).value)
110
+ ]
111
+ end
112
+
113
+ def place_label(place)
114
+ labels = %w[ones tens hundreds thousands]
115
+ labels.fetch(place, "10^#{place}")
116
+ end
117
+
118
+ def center(text, width)
119
+ text = text[0, width]
120
+ left = (width - text.length) / 2
121
+ right = width - text.length - left
122
+ (" " * left) + text + (" " * right)
123
+ end
124
+
125
+ def slash_place_for(row)
126
+ return nil unless row.sign.negative?
127
+ return nil unless %i[slash dual].include?(effective_sign_mode)
128
+
129
+ row.cells.index { |cell| cell.value.positive? }
130
+ end
131
+
132
+ def show_modern_negative_marker?(row)
133
+ return false unless row.sign.negative? && row.magnitude.positive?
134
+
135
+ %i[modern dual].include?(effective_sign_mode)
136
+ end
137
+
138
+ def color_negative_row?(row)
139
+ row.sign.negative? && row.magnitude.positive? && effective_sign_mode == :color && @config.color
140
+ end
141
+
142
+ def effective_sign_mode
143
+ @config.sign_mode == :color && !@config.color ? :modern : @config.sign_mode
144
+ end
145
+
146
+ def colorize(text)
147
+ "\e[31m#{text}\e[0m"
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,97 @@
1
+ module Sangi
2
+ module Renderer
3
+ class CellRenderer
4
+ CELL_WIDTH = 11
5
+ CELL_HEIGHT = 5
6
+
7
+ def initialize(config)
8
+ @config = config
9
+ end
10
+
11
+ def render(cell, row_sign: 1, slash: false)
12
+ lines = blank_lines
13
+ if cell.value.zero?
14
+ render_zero(lines)
15
+ elsif cell.orientation == :vertical
16
+ render_vertical(lines, cell)
17
+ else
18
+ render_horizontal(lines, cell)
19
+ end
20
+ put_char(lines, 0, 9, "/") if slash && row_sign.negative?
21
+ lines
22
+ end
23
+
24
+ private
25
+
26
+ def blank_lines
27
+ Array.new(CELL_HEIGHT) { " " * CELL_WIDTH }
28
+ end
29
+
30
+ def render_zero(lines)
31
+ case @config.zero_mode
32
+ when :digit
33
+ put_text(lines, 1, 5, "0")
34
+ when :circle
35
+ put_text(lines, 1, 5, "o")
36
+ end
37
+ end
38
+
39
+ def render_vertical(lines, cell)
40
+ cell.five_rods.size.times do |index|
41
+ put_text(lines, [index, CELL_HEIGHT - 1].min, 3, "-----")
42
+ end
43
+
44
+ unit_row = cell.five_rods.empty? ? 2 : [cell.five_rods.size, CELL_HEIGHT - 1].min
45
+ unit_positions(cell.unit_rods.size).each do |column|
46
+ put_char(lines, unit_row, column, "|")
47
+ end
48
+ end
49
+
50
+ def render_horizontal(lines, cell)
51
+ five_positions(cell.five_rods.size).each do |column|
52
+ CELL_HEIGHT.times { |row| put_char(lines, row, column, "|") }
53
+ end
54
+
55
+ start_row = cell.five_rods.empty? ? 0 : 1
56
+ cell.unit_rods.size.times do |index|
57
+ row = [start_row + index, CELL_HEIGHT - 1].min
58
+ put_text(lines, row, 3, "-----")
59
+ end
60
+ end
61
+
62
+ def unit_positions(count)
63
+ positions = {
64
+ 1 => [5],
65
+ 2 => [4, 6],
66
+ 3 => [3, 5, 7],
67
+ 4 => [2, 4, 6, 8],
68
+ 5 => [1, 3, 5, 7, 9]
69
+ }
70
+ positions.fetch([count, 5].min, [])
71
+ end
72
+
73
+ def five_positions(count)
74
+ positions = {
75
+ 1 => [5],
76
+ 2 => [4, 6]
77
+ }
78
+ positions.fetch([count, 2].min, [])
79
+ end
80
+
81
+ def put_text(lines, row, column, text)
82
+ text.chars.each_with_index do |char, offset|
83
+ put_char(lines, row, column + offset, char)
84
+ end
85
+ end
86
+
87
+ def put_char(lines, row, column, char)
88
+ return if row.negative? || row >= CELL_HEIGHT
89
+ return if column.negative? || column >= CELL_WIDTH
90
+
91
+ chars = lines[row].chars
92
+ chars[column] = char
93
+ lines[row] = chars.join
94
+ end
95
+ end
96
+ end
97
+ end
data/lib/sangi/repl.rb ADDED
@@ -0,0 +1,45 @@
1
+ module Sangi
2
+ class REPL
3
+ def initialize(config:, input: STDIN, output: STDOUT)
4
+ @config = config
5
+ @input = input
6
+ @output = output
7
+ end
8
+
9
+ def start
10
+ loop do
11
+ @output.print "sangi> "
12
+ line = @input.gets
13
+ break if line.nil?
14
+
15
+ line = line.strip
16
+ next if line.empty?
17
+ break if %w[:q :quit].include?(line)
18
+
19
+ if line == ":help"
20
+ show_help
21
+ next
22
+ end
23
+
24
+ run_expression(line)
25
+ end
26
+ 0
27
+ end
28
+
29
+ private
30
+
31
+ def run_expression(source)
32
+ expression = Parser.new(@config).parse(source)
33
+ steps = Engine.new(@config).build_steps(expression)
34
+ renderer = Renderer::AsciiRenderer.new(@config)
35
+ StepViewer.new(steps: steps, renderer: renderer, input: @input, output: @output).start
36
+ rescue Sangi::Error => e
37
+ @output.puts e.message
38
+ end
39
+
40
+ def show_help
41
+ @output.puts "式を入力すると計算を開始します。例: 128+47, 100-7, -12+7, 3--5"
42
+ @output.puts ":help ヘルプ / :q 終了 / :quit 終了"
43
+ end
44
+ end
45
+ end
data/lib/sangi/rod.rb ADDED
@@ -0,0 +1,16 @@
1
+ module Sangi
2
+ class Rod
3
+ attr_reader :id, :kind
4
+
5
+ def initialize(id:, kind:)
6
+ raise ValidationError, "invalid rod kind" unless %i[unit five].include?(kind)
7
+
8
+ @id = id
9
+ @kind = kind
10
+ end
11
+
12
+ def value
13
+ kind == :five ? 5 : 1
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,13 @@
1
+ module Sangi
2
+ class RodFactory
3
+ def initialize
4
+ @next_id = 1
5
+ end
6
+
7
+ def build(kind)
8
+ rod = Rod.new(id: @next_id, kind: kind)
9
+ @next_id += 1
10
+ rod
11
+ end
12
+ end
13
+ end
data/lib/sangi/row.rb ADDED
@@ -0,0 +1,51 @@
1
+ module Sangi
2
+ class Row
3
+ attr_reader :name
4
+ attr_accessor :sign, :cells
5
+
6
+ def self.from_integer(name:, value:, place_count:, rod_factory:)
7
+ sign = value <=> 0
8
+ digits = SignedNumber.from_integer(value.abs).digits
9
+ cells = place_count.times.map do |place|
10
+ Cell.from_digit(
11
+ place_index: place,
12
+ digit: digits.fetch(place, 0),
13
+ rod_factory: rod_factory
14
+ )
15
+ end
16
+ new(name: name, sign: sign, cells: cells)
17
+ end
18
+
19
+ def initialize(name:, sign:, cells:)
20
+ @name = name
21
+ @sign = sign
22
+ @cells = cells
23
+ normalize_zero_sign!
24
+ end
25
+
26
+ def magnitude
27
+ cells.each_with_index.sum do |cell, place_index|
28
+ cell.value * (10**place_index)
29
+ end
30
+ end
31
+
32
+ def value
33
+ current_magnitude = magnitude
34
+ return 0 if current_magnitude.zero?
35
+
36
+ sign * current_magnitude
37
+ end
38
+
39
+ def normalize_zero_sign!
40
+ @sign = 0 if magnitude.zero?
41
+ end
42
+
43
+ def clone_deep
44
+ Row.new(
45
+ name: name,
46
+ sign: sign,
47
+ cells: cells.map(&:clone_deep)
48
+ )
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,23 @@
1
+ module Sangi
2
+ class SignedNumber
3
+ attr_reader :sign, :digits
4
+
5
+ def self.from_integer(value)
6
+ sign = value <=> 0
7
+ digits = value.abs.to_s.chars.reverse.map(&:to_i)
8
+ digits = [0] if digits.empty?
9
+ new(sign: sign, digits: digits)
10
+ end
11
+
12
+ def initialize(sign:, digits:)
13
+ @digits = digits.empty? ? [0] : digits
14
+ @sign = @digits.all?(&:zero?) ? 0 : sign
15
+ end
16
+
17
+ def to_i
18
+ return 0 if sign.zero?
19
+
20
+ sign * digits.reverse.join.to_i
21
+ end
22
+ end
23
+ end
data/lib/sangi/step.rb ADDED
@@ -0,0 +1,16 @@
1
+ module Sangi
2
+ class Step
3
+ attr_reader :index, :title, :brief_message, :learn_message
4
+ attr_reader :event, :board, :numeric_state
5
+
6
+ def initialize(index:, title:, brief_message:, learn_message:, event:, board:, numeric_state:)
7
+ @index = index
8
+ @title = title
9
+ @brief_message = brief_message
10
+ @learn_message = learn_message
11
+ @event = event
12
+ @board = board
13
+ @numeric_state = numeric_state
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,87 @@
1
+ module Sangi
2
+ class StepBuilder
3
+ attr_reader :config, :expression, :board, :result_value, :rod_factory, :steps
4
+
5
+ def initialize(config:, expression:, board:, result_value:, rod_factory:)
6
+ @config = config
7
+ @expression = expression
8
+ @board = board
9
+ @result_value = result_value
10
+ @rod_factory = rod_factory
11
+ @steps = []
12
+ end
13
+
14
+ def push(title:, brief_message:, event:, learn_message: nil)
15
+ steps << Step.new(
16
+ index: steps.size + 1,
17
+ title: title,
18
+ brief_message: brief_message,
19
+ learn_message: learn_message || brief_message,
20
+ event: event,
21
+ board: board.clone_deep,
22
+ numeric_state: numeric_state
23
+ )
24
+ end
25
+
26
+ def place_rod(row:, place:, kind:, title: "棒を置く", brief_message: nil, learn_message: nil, event_type: :place_rod)
27
+ cell = work_cell(row, place)
28
+ cell.add_rod(rod_factory.build(kind))
29
+ board.highlight = { row: row, place_index: place }
30
+ push(
31
+ title: title,
32
+ brief_message: brief_message || "#{row_label(row)}の#{place_name(place)}に#{rod_name(kind)}棒を1本置きます。",
33
+ learn_message: learn_message || "#{place_name(place)}に#{rod_value_name(kind)}を表す棒を1本追加します。",
34
+ event: Event.new(type: event_type, row: row, place_index: place, kind: kind)
35
+ )
36
+ end
37
+
38
+ def remove_rod(row:, place:, kind:, title: "棒を取り除く", brief_message: nil, learn_message: nil, event_type: :remove_rod)
39
+ cell = work_cell(row, place)
40
+ removed = cell.remove_rod(kind)
41
+ raise InternalError, "#{row_label(row)}の#{place_name(place)}に#{rod_name(kind)}棒がありません。" if removed.nil?
42
+
43
+ board.highlight = { row: row, place_index: place }
44
+ push(
45
+ title: title,
46
+ brief_message: brief_message || "#{row_label(row)}の#{place_name(place)}から#{rod_name(kind)}棒を1本取り除きます。",
47
+ learn_message: learn_message || "#{place_name(place)}から#{rod_value_name(kind)}を表す棒を1本取り除きます。",
48
+ event: Event.new(type: event_type, row: row, place_index: place, kind: kind)
49
+ )
50
+ end
51
+
52
+ def row_label(row)
53
+ row == :work ? "Work" : row.to_s.upcase
54
+ end
55
+
56
+ def rod_name(kind)
57
+ kind == :five ? "five" : "unit"
58
+ end
59
+
60
+ def rod_value_name(kind)
61
+ kind == :five ? "5" : "1"
62
+ end
63
+
64
+ def place_name(place)
65
+ names = %w[一の位 十の位 百の位 千の位 万の位 十万の位 百万の位 千万の位 一億の位 十億の位]
66
+ names.fetch(place, "10^#{place}の位")
67
+ end
68
+
69
+ private
70
+
71
+ def work_cell(row, place)
72
+ board.row(row).cells.fetch(place)
73
+ end
74
+
75
+ def numeric_state
76
+ {
77
+ source: expression.source,
78
+ normalized: expression.normalized_source,
79
+ result_value: result_value,
80
+ left: expression.left,
81
+ right: expression.right,
82
+ effective_right: expression.effective_right,
83
+ work_value: board.row(:work).value
84
+ }
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,124 @@
1
+ require "io/console"
2
+ require "io/wait"
3
+
4
+ module Sangi
5
+ class StepViewer
6
+ AUTOPLAY_INTERVAL = 0.35
7
+
8
+ def initialize(steps:, renderer:, input: STDIN, output: STDOUT, clear_screen: true)
9
+ @steps = steps
10
+ @renderer = renderer
11
+ @input = input
12
+ @output = output
13
+ @clear_screen = clear_screen
14
+ @current_index = 0
15
+ @width_warning = TerminalWidthWarning.new(output: output)
16
+ end
17
+
18
+ def start
19
+ return if @steps.empty?
20
+
21
+ @exit_requested = false
22
+ loop do
23
+ render_current
24
+ handle_key(read_key)
25
+ break if @exit_requested
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def render_current
32
+ clear
33
+ warning = @width_warning.message(@steps)
34
+ @output.puts warning if warning
35
+ @output.print @renderer.render(@steps[@current_index], total_steps: @steps.size)
36
+ end
37
+
38
+ def next_step
39
+ @current_index = [@current_index + 1, @steps.size - 1].min
40
+ end
41
+
42
+ def previous_step
43
+ @current_index = [@current_index - 1, 0].max
44
+ end
45
+
46
+ def handle_key(key)
47
+ case key
48
+ when "n", "\r", "\n"
49
+ next_step
50
+ when "p"
51
+ previous_step
52
+ when "r"
53
+ @current_index = 0
54
+ when "e"
55
+ @current_index = @steps.size - 1
56
+ when "a"
57
+ autoplay
58
+ when "?"
59
+ show_key_help
60
+ when "q", "\u0003"
61
+ @exit_requested = true
62
+ end
63
+ end
64
+
65
+ def autoplay
66
+ loop do
67
+ key = read_key_with_timeout(AUTOPLAY_INTERVAL)
68
+ break if handle_autoplay_key(key)
69
+ break if @current_index >= @steps.size - 1
70
+
71
+ @current_index += 1
72
+ render_current
73
+ end
74
+ end
75
+
76
+ def handle_autoplay_key(key)
77
+ case key
78
+ when nil
79
+ false
80
+ when "a"
81
+ true
82
+ when "q", "\u0003"
83
+ @exit_requested = true
84
+ true
85
+ else
86
+ handle_key(key)
87
+ true
88
+ end
89
+ end
90
+
91
+ def show_key_help
92
+ @output.puts
93
+ @output.puts "n/Enter: next, p: previous, r: reset, e: end, a: auto, q: quit"
94
+ @output.puts "press any key to continue"
95
+ read_key
96
+ end
97
+
98
+ def read_key
99
+ return @input.getch if @input.is_a?(IO) && @input.respond_to?(:getch)
100
+
101
+ @input.gets&.chars&.first || "q"
102
+ end
103
+
104
+ def read_key_with_timeout(timeout)
105
+ return read_key if buffered_input_available?
106
+ return nil unless @input.is_a?(IO) && @input.respond_to?(:wait_readable)
107
+ return nil unless @input.wait_readable(timeout)
108
+
109
+ read_key
110
+ end
111
+
112
+ def buffered_input_available?
113
+ @input.respond_to?(:pos) &&
114
+ @input.respond_to?(:string) &&
115
+ @input.pos < @input.string.length
116
+ end
117
+
118
+ def clear
119
+ return unless @clear_screen
120
+
121
+ @output.print "\e[2J\e[H"
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,56 @@
1
+ require "io/console"
2
+
3
+ module Sangi
4
+ class TerminalWidthWarning
5
+ def initialize(output:)
6
+ @output = output
7
+ end
8
+
9
+ def message(steps)
10
+ width = terminal_width
11
+ return nil if width.nil? || steps.empty?
12
+
13
+ required = required_width(steps)
14
+ return nil if required <= width
15
+
16
+ "warning: terminal width is #{width}, but this board needs about #{required} columns; output may wrap."
17
+ end
18
+
19
+ def warn_once(steps, error_output:)
20
+ warning = message(steps)
21
+ error_output.puts warning if warning
22
+ end
23
+
24
+ private
25
+
26
+ def terminal_width
27
+ return nil unless @output.respond_to?(:tty?) && @output.tty?
28
+
29
+ width_from_output || width_from_console
30
+ end
31
+
32
+ def width_from_output
33
+ return nil unless @output.respond_to?(:winsize)
34
+
35
+ @output.winsize[1]
36
+ rescue SystemCallError
37
+ nil
38
+ end
39
+
40
+ def width_from_console
41
+ console = IO.console
42
+ return nil unless console
43
+
44
+ console.winsize[1]
45
+ rescue SystemCallError
46
+ nil
47
+ end
48
+
49
+ def required_width(steps)
50
+ max_places = steps.map { |step| step.board.place_count }.max || 0
51
+ Renderer::AsciiRenderer::LABEL_WIDTH +
52
+ (Renderer::CellRenderer::CELL_WIDTH + 1) * max_places +
53
+ 1
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,3 @@
1
+ module Sangi
2
+ VERSION = "0.1.0"
3
+ end