diff --git a/.gitignore b/.gitignore index ea8c4bf..f243e4c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,7 @@ /target +/target-appimage +/.cache +/dist/*.AppImage +/dist/rcalc.AppDir/ +/dist/.glibc-build.txt +.rcalc_last_bump diff --git a/Cargo.lock b/Cargo.lock index 9cc98d6..14ba08c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3904,7 +3904,7 @@ dependencies = [ [[package]] name = "rcalc" -version = "0.1.0" +version = "0.1.4" dependencies = [ "slint", "slint-build", diff --git a/Cargo.toml b/Cargo.toml index 1c1c525..e4dc672 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rcalc" -version = "0.1.0" +version = "0.1.5" edition = "2021" build = "build.rs" diff --git a/assets/rcalc-appimage.desktop b/assets/rcalc-appimage.desktop new file mode 100644 index 0000000..cb7a1b7 --- /dev/null +++ b/assets/rcalc-appimage.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Type=Application +Name=rcalc +GenericName=Calculator +Comment=Calculator with Programmer, Convert, CRC tools +Exec=rcalc +Icon=rcalc +Terminal=false +Categories=Utility;Calculator;Science;Engineering; +StartupNotify=true +StartupWMClass=rcalc diff --git a/build.rs b/build.rs index 688393d..3c966ff 100644 --- a/build.rs +++ b/build.rs @@ -1,3 +1,93 @@ +use std::env; +use std::fs; +use std::path::PathBuf; +use std::time::Duration; + fn main() { + let version = resolve_app_version(); + println!("cargo:rustc-env=APP_VERSION={version}"); + slint_build::compile("ui/app.slint").expect("Slint build failed"); } + +/// Window / package version shown as `Rcalc-{version}`. +/// +/// On each `release` build the patch in `Cargo.toml` is incremented once +/// (`0.1.0` → `0.1.1` → …). If Cargo restarts the build script right after we +/// rewrite `Cargo.toml`, a short mtime guard prevents a double bump. +fn resolve_app_version() -> String { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let pkg = env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0".into()); + let profile = env::var("PROFILE").unwrap_or_default(); + + if profile != "release" { + return pkg; + } + + let cargo_toml = manifest_dir.join("Cargo.toml"); + let guard_path = manifest_dir.join(".rcalc_last_bump"); + let already = fs::read_to_string(&guard_path) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + + // Cargo re-invoked us because we just rewrote Cargo.toml — keep version. + if already == pkg { + if let Ok(meta) = fs::metadata(&cargo_toml) { + if let Ok(modified) = meta.modified() { + if modified.elapsed().unwrap_or(Duration::MAX) < Duration::from_secs(15) { + return pkg; + } + } + } + } + + let Some(next) = bump_patch(&pkg) else { + return pkg; + }; + + if let Err(e) = set_cargo_toml_version(&cargo_toml, &next) { + println!("cargo:warning=could not bump Cargo.toml version: {e}"); + return pkg; + } + let _ = fs::write(&guard_path, &next); + next +} + +fn bump_patch(version: &str) -> Option { + let mut parts: Vec<&str> = version.split('.').collect(); + if parts.is_empty() { + return None; + } + while parts.len() < 3 { + parts.push("0"); + } + let patch: u64 = parts[2] + .split(['-', '+']) + .next()? + .parse() + .ok()?; + Some(format!("{}.{}.{}", parts[0], parts[1], patch + 1)) +} + +fn set_cargo_toml_version(path: &PathBuf, version: &str) -> std::io::Result<()> { + let raw = fs::read_to_string(path)?; + let mut out = String::with_capacity(raw.len() + 8); + let mut replaced = false; + for line in raw.lines() { + if !replaced && line.starts_with("version") && line.contains('=') { + out.push_str(&format!("version = \"{version}\"")); + out.push('\n'); + replaced = true; + } else { + out.push_str(line); + out.push('\n'); + } + } + if !replaced { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "version field not found in Cargo.toml", + )); + } + fs::write(path, out) +} diff --git a/dist/rcalc b/dist/rcalc index 8602f5b..d3fb50e 100755 Binary files a/dist/rcalc and b/dist/rcalc differ diff --git a/scripts/build-appimage.sh b/scripts/build-appimage.sh new file mode 100755 index 0000000..84b1c3a --- /dev/null +++ b/scripts/build-appimage.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Build a portable rcalc AppImage. +# +# The release binary is compiled inside Ubuntu 22.04 (glibc 2.35) so the +# AppImage runs on most modern distros — not only on bleeding-edge hosts. +# AppImage itself does NOT bundle glibc; the Docker base sets the floor. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ARCH="$(uname -m)" +OUT_DIR="${ROOT}/dist" +APPDIR="${OUT_DIR}/rcalc.AppDir" +TOOLS_DIR="${ROOT}/.cache/appimage-tools" +VERSION="$(grep -m1 '^version' "${ROOT}/Cargo.toml" | sed 's/.*"\(.*\)".*/\1/')" +IMAGE_NAME="rcalc-${VERSION}-${ARCH}.AppImage" + +DOCKER_IMAGE="${DOCKER_IMAGE:-ubuntu:22.04}" +# Set SKIP_DOCKER=1 to package the already-built host binary (same glibc floor as host). +SKIP_DOCKER="${SKIP_DOCKER:-0}" + +mkdir -p "${OUT_DIR}" "${TOOLS_DIR}" + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "error: required command not found: $1" >&2 + exit 1 + } +} + +download_tool() { + local url="$1" dest="$2" + if [[ -x "$dest" ]]; then + return 0 + fi + echo "Downloading $(basename "$dest")…" + curl -fsSL -o "$dest" "$url" + chmod +x "$dest" +} + +build_binary_docker() { + need_cmd docker + echo "Building release binary in ${DOCKER_IMAGE} (portable glibc)…" + mkdir -p "${HOME}/.cargo/registry" "${HOME}/.cargo/git" + docker run --rm \ + -v "${ROOT}:/src" \ + -v "${HOME}/.cargo/registry:/root/.cargo/registry" \ + -v "${HOME}/.cargo/git:/root/.cargo/git" \ + -w /src \ + "${DOCKER_IMAGE}" \ + bash -lc ' + set -euo pipefail + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq curl build-essential pkg-config libfontconfig1-dev ca-certificates + curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal + # shellcheck disable=SC1091 + source "$HOME/.cargo/env" + export CARGO_TARGET_DIR=/src/target-appimage + cargo build --release + install -D -m755 /src/target-appimage/release/rcalc /src/dist/rcalc + ldd --version | head -1 > /src/dist/.glibc-build.txt + # Make artifacts writable by the host user + chown -R '"$(id -u):$(id -g)"' /src/dist/rcalc /src/dist/.glibc-build.txt /src/target-appimage || true + ' +} + +ensure_binary() { + if [[ "$SKIP_DOCKER" == "1" ]]; then + if [[ ! -x "${OUT_DIR}/rcalc" ]]; then + echo "Building on host (SKIP_DOCKER=1)…" + (cd "${ROOT}" && cargo build --release) + install -D -m755 "${ROOT}/target/release/rcalc" "${OUT_DIR}/rcalc" + fi + ldd --version | head -1 > "${OUT_DIR}/.glibc-build.txt" || true + else + build_binary_docker + fi + + if [[ ! -x "${OUT_DIR}/rcalc" ]]; then + echo "error: dist/rcalc missing after build" >&2 + exit 1 + fi +} + +package_appimage() { + need_cmd curl + + local linuxdeploy="${TOOLS_DIR}/linuxdeploy-${ARCH}.AppImage" + download_tool \ + "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-${ARCH}.AppImage" \ + "${linuxdeploy}" + + rm -rf "${APPDIR}" + mkdir -p "${APPDIR}/usr/bin" "${APPDIR}/usr/share/icons/hicolor/256x256/apps" + + install -m755 "${OUT_DIR}/rcalc" "${APPDIR}/usr/bin/rcalc" + install -m644 "${ROOT}/assets/icon-256.png" "${APPDIR}/usr/share/icons/hicolor/256x256/apps/rcalc.png" + install -m644 "${ROOT}/assets/icon-256.png" "${APPDIR}/rcalc.png" + install -m644 "${ROOT}/assets/rcalc-appimage.desktop" "${APPDIR}/rcalc.desktop" + + echo "Bundling libraries with linuxdeploy…" + export APPIMAGE_EXTRACT_AND_RUN=1 + export OUTPUT="${IMAGE_NAME}" + ( + cd "${OUT_DIR}" + "${linuxdeploy}" \ + --appdir "${APPDIR}" \ + --executable "${APPDIR}/usr/bin/rcalc" \ + --desktop-file "${APPDIR}/rcalc.desktop" \ + --icon-file "${APPDIR}/rcalc.png" \ + --output appimage + ) + + # linuxdeploy drops the AppImage next to AppDir (dist/) + local built + built="$(find "${OUT_DIR}" -maxdepth 1 -name '*.AppImage' ! -name 'linuxdeploy*' | head -1)" + if [[ -z "$built" ]]; then + echo "error: AppImage was not produced" >&2 + exit 1 + fi + if [[ "$(basename "$built")" != "${IMAGE_NAME}" ]]; then + mv -f "$built" "${OUT_DIR}/${IMAGE_NAME}" + fi + + chmod +x "${OUT_DIR}/${IMAGE_NAME}" +} + +ensure_binary +package_appimage + +echo +echo "OK: ${OUT_DIR}/${IMAGE_NAME}" +if [[ -f "${OUT_DIR}/.glibc-build.txt" ]]; then + echo "Built against: $(cat "${OUT_DIR}/.glibc-build.txt")" +fi +echo "Run: ${OUT_DIR}/${IMAGE_NAME}" +echo "Tip: on the other PC, chmod +x and execute — no install needed." diff --git a/src/engine.rs b/src/engine.rs index de43017..3bb3b39 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,4 +1,9 @@ -//! Calculator logic (standard / Windows-like). +//! Calculator logic (standard / formula). +//! +//! Uses exact rational arithmetic when possible so that e.g. `1÷3×3 = 1` +//! instead of a float residue like `0.999999999999`. + +use std::fmt; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Op { @@ -17,25 +22,6 @@ impl Op { Op::Div => "÷", } } - - fn apply(self, a: f64, b: f64) -> Result { - let r = match self { - Op::Add => a + b, - Op::Sub => a - b, - Op::Mul => a * b, - Op::Div => { - if b == 0.0 { - return Err(CalcError::DivByZero); - } - a / b - } - }; - if !r.is_finite() { - Err(CalcError::Overflow) - } else { - Ok(r) - } - } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -55,22 +41,214 @@ impl CalcError { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StdPanel { + /// Classic immediate calculator (Windows-like chaining). + Standard, + /// Build a full expression, evaluate on `=` with operator precedence. + Formula, +} + +/// Exact rational number (always reduced, denominator > 0). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Rational { + num: i128, + den: i128, +} + +impl Rational { + fn new(num: i128, den: i128) -> Result { + if den == 0 { + return Err(CalcError::DivByZero); + } + let (mut num, mut den) = (num, den); + if den < 0 { + num = -num; + den = -den; + } + let g = gcd(num.unsigned_abs(), den.unsigned_abs()); + Ok(Self { + num: num / g as i128, + den: den / g as i128, + }) + } + + fn from_i128(n: i128) -> Self { + Self { num: n, den: 1 } + } + + /// Parse a decimal literal like `12`, `-3.5`, `0.001` into an exact fraction. + fn parse(s: &str) -> Result { + let s = s.trim(); + if s.is_empty() || s == "." || s == "-" || s == "-." { + return Err(CalcError::Invalid); + } + let neg = s.starts_with('-'); + let body = if neg { &s[1..] } else { s }; + + let (int_part, frac_part) = match body.split_once('.') { + Some((a, b)) => (a, b), + None => (body, ""), + }; + if int_part.is_empty() && frac_part.is_empty() { + return Err(CalcError::Invalid); + } + let int_part = if int_part.is_empty() { "0" } else { int_part }; + let int_val: i128 = int_part.parse().map_err(|_| CalcError::Invalid)?; + let frac_digits = frac_part.len(); + let frac_val: i128 = if frac_part.is_empty() { + 0 + } else { + frac_part.parse().map_err(|_| CalcError::Invalid)? + }; + let den = pow10(frac_digits)?; + let num = int_val + .checked_mul(den) + .and_then(|v| v.checked_add(frac_val)) + .ok_or(CalcError::Overflow)?; + let num = if neg { -num } else { num }; + Self::new(num, den) + } + + fn to_f64(self) -> f64 { + self.num as f64 / self.den as f64 + } + + fn is_integer(self) -> bool { + self.den == 1 + } + + fn add(self, o: Self) -> Result { + let num = self + .num + .checked_mul(o.den) + .and_then(|a| o.num.checked_mul(self.den).and_then(|b| a.checked_add(b))) + .ok_or(CalcError::Overflow)?; + let den = self.den.checked_mul(o.den).ok_or(CalcError::Overflow)?; + Self::new(num, den) + } + + fn sub(self, o: Self) -> Result { + self.add(Rational { + num: -o.num, + den: o.den, + }) + } + + fn mul(self, o: Self) -> Result { + let num = self.num.checked_mul(o.num).ok_or(CalcError::Overflow)?; + let den = self.den.checked_mul(o.den).ok_or(CalcError::Overflow)?; + Self::new(num, den) + } + + fn div(self, o: Self) -> Result { + if o.num == 0 { + return Err(CalcError::DivByZero); + } + self.mul(Rational { + num: o.den, + den: o.num, + }) + } + + fn neg(self) -> Self { + Self { + num: -self.num, + den: self.den, + } + } +} + +/// Numeric value: prefer exact rationals, fall back to float (√ etc.). +#[derive(Debug, Clone, Copy, PartialEq)] +enum Value { + Rat(Rational), + Float(f64), +} + +impl Value { + fn from_entry(s: &str) -> Result { + Ok(Value::Rat(Rational::parse(s)?)) + } + + fn from_f64(v: f64) -> Result { + if !v.is_finite() { + return Err(CalcError::Overflow); + } + // Prefer exact integer when float is (almost) integral. + if v.fract().abs() < 1e-12 && v.abs() < 1e15 { + return Ok(Value::Rat(Rational::from_i128(v.round() as i128))); + } + Ok(Value::Float(v)) + } + + fn to_f64(self) -> f64 { + match self { + Value::Rat(r) => r.to_f64(), + Value::Float(f) => f, + } + } + + fn apply(self, op: Op, other: Self) -> Result { + match (self, other) { + (Value::Rat(a), Value::Rat(b)) => { + let r = match op { + Op::Add => a.add(b)?, + Op::Sub => a.sub(b)?, + Op::Mul => a.mul(b)?, + Op::Div => a.div(b)?, + }; + Ok(Value::Rat(r)) + } + (a, b) => { + let (x, y) = (a.to_f64(), b.to_f64()); + let r = match op { + Op::Add => x + y, + Op::Sub => x - y, + Op::Mul => x * y, + Op::Div => { + if y == 0.0 { + return Err(CalcError::DivByZero); + } + x / y + } + }; + Value::from_f64(r) + } + } + } + + fn format(self) -> String { + match self { + Value::Rat(r) => format_rational(r), + Value::Float(f) => format_float(f), + } + } +} + #[derive(Debug, Clone)] pub struct Calculator { + panel: StdPanel, /// Digits currently being entered (or last result as string). entry: String, + /// Exact value behind `entry` when not typing (avoids `1/3` → `"0.333…"` → inexact parse). + entry_value: Option, /// True while the user is typing a new number. typing: bool, - /// Left operand waiting for the next number / equals. - pending: Option, - /// Pending binary operator. + /// Left operand waiting for the next number / equals (standard panel). + pending: Option, + /// Pending binary operator (standard panel). op: Option, - /// Expression line above the main display (e.g. "12 +"). + /// Expression line above the main display. expression: String, + /// Formula panel: tokens / text of the expression being built (without current entry). + formula: String, + /// After `=` in formula mode, next digit starts fresh. + formula_done: bool, /// Last error, if any. error: Option, /// Memory register. - memory: f64, + memory: Value, /// Whether memory has been set (for UI hint). memory_set: bool, } @@ -84,17 +262,36 @@ impl Default for Calculator { impl Calculator { pub fn new() -> Self { Self { + panel: StdPanel::Standard, entry: "0".into(), + entry_value: Some(Value::Rat(Rational::from_i128(0))), typing: false, pending: None, op: None, expression: String::new(), + formula: String::new(), + formula_done: false, error: None, - memory: 0.0, + memory: Value::Rat(Rational::from_i128(0)), memory_set: false, } } + pub fn panel(&self) -> StdPanel { + self.panel + } + + pub fn set_panel(&mut self, panel: StdPanel) { + if self.panel == panel { + return; + } + let mem = (self.memory, self.memory_set); + *self = Self::new(); + self.panel = panel; + self.memory = mem.0; + self.memory_set = mem.1; + } + pub fn display(&self) -> &str { if let Some(err) = self.error { return err.message(); @@ -111,17 +308,23 @@ impl Calculator { } pub fn clear_all(&mut self) { - *self = Self { - memory: self.memory, - memory_set: self.memory_set, - ..Self::new() - }; + let panel = self.panel; + let memory = self.memory; + let memory_set = self.memory_set; + *self = Self::new(); + self.panel = panel; + self.memory = memory; + self.memory_set = memory_set; } pub fn clear_entry(&mut self) { self.error = None; self.entry = "0".into(); + self.entry_value = Some(Value::Rat(Rational::from_i128(0))); self.typing = false; + if self.panel == StdPanel::Formula { + self.sync_formula_expression(); + } } pub fn backspace(&mut self) { @@ -129,13 +332,37 @@ impl Calculator { self.clear_entry(); return; } + if self.panel == StdPanel::Formula && self.formula_done { + return; + } if !self.typing { + if self.panel == StdPanel::Formula && !self.formula.is_empty() { + // Remove trailing operator / open paren from formula. + while self.formula.ends_with(' ') { + self.formula.pop(); + } + if let Some(ch) = self.formula.chars().last() { + if "++−-×*÷/(".contains(ch) || ch == '−' || ch == '×' || ch == '÷' { + self.formula.pop(); + while self.formula.ends_with(' ') { + self.formula.pop(); + } + self.sync_formula_expression(); + } + } + } return; } self.entry.pop(); if self.entry.is_empty() || self.entry == "-" { self.entry = "0".into(); self.typing = false; + self.entry_value = Some(Value::Rat(Rational::from_i128(0))); + } else { + self.entry_value = None; + } + if self.panel == StdPanel::Formula { + self.sync_formula_expression(); } } @@ -144,9 +371,23 @@ impl Calculator { if self.error.is_some() { self.clear_all(); } + if self.panel == StdPanel::Formula && self.formula_done { + self.formula.clear(); + self.expression.clear(); + self.formula_done = false; + self.entry = d.to_string(); + self.entry_value = None; + self.typing = true; + self.sync_formula_expression(); + return; + } if !self.typing { self.entry = d.to_string(); + self.entry_value = None; self.typing = true; + if self.panel == StdPanel::Formula { + self.sync_formula_expression(); + } return; } if self.entry == "0" { @@ -156,19 +397,41 @@ impl Calculator { } else if digit_count(&self.entry) < 16 { self.entry.push(d); } + self.entry_value = None; + if self.panel == StdPanel::Formula { + self.sync_formula_expression(); + } } pub fn input_dot(&mut self) { if self.error.is_some() { self.clear_all(); } + if self.panel == StdPanel::Formula && self.formula_done { + self.formula.clear(); + self.expression.clear(); + self.formula_done = false; + self.entry = "0.".into(); + self.entry_value = None; + self.typing = true; + self.sync_formula_expression(); + return; + } if !self.typing { self.entry = "0.".into(); + self.entry_value = None; self.typing = true; + if self.panel == StdPanel::Formula { + self.sync_formula_expression(); + } return; } if !self.entry.contains('.') { self.entry.push('.'); + self.entry_value = None; + if self.panel == StdPanel::Formula { + self.sync_formula_expression(); + } } } @@ -176,54 +439,61 @@ impl Calculator { if self.error.is_some() { return; } + if self.panel == StdPanel::Formula && self.formula_done { + if let Ok(v) = self.current_value() { + self.set_entry_value(v.apply(Op::Mul, Value::Rat(Rational::from_i128(-1))).unwrap_or(v)); + } + return; + } if let Ok(v) = self.current_value() { - self.set_entry(-v); + match v { + Value::Rat(r) => self.set_entry_value(Value::Rat(r.neg())), + Value::Float(f) => self.set_entry_value(Value::Float(-f)), + } self.typing = true; + if self.panel == StdPanel::Formula { + self.sync_formula_expression(); + } } } pub fn percent(&mut self) { - if self.error.is_some() { + if self.error.is_some() || self.panel == StdPanel::Formula { return; } let Ok(cur) = self.current_value() else { return; }; let result = match (self.pending, self.op) { - (Some(a), Some(Op::Add | Op::Sub)) => a * cur / 100.0, - _ => cur / 100.0, + (Some(a), Some(Op::Add | Op::Sub)) => a + .apply(Op::Mul, cur) + .and_then(|p| p.apply(Op::Div, Value::Rat(Rational::from_i128(100)))), + _ => cur.apply(Op::Div, Value::Rat(Rational::from_i128(100))), }; - self.set_entry(result); - self.typing = false; + match result { + Ok(v) => { + self.set_entry_value(v); + self.typing = false; + } + Err(e) => self.fail(e), + } } pub fn reciprocal(&mut self) { - self.unary(|x| { - if x == 0.0 { - Err(CalcError::DivByZero) - } else { - Ok(1.0 / x) - } - }); + self.unary(|x| Value::Rat(Rational::from_i128(1)).apply(Op::Div, x)); } pub fn square(&mut self) { - self.unary(|x| { - let r = x * x; - if r.is_finite() { - Ok(r) - } else { - Err(CalcError::Overflow) - } - }); + self.unary(|x| x.apply(Op::Mul, x)); } pub fn sqrt(&mut self) { self.unary(|x| { - if x < 0.0 { + let f = x.to_f64(); + if f < 0.0 { Err(CalcError::Invalid) } else { - Ok(x.sqrt()) + Value::from_f64(f.sqrt()) } }); } @@ -232,53 +502,88 @@ impl Calculator { if self.error.is_some() { return; } - if let Err(e) = self.commit_pending() { - self.fail(e); + match self.panel { + StdPanel::Standard => self.set_op_standard(op), + StdPanel::Formula => self.set_op_formula(op), + } + } + + pub fn paren_open(&mut self) { + if self.panel != StdPanel::Formula || self.error.is_some() { return; } - let Ok(v) = self.current_value() else { - return; - }; - self.pending = Some(v); - self.op = Some(op); - self.expression = format!("{} {}", format_number(v), op.symbol()); + if self.formula_done { + self.formula.clear(); + self.formula_done = false; + self.entry = "0".into(); + self.typing = false; + } + // If a number was being typed, imply multiply: 2(3+1) + if self.typing { + self.flush_entry_to_formula(); + if !self.formula.is_empty() && !self.formula.ends_with('(') && !self.formula.ends_with(' ') { + // already flushed number; add × before ( + // flush_entry_to_formula already appended the number + } + let trimmed = self.formula.trim_end(); + if trimmed + .chars() + .last() + .is_some_and(|c| c.is_ascii_digit() || c == ')') + { + self.formula.push_str(" × "); + } + } else if !self.formula.is_empty() { + let trimmed = self.formula.trim_end(); + if trimmed + .chars() + .last() + .is_some_and(|c| c.is_ascii_digit() || c == ')') + { + self.formula.push_str(" × "); + } + } + self.formula.push('('); + self.entry = "0".into(); self.typing = false; + self.sync_formula_expression(); + } + + pub fn paren_close(&mut self) { + if self.panel != StdPanel::Formula || self.error.is_some() { + return; + } + if self.formula_done { + return; + } + let opens = self.formula.chars().filter(|&c| c == '(').count(); + let closes = self.formula.chars().filter(|&c| c == ')').count(); + if opens <= closes { + return; + } + if self.typing || self.entry != "0" || !self.formula.ends_with('(') { + self.flush_entry_to_formula(); + } + self.formula.push(')'); + self.entry = "0".into(); + self.typing = false; + self.sync_formula_expression(); } pub fn equals(&mut self) { if self.error.is_some() { return; } - let Some(op) = self.op else { - self.expression.clear(); - self.typing = false; - return; - }; - let Ok(b) = self.current_value() else { - return; - }; - let a = self.pending.unwrap_or(b); - match op.apply(a, b) { - Ok(r) => { - self.expression = format!( - "{} {} {} =", - format_number(a), - op.symbol(), - format_number(b) - ); - self.set_entry(r); - self.pending = None; - self.op = None; - self.typing = false; - } - Err(e) => self.fail(e), + match self.panel { + StdPanel::Standard => self.equals_standard(), + StdPanel::Formula => self.equals_formula(), } } // --- Memory --- pub fn memory_clear(&mut self) { - self.memory = 0.0; + self.memory = Value::Rat(Rational::from_i128(0)); self.memory_set = false; } @@ -286,8 +591,15 @@ impl Calculator { if self.error.is_some() { self.clear_all(); } - self.set_entry(self.memory); + if self.panel == StdPanel::Formula && self.formula_done { + self.formula.clear(); + self.formula_done = false; + } + self.set_entry_value(self.memory); self.typing = false; + if self.panel == StdPanel::Formula { + self.sync_formula_expression(); + } } pub fn memory_add(&mut self) { @@ -295,9 +607,14 @@ impl Calculator { return; } if let Ok(v) = self.current_value() { - self.memory += v; - self.memory_set = true; - self.typing = false; + match self.memory.apply(Op::Add, v) { + Ok(m) => { + self.memory = m; + self.memory_set = true; + self.typing = false; + } + Err(e) => self.fail(e), + } } } @@ -306,9 +623,14 @@ impl Calculator { return; } if let Ok(v) = self.current_value() { - self.memory -= v; - self.memory_set = true; - self.typing = false; + match self.memory.apply(Op::Sub, v) { + Ok(m) => { + self.memory = m; + self.memory_set = true; + self.typing = false; + } + Err(e) => self.fail(e), + } } } @@ -323,19 +645,43 @@ impl Calculator { } } - // --- internals --- + // --- standard panel --- - fn unary(&mut self, f: impl FnOnce(f64) -> Result) { - if self.error.is_some() { + fn set_op_standard(&mut self, op: Op) { + if let Err(e) = self.commit_pending() { + self.fail(e); return; } let Ok(v) = self.current_value() else { return; }; - match f(v) { + self.pending = Some(v); + self.op = Some(op); + self.expression = format!("{} {}", v.format(), op.symbol()); + self.typing = false; + } + + fn equals_standard(&mut self) { + let Some(op) = self.op else { + self.expression.clear(); + self.typing = false; + return; + }; + let Ok(b) = self.current_value() else { + return; + }; + let a = self.pending.unwrap_or(b); + match a.apply(op, b) { Ok(r) => { - self.expression.clear(); - self.set_entry(r); + self.expression = format!( + "{} {} {} =", + a.format(), + op.symbol(), + b.format() + ); + self.set_entry_value(r); + self.pending = None; + self.op = None; self.typing = false; } Err(e) => self.fail(e), @@ -350,21 +696,145 @@ impl Calculator { return Ok(()); } let b = self.current_value()?; - let r = op.apply(a, b)?; - self.set_entry(r); + let r = a.apply(op, b)?; + self.set_entry_value(r); self.pending = Some(r); self.op = None; + self.typing = false; Ok(()) } - fn current_value(&self) -> Result { - self.entry - .parse::() - .map_err(|_| CalcError::Invalid) + // --- formula panel --- + + fn set_op_formula(&mut self, op: Op) { + if self.formula_done { + // Continue from previous result: `ans + …` + self.formula.clear(); + self.formula_done = false; + self.typing = true; // treat current entry as left operand + } + self.flush_entry_to_formula(); + // Replace trailing operator if user changes mind: `1 +` then `-` → `1 −` + let trimmed = self.formula.trim_end(); + if let Some(last) = trimmed.chars().last() { + if matches!(last, '+' | '−' | '×' | '÷' | '-' | '*' | '/') { + while self.formula.ends_with(' ') { + self.formula.pop(); + } + self.formula.pop(); + while self.formula.ends_with(' ') { + self.formula.pop(); + } + } + } + if !self.formula.is_empty() { + self.formula.push(' '); + } + self.formula.push_str(op.symbol()); + self.formula.push(' '); + self.entry = "0".into(); + self.typing = false; + self.sync_formula_expression(); } - fn set_entry(&mut self, v: f64) { - self.entry = format_number(v); + fn equals_formula(&mut self) { + let mut src = self.formula.clone(); + let ends_with_paren = src.trim_end().ends_with(')'); + if !ends_with_paren { + if !src.is_empty() && !src.ends_with(' ') && !src.ends_with('(') { + src.push(' '); + } + src.push_str(&self.entry); + } else if src.is_empty() { + src = self.entry.clone(); + } + + // Auto-close parentheses. + let opens = src.chars().filter(|&c| c == '(').count(); + let closes = src.chars().filter(|&c| c == ')').count(); + for _ in 0..opens.saturating_sub(closes) { + src.push(')'); + } + + match eval_expression(&src) { + Ok(v) => { + self.expression = format!("{} =", src); + self.set_entry_value(v); + self.formula.clear(); + self.typing = false; + self.formula_done = true; + } + Err(e) => self.fail(e), + } + } + + fn flush_entry_to_formula(&mut self) { + if !self.typing && self.entry == "0" && self.formula.trim_end().ends_with(')') { + return; + } + if !self.formula.is_empty() + && !self.formula.ends_with(' ') + && !self.formula.ends_with('(') + { + self.formula.push(' '); + } + self.formula.push_str(&self.entry); + self.typing = false; + } + + fn sync_formula_expression(&mut self) { + if self.formula_done { + return; + } + let mut s = self.formula.clone(); + if self.typing { + if !s.is_empty() && !s.ends_with(' ') && !s.ends_with('(') { + s.push(' '); + } + s.push_str(&self.entry); + } + self.expression = s; + } + + // --- internals --- + + fn unary(&mut self, f: impl FnOnce(Value) -> Result) { + if self.error.is_some() { + return; + } + let Ok(v) = self.current_value() else { + return; + }; + match f(v) { + Ok(r) => { + if self.panel == StdPanel::Formula { + // Unary applies to current entry only. + self.set_entry_value(r); + self.typing = true; + self.formula_done = false; + self.sync_formula_expression(); + } else { + self.expression.clear(); + self.set_entry_value(r); + self.typing = false; + } + } + Err(e) => self.fail(e), + } + } + + fn current_value(&self) -> Result { + if !self.typing { + if let Some(v) = self.entry_value { + return Ok(v); + } + } + Value::from_entry(&self.entry) + } + + fn set_entry_value(&mut self, v: Value) { + self.entry = v.format(); + self.entry_value = Some(v); self.error = None; } @@ -372,22 +842,231 @@ impl Calculator { self.error = Some(e); self.pending = None; self.op = None; + self.entry_value = None; self.expression.clear(); + self.formula.clear(); + self.formula_done = false; self.typing = false; } } +// --- expression parser (formula mode) --- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Tok { + Num, // placeholder — numbers carried separately + Op(Op), + LParen, + RParen, +} + +fn eval_expression(src: &str) -> Result { + let tokens = tokenize(src)?; + let mut idx = 0; + let value = parse_expr(&tokens, &mut idx)?; + if idx != tokens.len() { + return Err(CalcError::Invalid); + } + Ok(value) +} + +fn tokenize(src: &str) -> Result)>, CalcError> { + let mut out = Vec::new(); + let chars: Vec = src.chars().collect(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if c.is_whitespace() { + i += 1; + continue; + } + match c { + '+' => { + out.push((Tok::Op(Op::Add), None)); + i += 1; + } + '−' | '-' => { + out.push((Tok::Op(Op::Sub), None)); + i += 1; + } + '×' | '*' => { + out.push((Tok::Op(Op::Mul), None)); + i += 1; + } + '÷' | '/' => { + out.push((Tok::Op(Op::Div), None)); + i += 1; + } + '(' => { + out.push((Tok::LParen, None)); + i += 1; + } + ')' => { + out.push((Tok::RParen, None)); + i += 1; + } + '0'..='9' | '.' => { + let start = i; + i += 1; + while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') { + i += 1; + } + let lit: String = chars[start..i].iter().collect(); + let v = Value::from_entry(&lit)?; + out.push((Tok::Num, Some(v))); + } + _ => return Err(CalcError::Invalid), + } + } + Ok(out) +} + +fn parse_expr(tokens: &[(Tok, Option)], idx: &mut usize) -> Result { + let mut left = parse_term(tokens, idx)?; + while let Some((Tok::Op(op @ (Op::Add | Op::Sub)), _)) = tokens.get(*idx).copied() { + *idx += 1; + let right = parse_term(tokens, idx)?; + left = left.apply(op, right)?; + } + Ok(left) +} + +fn parse_term(tokens: &[(Tok, Option)], idx: &mut usize) -> Result { + let mut left = parse_unary(tokens, idx)?; + while let Some((Tok::Op(op @ (Op::Mul | Op::Div)), _)) = tokens.get(*idx).copied() { + *idx += 1; + let right = parse_unary(tokens, idx)?; + left = left.apply(op, right)?; + } + Ok(left) +} + +fn parse_unary(tokens: &[(Tok, Option)], idx: &mut usize) -> Result { + if let Some((Tok::Op(Op::Sub), _)) = tokens.get(*idx).copied() { + *idx += 1; + let v = parse_unary(tokens, idx)?; + return v.apply(Op::Mul, Value::Rat(Rational::from_i128(-1))); + } + if let Some((Tok::Op(Op::Add), _)) = tokens.get(*idx).copied() { + *idx += 1; + return parse_unary(tokens, idx); + } + parse_primary(tokens, idx) +} + +fn parse_primary(tokens: &[(Tok, Option)], idx: &mut usize) -> Result { + let Some((tok, val)) = tokens.get(*idx).copied() else { + return Err(CalcError::Invalid); + }; + match tok { + Tok::Num => { + *idx += 1; + val.ok_or(CalcError::Invalid) + } + Tok::LParen => { + *idx += 1; + let v = parse_expr(tokens, idx)?; + match tokens.get(*idx).copied() { + Some((Tok::RParen, _)) => { + *idx += 1; + Ok(v) + } + _ => Err(CalcError::Invalid), + } + } + _ => Err(CalcError::Invalid), + } +} + +// --- formatting / helpers --- + fn digit_count(s: &str) -> usize { s.chars().filter(|c| c.is_ascii_digit()).count() } -fn format_number(v: f64) -> String { +fn gcd(mut a: u128, mut b: u128) -> u128 { + while b != 0 { + let t = b; + b = a % b; + a = t; + } + a +} + +fn pow10(n: usize) -> Result { + let mut r: i128 = 1; + for _ in 0..n { + r = r.checked_mul(10).ok_or(CalcError::Overflow)?; + } + Ok(r) +} + +fn format_rational(r: Rational) -> String { + if r.num == 0 { + return "0".into(); + } + if r.is_integer() { + return r.num.to_string(); + } + // Exact terminating decimal if den's primes ⊆ {2,5}. + if let Some(s) = terminating_decimal(r) { + return s; + } + format_float(r.to_f64()) +} + +fn terminating_decimal(r: Rational) -> Option { + let mut den = r.den; + while den % 2 == 0 { + den /= 2; + } + while den % 5 == 0 { + den /= 5; + } + if den != 1 { + return None; + } + // Scale denominator to a power of 10. + let mut d = r.den; + let mut n = r.num; + let neg = n < 0; + n = n.abs(); + while d % 10 != 0 { + if d % 2 == 0 { + d *= 5; + n *= 5; + } else if d % 5 == 0 { + d *= 2; + n *= 2; + } else { + break; + } + } + let int_part = n / d; + let mut frac = (n % d).to_string(); + let width = d.to_string().len().saturating_sub(1); + while frac.len() < width { + frac.insert(0, '0'); + } + // trim trailing zeros + while frac.ends_with('0') { + frac.pop(); + } + let body = if frac.is_empty() { + int_part.to_string() + } else { + format!("{int_part}.{frac}") + }; + Some(if neg { format!("-{body}") } else { body }) +} + +fn format_float(v: f64) -> String { if !v.is_finite() { return "0".into(); } - // Prefer integer display when close enough. - if v.fract().abs() < 1e-12 && v.abs() < 1e15 { - return format!("{}", v as i64); + // Snap near-integers (guards any float path residue). + if (v - v.round()).abs() < 1e-10 && v.abs() < 1e15 { + return format!("{}", v.round() as i64); } let s = format!("{:.12}", v); let s = s.trim_end_matches('0').trim_end_matches('.').to_string(); @@ -398,6 +1077,16 @@ fn format_number(v: f64) -> String { } } +impl fmt::Display for Rational { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.den == 1 { + write!(f, "{}", self.num) + } else { + write!(f, "{}/{}", self.num, self.den) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -413,6 +1102,60 @@ mod tests { assert_eq!(c.display(), "15"); } + #[test] + fn one_third_times_three_standard() { + let mut c = Calculator::new(); + c.input_digit('1'); + c.set_op(Op::Div); + c.input_digit('3'); + c.set_op(Op::Mul); + c.input_digit('3'); + c.equals(); + assert_eq!(c.display(), "1"); + } + + #[test] + fn one_third_times_three_formula() { + let mut c = Calculator::new(); + c.set_panel(StdPanel::Formula); + c.input_digit('1'); + c.set_op(Op::Div); + c.input_digit('3'); + c.set_op(Op::Mul); + c.input_digit('3'); + c.equals(); + assert_eq!(c.display(), "1"); + assert!(c.expression().contains('=')); + } + + #[test] + fn formula_precedence() { + let mut c = Calculator::new(); + c.set_panel(StdPanel::Formula); + c.input_digit('1'); + c.set_op(Op::Add); + c.input_digit('2'); + c.set_op(Op::Mul); + c.input_digit('3'); + c.equals(); + assert_eq!(c.display(), "7"); + } + + #[test] + fn formula_parens() { + let mut c = Calculator::new(); + c.set_panel(StdPanel::Formula); + c.paren_open(); + c.input_digit('1'); + c.set_op(Op::Add); + c.input_digit('2'); + c.paren_close(); + c.set_op(Op::Mul); + c.input_digit('3'); + c.equals(); + assert_eq!(c.display(), "9"); + } + #[test] fn div_by_zero() { let mut c = Calculator::new(); @@ -422,4 +1165,12 @@ mod tests { c.equals(); assert!(c.display().contains("zero")); } + + #[test] + fn rational_parse_decimal() { + let r = Rational::parse("0.5").unwrap(); + assert_eq!(r, Rational::new(1, 2).unwrap()); + let r = Rational::parse("1.25").unwrap(); + assert_eq!(r, Rational::new(5, 4).unwrap()); + } } diff --git a/src/main.rs b/src/main.rs index 1a3d58c..865916f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,7 +11,7 @@ use std::rc::Rc; use convert::{ConvField, ConvPanel, Converter, Endian, RatioWidth}; use crc::CrcTool; -use engine::{Calculator, Op}; +use engine::{Calculator, Op, StdPanel}; use programmer::{Base, ProgOp, Programmer}; use slint::{ComponentHandle, ModelRc, VecModel}; @@ -55,6 +55,7 @@ fn main() -> Result<(), slint::PlatformError> { let ui = AppWindow::new()?; // Must match the .desktop basename (rcalc.desktop → "rcalc"), before show/run. slint::set_xdg_app_id("rcalc")?; + ui.set_app_title(format!("Rcalc-{}", env!("APP_VERSION")).into()); let state = Rc::new(RefCell::new(State::default())); @@ -170,6 +171,10 @@ fn refresh(ui: &AppWindow, state: &mut State) { ui.set_display_text(state.std.display().into()); ui.set_expression_text(state.std.expression().into()); ui.set_has_memory(state.std.has_memory()); + ui.set_std_panel(match state.std.panel() { + StdPanel::Standard => 0, + StdPanel::Formula => 1, + }); } Mode::Programmer => { ui.set_display_text(state.prog.display().into()); @@ -266,6 +271,8 @@ fn handle_key(state: &mut State, id: &str) { fn handle_standard(calc: &mut Calculator, id: &str) { match id { + "panel:std" => calc.set_panel(StdPanel::Standard), + "panel:formula" => calc.set_panel(StdPanel::Formula), "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" => { calc.input_digit(id.chars().next().unwrap()); } @@ -276,9 +283,12 @@ fn handle_standard(calc: &mut Calculator, id: &str) { "/" | "÷" => calc.set_op(Op::Div), "=" => calc.equals(), "%" => calc.percent(), + "(" => calc.paren_open(), + ")" => calc.paren_close(), "CE" => calc.clear_entry(), "C" => calc.clear_all(), "BS" => calc.backspace(), + "1/x" | "x2" | "sqrt" if calc.panel() == StdPanel::Formula => {} "1/x" => calc.reciprocal(), "x2" => calc.square(), "sqrt" => calc.sqrt(), diff --git a/ui/app.slint b/ui/app.slint index 25c9484..dd5dc80 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -294,7 +294,8 @@ component EndianChip inherits Rectangle { } export component AppWindow inherits Window { - title: "rcalc"; + in-out property app-title: "Rcalc"; + title: root.app-title; icon: @image-url("../assets/icon-256.png"); preferred-width: 420px; preferred-height: 640px; @@ -308,6 +309,8 @@ export component AppWindow inherits Window { in property display-text: "0"; in property expression-text: ""; in property has-memory: false; + // 0 = classic standard, 1 = formula + in property std-panel: 0; in property hex-text: "0"; in property dec-text: "0"; @@ -1004,6 +1007,21 @@ export component AppWindow inherits Window { } } + if root.mode == 0: HorizontalLayout { + spacing: 6px; + height: 30px; + ModeTab { + label: "Ordinary"; + active: root.std-panel == 0; + clicked => { root.press("panel:std"); } + } + ModeTab { + label: "Formula"; + active: root.std-panel == 1; + clicked => { root.press("panel:formula"); } + } + } + if root.mode == 0: HorizontalLayout { spacing: 6px; height: 32px; @@ -1017,15 +1035,17 @@ export component AppWindow inherits Window { if root.mode == 0: GridLayout { spacing: 6px; Row { - FuncBtn { label: "%"; clicked => { root.press("%"); } } - FuncBtn { label: "CE"; clicked => { root.press("CE"); } } + if root.std-panel == 0: FuncBtn { label: "%"; clicked => { root.press("%"); } } + if root.std-panel == 1: FuncBtn { label: "("; clicked => { root.press("("); } } + if root.std-panel == 0: FuncBtn { label: "CE"; clicked => { root.press("CE"); } } + if root.std-panel == 1: FuncBtn { label: ")"; clicked => { root.press(")"); } } FuncBtn { label: "C"; clicked => { root.press("C"); } } FuncBtn { label: "⌫"; clicked => { root.press("BS"); } } } Row { - FuncBtn { label: "1/x"; clicked => { root.press("1/x"); } } - FuncBtn { label: "x²"; clicked => { root.press("x2"); } } - FuncBtn { label: "√x"; clicked => { root.press("sqrt"); } } + FuncBtn { label: "1/x"; enabled: root.std-panel == 0; clicked => { root.press("1/x"); } } + FuncBtn { label: "x²"; enabled: root.std-panel == 0; clicked => { root.press("x2"); } } + FuncBtn { label: "√x"; enabled: root.std-panel == 0; clicked => { root.press("sqrt"); } } FuncBtn { label: "÷"; clicked => { root.press("/"); } } } Row {