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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +126 -0
- data/exe/keisanjaku +6 -0
- data/lib/keisanjaku/ansi.rb +79 -0
- data/lib/keisanjaku/app.rb +144 -0
- data/lib/keisanjaku/drill.rb +118 -0
- data/lib/keisanjaku/engine.rb +98 -0
- data/lib/keisanjaku/input.rb +78 -0
- data/lib/keisanjaku/modes/demo.rb +142 -0
- data/lib/keisanjaku/modes/drill.rb +127 -0
- data/lib/keisanjaku/modes/sandbox.rb +159 -0
- data/lib/keisanjaku/parser.rb +185 -0
- data/lib/keisanjaku/planner.rb +306 -0
- data/lib/keisanjaku/procedure.rb +108 -0
- data/lib/keisanjaku/renderer.rb +142 -0
- data/lib/keisanjaku/rule_state.rb +98 -0
- data/lib/keisanjaku/scale.rb +205 -0
- data/lib/keisanjaku/terminal_viewport.rb +107 -0
- data/lib/keisanjaku/version.rb +3 -0
- data/lib/keisanjaku.rb +13 -0
- metadata +64 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
require_relative "parser"
|
|
2
|
+
require_relative "rule_state"
|
|
3
|
+
|
|
4
|
+
module Keisanjaku
|
|
5
|
+
Step = Struct.new(:primitive, :scale, :value, :side, :description, keyword_init: true) do
|
|
6
|
+
def label
|
|
7
|
+
parts = [primitive]
|
|
8
|
+
parts << scale if scale
|
|
9
|
+
parts << value if value
|
|
10
|
+
parts << side if side
|
|
11
|
+
parts.join(" ")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
Plan = Struct.new(
|
|
16
|
+
:expression,
|
|
17
|
+
:steps,
|
|
18
|
+
:true_value,
|
|
19
|
+
:read_scale,
|
|
20
|
+
:read_value,
|
|
21
|
+
:place_explanation,
|
|
22
|
+
keyword_init: true
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
class Planner
|
|
26
|
+
def initialize(ast, expression: nil)
|
|
27
|
+
@ast = ast
|
|
28
|
+
@expression = expression
|
|
29
|
+
@state = RuleState.new
|
|
30
|
+
@steps = []
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def compile
|
|
34
|
+
terms, operations = flatten(@ast)
|
|
35
|
+
current_value = term_value(terms.first)
|
|
36
|
+
emit_initial_term(terms.first, chained: !operations.empty?)
|
|
37
|
+
if operations.any?
|
|
38
|
+
ensure_front if @state.face == :back
|
|
39
|
+
ensure_cursor_on_d(current_value) unless cursor_matches_d?(current_value)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
operations.each do |operator, term|
|
|
43
|
+
factor = term_value(term)
|
|
44
|
+
ensure_front
|
|
45
|
+
if operator == :multiply
|
|
46
|
+
emit_multiply(current_value, factor, term)
|
|
47
|
+
current_value *= factor
|
|
48
|
+
else
|
|
49
|
+
emit_divide(current_value, factor, term)
|
|
50
|
+
current_value /= factor
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
emit_final_read(current_value, operations.empty? ? terms.first : nil)
|
|
55
|
+
Plan.new(
|
|
56
|
+
expression: @expression,
|
|
57
|
+
steps: @steps,
|
|
58
|
+
true_value: current_value,
|
|
59
|
+
read_scale: final_read_scale(operations.empty? ? terms.first : nil),
|
|
60
|
+
read_value: current_value,
|
|
61
|
+
place_explanation: place_explanation(current_value)
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.compile(ast, expression: nil)
|
|
66
|
+
new(ast, expression: expression).compile
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def flatten(node)
|
|
72
|
+
return [[node], []] unless node.is_a?(BinaryNode)
|
|
73
|
+
|
|
74
|
+
terms, operations = flatten(node.left)
|
|
75
|
+
terms << node.right
|
|
76
|
+
operations << [node.operator, node.right]
|
|
77
|
+
[terms, operations]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def term_value(node)
|
|
81
|
+
case node
|
|
82
|
+
when NumberNode
|
|
83
|
+
node.value
|
|
84
|
+
when PowerNode
|
|
85
|
+
node.base**node.power
|
|
86
|
+
when FunctionNode
|
|
87
|
+
function_value(node)
|
|
88
|
+
else
|
|
89
|
+
raise ArgumentError, "unknown AST node: #{node.inspect}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def function_value(node)
|
|
94
|
+
case node.name
|
|
95
|
+
when :sqrt then Math.sqrt(node.argument)
|
|
96
|
+
when :cbrt then node.argument**(1.0 / 3.0)
|
|
97
|
+
when :sin then Math.sin(node.argument * Scale::DEGREE)
|
|
98
|
+
when :tan then Math.tan(node.argument * Scale::DEGREE)
|
|
99
|
+
when :log then Math.log10(node.argument)
|
|
100
|
+
else
|
|
101
|
+
raise ArgumentError, "unknown function: #{node.name}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def emit_initial_term(node, chained:)
|
|
106
|
+
case node
|
|
107
|
+
when NumberNode
|
|
108
|
+
add_move_cursor("D", mantissa(node.value), "カーソルを D 尺の #{display_number(node.value)} に合わせます")
|
|
109
|
+
when PowerNode
|
|
110
|
+
add_move_cursor("D", mantissa(node.base), "カーソルを D 尺の #{display_number(node.base)} に合わせます")
|
|
111
|
+
add_read("A", "#{display_number(node.base)}^2 を A 尺で読み取ります") if node.power == 2
|
|
112
|
+
add_read("K", "#{display_number(node.base)}^3 を K 尺で読み取ります") if node.power == 3
|
|
113
|
+
when FunctionNode
|
|
114
|
+
emit_function_term(node)
|
|
115
|
+
end
|
|
116
|
+
ensure_cursor_on_d(term_value(node)) if chained
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def emit_function_term(node)
|
|
120
|
+
case node.name
|
|
121
|
+
when :sqrt
|
|
122
|
+
add_move_cursor("A", normalized_power_value(node.argument, 2), "カーソルを A 尺の #{display_number(node.argument)} に合わせます")
|
|
123
|
+
add_read("D", "D 尺で平方根を読み取ります")
|
|
124
|
+
when :cbrt
|
|
125
|
+
add_move_cursor("K", normalized_power_value(node.argument, 3), "カーソルを K 尺の #{display_number(node.argument)} に合わせます")
|
|
126
|
+
add_read("D", "D 尺で立方根を読み取ります")
|
|
127
|
+
when :sin
|
|
128
|
+
ensure_back
|
|
129
|
+
add_move_cursor("S", node.argument, "#{source_label(node)} を S 尺に合わせます")
|
|
130
|
+
add_read("D", "D 尺で正弦の仮数を読み取ります")
|
|
131
|
+
when :tan
|
|
132
|
+
ensure_back
|
|
133
|
+
add_move_cursor("T", node.argument, "#{source_label(node)} を T 尺に合わせます")
|
|
134
|
+
add_read("D", "D 尺で正接の仮数を読み取ります")
|
|
135
|
+
when :log
|
|
136
|
+
add_move_cursor("D", mantissa(node.argument), "カーソルを D 尺の #{display_number(node.argument)} に合わせます")
|
|
137
|
+
add_read("L", "L 尺で常用対数を読み取ります")
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def emit_multiply(current_value, factor, term)
|
|
142
|
+
current_pos = d_position(current_value)
|
|
143
|
+
factor_mantissa = mantissa(factor)
|
|
144
|
+
factor_pos = Scale.fetch("C").position(factor_mantissa)
|
|
145
|
+
side, target = choose_multiply_side(current_pos, factor_pos)
|
|
146
|
+
add_move_slide_index(side, multiply_index_description(side, current_value))
|
|
147
|
+
add_move_cursor("C", factor_mantissa, "#{term_label(term)} を C 尺でカーソルに合わせます")
|
|
148
|
+
@state.cursor = target
|
|
149
|
+
add_read("D", "D 尺で積の仮数を読み取ります")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def emit_divide(current_value, factor, term)
|
|
153
|
+
factor_mantissa = mantissa(factor)
|
|
154
|
+
add_move_slide_to("C", factor_mantissa, "#{term_label(term)} を C 尺で現在のカーソルに合わせます")
|
|
155
|
+
side, target = choose_division_side(@state.slide)
|
|
156
|
+
add_move_cursor("C", side == :left ? 1.0 : 10.0, "C 尺の#{side == :left ? '左' : '右'}基線へカーソルを動かします")
|
|
157
|
+
@state.cursor = target
|
|
158
|
+
add_read("D", "D 尺で商の仮数を読み取ります")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def choose_multiply_side(current_pos, factor_pos)
|
|
162
|
+
left_target = current_pos + factor_pos
|
|
163
|
+
return [:left, left_target] if unit?(left_target)
|
|
164
|
+
|
|
165
|
+
right_target = current_pos - 1.0 + factor_pos
|
|
166
|
+
return [:right, right_target] if unit?(right_target)
|
|
167
|
+
|
|
168
|
+
raise ScaleError, "no visible slide index can multiply at #{current_pos}"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def choose_division_side(slide)
|
|
172
|
+
left_target = slide
|
|
173
|
+
return [:left, left_target] if unit?(left_target)
|
|
174
|
+
|
|
175
|
+
right_target = slide + 1.0
|
|
176
|
+
return [:right, right_target] if unit?(right_target)
|
|
177
|
+
|
|
178
|
+
raise ScaleError, "no visible slide index can divide at slide #{slide}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def multiply_index_description(side, current_value)
|
|
182
|
+
if side == :left
|
|
183
|
+
"滑尺の左基線(1)を現在値 #{display_number(current_value)} に合わせます"
|
|
184
|
+
else
|
|
185
|
+
"左基線では枠外に出るため、滑尺の右基線(10)を使って折り返します"
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def emit_final_read(value, single_term)
|
|
190
|
+
scale = final_read_scale(single_term)
|
|
191
|
+
return if @steps.last&.primitive == :read && @steps.last.scale.to_s == scale.to_s
|
|
192
|
+
|
|
193
|
+
ensure_cursor_on_d(value) if scale == "D" && !cursor_matches_d?(value)
|
|
194
|
+
add_read(scale, "最終結果を #{scale} 尺で読み取ります")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def final_read_scale(single_term)
|
|
198
|
+
case single_term
|
|
199
|
+
when PowerNode
|
|
200
|
+
single_term.power == 2 ? "A" : "K"
|
|
201
|
+
when FunctionNode
|
|
202
|
+
single_term.name == :log ? "L" : "D"
|
|
203
|
+
else
|
|
204
|
+
"D"
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def ensure_cursor_on_d(value)
|
|
209
|
+
return if cursor_matches_d?(value)
|
|
210
|
+
|
|
211
|
+
add_move_cursor("D", mantissa(value), "中間結果の仮数 #{display_number(mantissa(value))} を D 尺に移します")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def cursor_matches_d?(value)
|
|
215
|
+
(@state.cursor - d_position(value)).abs < 1e-9
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def ensure_front
|
|
219
|
+
add_flip("滑尺を表面(C/CI/B)に戻します") if @state.face == :back
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def ensure_back
|
|
223
|
+
add_flip("滑尺を裏面(S/T)に切り替えます") if @state.face == :front
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def add_move_cursor(scale, value, description)
|
|
227
|
+
add_step(Step.new(primitive: :move_cursor, scale: scale, value: value, description: description))
|
|
228
|
+
@state.move_cursor_to(scale, value)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def add_move_slide_index(side, description)
|
|
232
|
+
add_step(Step.new(primitive: :move_slide_index, side: side, description: description))
|
|
233
|
+
@state.align_slide_index(side)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def add_move_slide_to(scale, value, description)
|
|
237
|
+
add_step(Step.new(primitive: :move_slide_to, scale: scale, value: value, description: description))
|
|
238
|
+
@state.align_slide_value(scale, value)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def add_flip(description)
|
|
242
|
+
add_step(Step.new(primitive: :flip_slide, description: description))
|
|
243
|
+
@state.flip!
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def add_read(scale, description)
|
|
247
|
+
add_step(Step.new(primitive: :read, scale: scale, description: description))
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def add_step(step)
|
|
251
|
+
@steps << step
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def mantissa(value)
|
|
255
|
+
numeric = Float(value)
|
|
256
|
+
exponent = Math.log10(numeric).floor
|
|
257
|
+
mantissa = numeric / (10.0**exponent)
|
|
258
|
+
return 1.0 if (mantissa - 10.0).abs < 1e-12
|
|
259
|
+
|
|
260
|
+
mantissa
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def normalized_power_value(value, power)
|
|
264
|
+
max = 10.0**power
|
|
265
|
+
numeric = Float(value)
|
|
266
|
+
numeric /= max while numeric > max
|
|
267
|
+
numeric *= max while numeric < 1.0
|
|
268
|
+
numeric
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def d_position(value)
|
|
272
|
+
Scale.fetch("D").position(mantissa(value))
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def unit?(value)
|
|
276
|
+
value >= -1e-12 && value <= 1.0 + 1e-12
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def source_label(node)
|
|
280
|
+
if node.source_name == :cos
|
|
281
|
+
"cos(#{display_number(node.source_argument)})=sin(#{display_number(node.argument)})"
|
|
282
|
+
else
|
|
283
|
+
"#{node.source_name}(#{display_number(node.source_argument)})"
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def term_label(node)
|
|
288
|
+
case node
|
|
289
|
+
when NumberNode then display_number(node.value)
|
|
290
|
+
when PowerNode then "#{display_number(node.base)}^#{node.power}"
|
|
291
|
+
when FunctionNode then source_label(node)
|
|
292
|
+
else node.to_s
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def display_number(value)
|
|
297
|
+
format("%.6g", value)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def place_explanation(value)
|
|
301
|
+
exponent = Math.log10(value).floor
|
|
302
|
+
mant = value / (10.0**exponent)
|
|
303
|
+
"読み取り仮数 #{format('%.4g', mant)} に 10^#{exponent} を掛けて #{format('%.6g', value)} とします"
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
require_relative "planner"
|
|
2
|
+
|
|
3
|
+
module Keisanjaku
|
|
4
|
+
ProcedureAttempt = Struct.new(:actions, :used_model, :answer, keyword_init: true)
|
|
5
|
+
|
|
6
|
+
ProcedureEvent = Struct.new(:primitive, :scale, :value, :side, keyword_init: true) do
|
|
7
|
+
def self.from_step(step)
|
|
8
|
+
new(
|
|
9
|
+
primitive: step.primitive,
|
|
10
|
+
scale: step.scale&.to_s,
|
|
11
|
+
value: step.value,
|
|
12
|
+
side: step.side
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class ProcedureCapture
|
|
18
|
+
POSITION_TOLERANCE = 0.002
|
|
19
|
+
|
|
20
|
+
attr_reader :events
|
|
21
|
+
|
|
22
|
+
def initialize(plan)
|
|
23
|
+
@steps = plan&.steps || []
|
|
24
|
+
@index = 0
|
|
25
|
+
@events = []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def record_satisfied!(state, trigger: nil)
|
|
29
|
+
loop do
|
|
30
|
+
step = @steps[@index]
|
|
31
|
+
break unless step_satisfied?(step, state, trigger)
|
|
32
|
+
|
|
33
|
+
@events << ProcedureEvent.from_step(step)
|
|
34
|
+
@index += 1
|
|
35
|
+
trigger = nil
|
|
36
|
+
end
|
|
37
|
+
events
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def step_satisfied?(step, state, trigger)
|
|
43
|
+
return false unless step
|
|
44
|
+
|
|
45
|
+
case step.primitive
|
|
46
|
+
when :move_cursor
|
|
47
|
+
close?(state.cursor, state.board_position(step.scale, step.value))
|
|
48
|
+
when :move_slide_index
|
|
49
|
+
close?(state.slide, state.cursor - index_position(step.side))
|
|
50
|
+
when :move_slide_to
|
|
51
|
+
close?(state.slide, state.cursor - Scale.fetch(step.scale).position(step.value))
|
|
52
|
+
when :flip_slide
|
|
53
|
+
trigger == :flip
|
|
54
|
+
when :read
|
|
55
|
+
trigger == :read
|
|
56
|
+
else
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def index_position(side)
|
|
62
|
+
side == :right ? 1.0 : 0.0
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def close?(actual, expected)
|
|
66
|
+
(actual - expected).abs <= POSITION_TOLERANCE
|
|
67
|
+
rescue ScaleError
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
class ProcedureChecker
|
|
73
|
+
VALUE_TOLERANCE = 1e-9
|
|
74
|
+
|
|
75
|
+
def matches?(plan, attempt)
|
|
76
|
+
return false if attempt.used_model
|
|
77
|
+
|
|
78
|
+
expected = expected_events(plan)
|
|
79
|
+
observed = observed_events(attempt)
|
|
80
|
+
expected.length == observed.length &&
|
|
81
|
+
expected.zip(observed).all? { |expected_event, observed_event| event_matches?(expected_event, observed_event) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def expected_events(plan)
|
|
85
|
+
plan.steps.map { |step| ProcedureEvent.from_step(step) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def observed_events(attempt)
|
|
89
|
+
attempt.actions.filter { |action| action.is_a?(ProcedureEvent) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def event_matches?(expected, observed)
|
|
95
|
+
expected.primitive == observed.primitive &&
|
|
96
|
+
expected.scale == observed.scale &&
|
|
97
|
+
expected.side == observed.side &&
|
|
98
|
+
values_match?(expected.value, observed.value)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def values_match?(expected, observed)
|
|
102
|
+
return true if expected.nil? && observed.nil?
|
|
103
|
+
return false if expected.nil? || observed.nil?
|
|
104
|
+
|
|
105
|
+
(expected.to_f - observed.to_f).abs <= VALUE_TOLERANCE
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
require_relative "ansi"
|
|
2
|
+
require_relative "rule_state"
|
|
3
|
+
|
|
4
|
+
module Keisanjaku
|
|
5
|
+
class Renderer
|
|
6
|
+
MIN_WIDTH = 60
|
|
7
|
+
DEFAULT_WIDTH = 100
|
|
8
|
+
LABEL_WIDTH = 6
|
|
9
|
+
|
|
10
|
+
def initialize(color: true)
|
|
11
|
+
@color = color
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def render(state, width: DEFAULT_WIDTH, message: nil, highlight_scale: nil)
|
|
15
|
+
width = [width.to_i, MIN_WIDTH].max
|
|
16
|
+
rail_width = width - LABEL_WIDTH - 2
|
|
17
|
+
cursor_col = column_for(state.cursor, rail_width)
|
|
18
|
+
highlight_name = highlight_scale&.to_s&.upcase
|
|
19
|
+
lines = []
|
|
20
|
+
lines << border_line(rail_width, :top)
|
|
21
|
+
lines.concat(render_scale("K", state, rail_width, cursor_col, highlight_name))
|
|
22
|
+
lines.concat(render_scale("A", state, rail_width, cursor_col, highlight_name))
|
|
23
|
+
lines << border_line(rail_width, :divider)
|
|
24
|
+
slide_names = state.face == :front ? %w[B CI C] : %w[S T]
|
|
25
|
+
slide_names.each { |name| lines.concat(render_scale(name, state, rail_width, cursor_col, highlight_name)) }
|
|
26
|
+
lines << border_line(rail_width, :divider)
|
|
27
|
+
lines.concat(render_scale("D", state, rail_width, cursor_col, highlight_name))
|
|
28
|
+
lines.concat(render_scale("L", state, rail_width, cursor_col, highlight_name))
|
|
29
|
+
lines << border_line(rail_width, :bottom)
|
|
30
|
+
lines << status_line(state, width, highlight_name)
|
|
31
|
+
lines << ANSI.fit(message.to_s, width) if message
|
|
32
|
+
lines.map { |line| ANSI.fit(line, width) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.render(state, width: DEFAULT_WIDTH, color: true, message: nil, highlight_scale: nil)
|
|
36
|
+
new(color: color).render(state, width: width, message: message, highlight_scale: highlight_scale)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def border_line(rail_width, kind)
|
|
42
|
+
left = kind == :top || kind == :bottom ? "+" : "+"
|
|
43
|
+
right = "+"
|
|
44
|
+
label = " " * LABEL_WIDTH
|
|
45
|
+
"#{label}#{left}#{'-' * rail_width}#{right}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def render_scale(name, state, rail_width, cursor_col, highlight_name)
|
|
49
|
+
scale = Scale.fetch(name)
|
|
50
|
+
slide_scale = state.slide_scale?(name)
|
|
51
|
+
tick_row = base_row(rail_width, slide_scale, state.slide)
|
|
52
|
+
label_row = base_row(rail_width, slide_scale, state.slide)
|
|
53
|
+
highlight_col = highlight_name == name ? cursor_col : nil
|
|
54
|
+
|
|
55
|
+
scale.ticks(rail_width).each do |tick|
|
|
56
|
+
board_pos = slide_scale ? tick[:col].to_f / (rail_width - 1) + state.slide : tick[:col].to_f / (rail_width - 1)
|
|
57
|
+
next unless board_pos >= 0.0 && board_pos <= 1.0
|
|
58
|
+
|
|
59
|
+
col = column_for(board_pos, rail_width)
|
|
60
|
+
tick_row[col] = tick_mark(tick[:level])
|
|
61
|
+
write_label(label_row, col, tick[:label]) if tick[:label]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
draw_cursor(tick_row, cursor_col)
|
|
65
|
+
draw_cursor(label_row, cursor_col)
|
|
66
|
+
label = name.rjust(LABEL_WIDTH)
|
|
67
|
+
[
|
|
68
|
+
"#{label}|#{join_row(tick_row, slide_scale, state.slide, rail_width, highlight_col)}|",
|
|
69
|
+
"#{' ' * LABEL_WIDTH}|#{join_row(label_row, slide_scale, state.slide, rail_width, highlight_col)}|"
|
|
70
|
+
]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def base_row(rail_width, slide_scale, slide)
|
|
74
|
+
return Array.new(rail_width, " ") unless slide_scale
|
|
75
|
+
|
|
76
|
+
Array.new(rail_width) do |index|
|
|
77
|
+
board_pos = index.to_f / (rail_width - 1)
|
|
78
|
+
board_pos >= slide && board_pos <= slide + 1.0 ? "." : " "
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def tick_mark(level)
|
|
83
|
+
case level
|
|
84
|
+
when 0 then "|"
|
|
85
|
+
when 1 then ":"
|
|
86
|
+
else "'"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def write_label(row, col, label)
|
|
91
|
+
start = [[col - (label.length / 2), 0].max, row.length - label.length].min
|
|
92
|
+
label.each_char.with_index { |char, index| row[start + index] = char }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def draw_cursor(row, cursor_col)
|
|
96
|
+
row[cursor_col] = ANSI.paint("|", :red, color: @color)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def join_row(row, slide_scale, slide, rail_width, highlight_col)
|
|
100
|
+
row.each_with_index.map do |cell, index|
|
|
101
|
+
highlighted = highlight_col == index
|
|
102
|
+
inside_slide = slide_scale && inside_slide?(index, slide, rail_width)
|
|
103
|
+
decorate_cell(cell, inside_slide, highlighted)
|
|
104
|
+
end.join
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def decorate_cell(cell, inside_slide, highlighted)
|
|
108
|
+
return ANSI.paint(cell, :yellow, :bold, color: @color) if highlighted
|
|
109
|
+
return cell unless inside_slide
|
|
110
|
+
return cell unless @color
|
|
111
|
+
|
|
112
|
+
display = cell == "." ? " " : cell
|
|
113
|
+
ANSI.paint(display, :cyan_bg, color: @color)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def inside_slide?(index, slide, rail_width)
|
|
117
|
+
board_pos = index.to_f / (rail_width - 1)
|
|
118
|
+
board_pos >= slide && board_pos <= slide + 1.0
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def column_for(pos, rail_width)
|
|
122
|
+
(pos.clamp(0.0, 1.0) * (rail_width - 1)).round
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def status_line(state, width, highlight_name)
|
|
126
|
+
values = state.visible_scale_names.filter_map do |name|
|
|
127
|
+
value = state.read(name)
|
|
128
|
+
next unless value
|
|
129
|
+
|
|
130
|
+
text = "#{name}=#{format_value(value)}"
|
|
131
|
+
name == highlight_name ? ANSI.paint(text, :yellow, :bold, color: @color) : text
|
|
132
|
+
end
|
|
133
|
+
ANSI.fit("cursor=#{format('%.4f', state.cursor)} slide=#{format('%+.4f', state.slide)} face=#{state.face} #{values.join(' ')}", width)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def format_value(value)
|
|
137
|
+
return "--" unless value
|
|
138
|
+
|
|
139
|
+
format("%.5g", value)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
require_relative "scale"
|
|
2
|
+
|
|
3
|
+
module Keisanjaku
|
|
4
|
+
class RuleState
|
|
5
|
+
FRONT_SLIDE_SCALES = %w[B CI C].freeze
|
|
6
|
+
BACK_SLIDE_SCALES = %w[S T].freeze
|
|
7
|
+
FIXED_SCALES = %w[K A D L].freeze
|
|
8
|
+
SLIDE_SCALES = (FRONT_SLIDE_SCALES + BACK_SLIDE_SCALES).freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :slide, :cursor, :face
|
|
11
|
+
|
|
12
|
+
def initialize(slide: 0.0, cursor: 0.0, face: :front)
|
|
13
|
+
self.slide = slide
|
|
14
|
+
self.cursor = cursor
|
|
15
|
+
self.face = face
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def slide=(value)
|
|
19
|
+
@slide = Float(value).clamp(-1.0, 1.0)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def cursor=(value)
|
|
23
|
+
@cursor = Float(value).clamp(0.0, 1.0)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def face=(value)
|
|
27
|
+
symbol = value.to_sym
|
|
28
|
+
raise ArgumentError, "face must be :front or :back" unless %i[front back].include?(symbol)
|
|
29
|
+
|
|
30
|
+
@face = symbol
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def dup
|
|
34
|
+
self.class.new(slide: slide, cursor: cursor, face: face)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def read(scale_name)
|
|
38
|
+
scale = Scale.fetch(scale_name)
|
|
39
|
+
pos = local_position_for(scale.name)
|
|
40
|
+
return nil unless pos && pos >= -1e-12 && pos <= 1.0 + 1e-12
|
|
41
|
+
|
|
42
|
+
scale.value_at(pos.clamp(0.0, 1.0))
|
|
43
|
+
rescue ScaleError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def visible_scale_names
|
|
48
|
+
face == :front ? %w[K A B CI C D L] : %w[K A S T D L]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def board_position(scale_name, value)
|
|
52
|
+
scale = Scale.fetch(scale_name)
|
|
53
|
+
local = scale.position(value)
|
|
54
|
+
slide_scale?(scale.name) ? local + slide : local
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def move_cursor_to(scale_name, value)
|
|
58
|
+
self.cursor = board_position(scale_name, value)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def align_slide_index(side)
|
|
62
|
+
self.slide = cursor - index_position(side)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def align_slide_value(scale_name, value)
|
|
66
|
+
scale = Scale.fetch(scale_name)
|
|
67
|
+
raise ScaleError, "#{scale.name} is not on the slide" unless slide_scale?(scale.name)
|
|
68
|
+
|
|
69
|
+
self.slide = cursor - scale.position(value)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def flip!
|
|
73
|
+
self.face = face == :front ? :back : :front
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def slide_scale?(scale_name)
|
|
77
|
+
SLIDE_SCALES.include?(scale_name.to_s.upcase)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def local_position_for(scale_name)
|
|
81
|
+
name = scale_name.to_s.upcase
|
|
82
|
+
slide_scale?(name) ? cursor - slide : cursor
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def index_position(side)
|
|
88
|
+
case side
|
|
89
|
+
when :left, "left", 1, "1"
|
|
90
|
+
0.0
|
|
91
|
+
when :right, "right", 10, "10"
|
|
92
|
+
1.0
|
|
93
|
+
else
|
|
94
|
+
raise ArgumentError, "unknown slide index side: #{side}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|