Newer
Older
Ruby / keisan.rb
@SAITO Azuma SAITO Azuma on 5 Jan 4 KB 2026-01-05 23:41:20
# encoding: UTF-8
# frozen_string_literal: true
require "curses"
include Curses

OPS = { "+" => 1, "-" => 1, "*" => 2, "/" => 2 }.freeze

def screen_cols
  if Curses.respond_to?(:cols)
    Curses.cols
  elsif defined?(Curses::COLS)
    Curses::COLS
  else
    80
  end
end

def screen_lines
  if Curses.respond_to?(:lines)
    Curses.lines
  elsif defined?(Curses::LINES)
    Curses::LINES
  else
    24
  end
end

def tokenize(s)
  t = []
  i = 0
  prev = :start

  while i < s.size
    c = s[i]

    if c =~ /\s/
      i += 1
      next
    end

    if c =~ /[0-9.]/
      j = i
      dot = 0
      while j < s.size && s[j] =~ /[0-9.]/
        dot += 1 if s[j] == "."
        raise "too many dots" if dot > 1
        j += 1
      end
      t << [:n, s[i...j].to_f]
      i = j
      prev = :num
      next
    end

    if c == "("
      t << [:l, c]
      i += 1
      prev = :l
      next
    end

    if c == ")"
      t << [:r, c]
      i += 1
      prev = :r
      next
    end

    if "+-*/".include?(c)
      if c == "-" && (prev == :start || prev == :op || prev == :l)
        t << [:n, 0.0]
        t << [:op, "-"]
        i += 1
        prev = :op
        next
      end
      t << [:op, c]
      i += 1
      prev = :op
      next
    end

    raise "bad char: #{c.inspect}"
  end

  t
end

def to_rpn(tokens)
  out = []
  ops = []

  tokens.each do |k, v|
    if k == :n
      out << [k, v]
    elsif k == :op
      while !ops.empty? && ops[-1][0] == :op && OPS[ops[-1][1]] >= OPS[v]
        out << ops.pop
      end
      ops << [k, v]
    elsif k == :l
      ops << [k, v]
    else # :r
      found = false
      until ops.empty?
        x = ops.pop
        if x[0] == :l
          found = true
          break
        else
          out << x
        end
      end
      raise "paren mismatch" unless found
    end
  end

  until ops.empty?
    x = ops.pop
    raise "paren mismatch" if x[0] == :l
    out << x
  end

  out
end

def eval_rpn(rpn)
  st = []

  rpn.each do |k, v|
    if k == :n
      st << v
      next
    end

    b = st.pop
    a = st.pop
    raise "bad expr" if a.nil? || b.nil?

    if v == "+"
      st << (a + b)
    elsif v == "-"
      st << (a - b)
    elsif v == "*"
      st << (a * b)
    elsif v == "/"
      raise "div by zero" if b == 0
      st << (a / b)
    else
      raise "unknown op: #{v.inspect}"
    end
  end

  raise "bad expr" unless st.size == 1
  st[0]
end

def calc(expr)
  eval_rpn(to_rpn(tokenize(expr)))
end

def show_char(c)
  if c == "*"
    "×"
  elsif c == "/"
    "÷"
  else
    c
  end
end

def read_key
  k = Curses.getch
  if k == 10 || k == "\n"
    return [:enter, nil]
  end
  if k == 8 || k == 127 || k == Curses::KEY_BACKSPACE
    return [:bs, nil]
  end
  if k.is_a?(String)
    return [:ch, k]
  end
  [:ig, nil]
end

def tail_str(str, width)
  return "" if width <= 0
  return str if str.size <= width
  str[str.size - width, width]
end

def center(y, txt)
  Curses.setpos(y, 0)
  Curses.clrtoeol
  w = screen_cols
  x = (w - txt.size) / 2
  x = 0 if x < 0
  Curses.setpos(y, x)
  Curses.addstr(txt)
end

Curses.init_screen
Curses.noecho
begin
  Curses.curs_set(0)
rescue
end

begin
  expr = String.new
  disp = String.new
  msg  = String.new

  Curses.clear
  center(0, "電卓: Enter/= で計算, BSで消す, cでクリア, qで終了")

  loop do
    w = screen_cols
    h = screen_lines

    center(h / 2, tail_str(disp, w - 2))
    center(h / 2 + 1, tail_str(msg.to_s, w - 2))

    Curses.refresh

    act, ch = read_key
    next if act == :ig

    msg = String.new

    if act == :bs
      expr.chop!
      disp.chop!
      next
    end

    if act == :enter
      begin
        r = calc(expr)
        msg = "= #{r}"
        expr = r.to_s
        disp = expr.dup
      rescue => e
        msg = "ERR: #{e.message}"
      end
      next
    end

    # :ch
    break if ch == "q"

    if ch == "c"
      expr = String.new
      disp = String.new
      msg  = String.new
      next
    end

    if ch == "="
      begin
        r = calc(expr)
        msg = "= #{r}"
        expr = r.to_s
        disp = expr.dup
      rescue => e
        msg = "ERR: #{e.message}"
      end
      next
    end

    # ここ重要: / を正規表現内でエスケープ
    if ch =~ /[0-9+\-*\/().]/
      expr << ch
      disp << show_char(ch)
    end
  end
ensure
  Curses.close_screen
end