Compare commits

...
2 Commits
Author SHA1 Message Date
alex 93e193f7fa 300826 2026-08-30 12:22:00 +03:00
alex b18dacec07 0124 2026-08-30 12:22:00 +03:00
11 changed files with 499 additions and 148 deletions
Generated
+1 -1
View File
@@ -3904,7 +3904,7 @@ dependencies = [
[[package]] [[package]]
name = "rcalc" name = "rcalc"
version = "0.1.21" version = "0.1.28"
dependencies = [ dependencies = [
"slint", "slint",
"slint-build", "slint-build",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rcalc" name = "rcalc"
version = "0.1.21" version = "0.1.28"
edition = "2021" edition = "2021"
build = "build.rs" build = "build.rs"
+5 -80
View File
@@ -1,7 +1,4 @@
use std::env; use std::env;
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
fn main() { fn main() {
let version = resolve_app_version(); let version = resolve_app_version();
@@ -38,82 +35,10 @@ fn embed_windows_icon() {
/// Window / package version shown as `Rcalc-{version}`. /// Window / package version shown as `Rcalc-{version}`.
/// ///
/// On each `release` build the patch in `Cargo.toml` is incremented once /// Always the version from `Cargo.toml`. Раньше каждая release-сборка
/// (`0.1.0` → `0.1.1` → …). If Cargo restarts the build script right after we /// инкрементировала patch в Cargo.toml посреди сборки — из-за этого
/// rewrite `Cargo.toml`, a short mtime guard prevents a double bump. /// имя AppImage (читается скриптом до сборки) отставало от версии,
/// зашитой в бинарник. Теперь версия меняется только вручную.
fn resolve_app_version() -> String { fn resolve_app_version() -> String {
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0".into())
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<String> {
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)
} }
Vendored
BIN
View File
Binary file not shown.
+10 -3
View File
@@ -101,6 +101,9 @@ package_appimage() {
echo "Bundling libraries with linuxdeploy…" echo "Bundling libraries with linuxdeploy…"
export APPIMAGE_EXTRACT_AND_RUN=1 export APPIMAGE_EXTRACT_AND_RUN=1
export OUTPUT="${IMAGE_NAME}" export OUTPUT="${IMAGE_NAME}"
# Marker: mtime boundary to find the FRESH linuxdeploy output.
local marker="${OUT_DIR}/.pack-start"
touch "${marker}"
( (
cd "${OUT_DIR}" cd "${OUT_DIR}"
"${linuxdeploy}" \ "${linuxdeploy}" \
@@ -111,11 +114,15 @@ package_appimage() {
--output appimage --output appimage
) )
# linuxdeploy drops the AppImage next to AppDir (dist/) # linuxdeploy drops the AppImage next to AppDir (dist/). Only consider files
# NEWER than the marker: a blind `find | head -1` with several older
# *.AppImage in dist/ renamed an OLD image OVER the freshly built one
# (rcalc-0.1.28 got shipped with a 0.1.25 binary from the first build).
local built local built
built="$(find "${OUT_DIR}" -maxdepth 1 -name '*.AppImage' ! -name 'linuxdeploy*' | head -1)" built="$(find "${OUT_DIR}" -maxdepth 1 -name '*.AppImage' ! -name 'linuxdeploy*' -newer "${marker}" | head -1)"
rm -f "${marker}"
if [[ -z "$built" ]]; then if [[ -z "$built" ]]; then
echo "error: AppImage was not produced" >&2 echo "error: AppImage was not produced (linuxdeploy failed?)" >&2
exit 1 exit 1
fi fi
if [[ "$(basename "$built")" != "${IMAGE_NAME}" ]]; then if [[ "$(basename "$built")" != "${IMAGE_NAME}" ]]; then
+89 -3
View File
@@ -391,7 +391,9 @@ impl Converter {
if !ch.is_ascii_hexdigit() { if !ch.is_ascii_hexdigit() {
return; return;
} }
if self.draft == "0" { // A synced all-zero draft ("0", "0000") is a placeholder:
// first digit replaces it instead of being swallowed.
if self.draft.chars().all(|c| c == '0') {
self.draft = ch.to_string(); self.draft = ch.to_string();
} else if self.draft.len() < 4 { } else if self.draft.len() < 4 {
self.draft.push(ch); self.draft.push(ch);
@@ -478,6 +480,11 @@ impl Converter {
if t.is_empty() || t == "-" || t == "." || t == "-." { if t.is_empty() || t == "-" || t == "." || t == "-." {
return Ok(()); return Ok(());
} }
// Exponent being typed: `1E`, `1E+`, `1E-` — intermediate
// state, keep the previous value without flagging an error.
if t.ends_with('E') || t.ends_with("E+") || t.ends_with("E-") {
return Ok(());
}
match t.parse::<f32>() { match t.parse::<f32>() {
Ok(v) => { Ok(v) => {
self.float_bits = v.to_bits(); self.float_bits = v.to_bits();
@@ -777,8 +784,20 @@ fn format_float(v: f32) -> String {
"Inf".into() "Inf".into()
}; };
} }
let s = format!("{v}"); if v == 0.0 {
s // Keep IEEE −0 visible (bits 0x8000_0000).
return if v.is_sign_negative() {
"-0".into()
} else {
"0".into()
};
}
// Scientific notation for extremes: keeps f32::MAX readable and the
// draft re-parsable (`3.4028235e38`.parse::<f32>() works).
if v.abs() < 1e-10 || v.abs() >= 1e16 {
return format!("{v:e}");
}
format!("{v}")
} }
/// IEEE float bytes in natural order A B C D (big-endian bit pattern), /// IEEE float bytes in natural order A B C D (big-endian bit pattern),
@@ -889,4 +908,71 @@ mod tests {
assert_eq!(c.ascii_code(), b'A'); assert_eq!(c.ascii_code(), b'A');
assert_eq!(c.ascii_char_text(), "A"); assert_eq!(c.ascii_char_text(), "A");
} }
#[test]
fn float_draft_exponent_in_progress() {
// Typing `1E` is an intermediate state, not an error.
let mut c = Converter::new();
c.clear_active(); // draft "0"
c.input_char('1');
c.input_char('E');
assert_eq!(c.error(), None, "typing `1E` must not flag invalid float");
c.input_char('5');
assert_eq!(c.error(), None);
assert_eq!(c.float_value(), 1e5);
}
#[test]
fn float_draft_exponent_sign_in_progress() {
let mut c = Converter::new();
c.clear_active(); // draft "0"
c.input_char('1');
c.input_char('E');
c.input_char('-');
assert_eq!(c.error(), None, "typing `1E-` must not flag invalid float");
c.input_char('9');
assert_eq!(c.error(), None);
assert_eq!(c.float_value(), 1e-9);
}
#[test]
fn negative_zero_words() {
// IEEE -0.0 must round-trip through the word view (0x8000 0000).
let mut c = Converter::new();
c.clear_active(); // draft "0", float +0
c.input_char('-');
c.input_char('0');
assert_eq!(c.words(), (0x8000, 0x0000));
assert_eq!(c.float_text(), "-0");
}
#[test]
fn word_edit_updates_float() {
let mut c = Converter::new();
c.clear_active(); // float = 0
c.set_active(ConvField::Word0);
for ch in "3F80".chars() {
c.input_char(ch);
}
assert_eq!(c.float_value(), 1.0);
}
#[test]
fn huge_float_uses_scientific() {
// f32::MAX plain-decimal display is 39 chars; exponent form is
// readable and re-parsable by the float draft parser.
let t = format_float(f32::MAX);
assert!(t.contains('e'), "expected scientific notation, got {t}");
assert_eq!(t.parse::<f32>().unwrap(), f32::MAX);
let t = format_float(1e-20);
assert!(t.contains('e'), "expected scientific notation, got {t}");
assert_eq!(t.parse::<f32>().unwrap(), 1e-20);
}
#[test]
fn negative_zero_displayed() {
assert_eq!(format_float(-0.0), "-0");
assert_eq!(format_float(0.0), "0");
}
} }
+199 -37
View File
@@ -77,18 +77,27 @@ impl Rational {
Self { num: n, den: 1 } Self { num: n, den: 1 }
} }
/// Parse a decimal literal like `12`, `-3.5`, `0.001` into an exact fraction. /// Parse a decimal literal like `12`, `-3.5`, `0.001`, `1.5e-9` into an
/// exact fraction (exponent shifts by exact powers of ten).
fn parse(s: &str) -> Result<Self, CalcError> { fn parse(s: &str) -> Result<Self, CalcError> {
let s = s.trim(); let s = s.trim();
if s.is_empty() || s == "." || s == "-" || s == "-." { if s.is_empty() || s == "." || s == "-" || s == "-." {
return Err(CalcError::Invalid); return Err(CalcError::Invalid);
} }
let neg = s.starts_with('-'); // Split off an optional exponent: `1e-15`, `2.5E+3`.
let body = if neg { &s[1..] } else { s }; let (body, exp) = match s.split_once(['e', 'E']) {
Some((b, e)) => {
let exp: i32 = e.parse().map_err(|_| CalcError::Invalid)?;
(b, exp)
}
None => (s, 0i32),
};
let neg = body.starts_with('-');
let b = if neg { &body[1..] } else { body };
let (int_part, frac_part) = match body.split_once('.') { let (int_part, frac_part) = match b.split_once('.') {
Some((a, b)) => (a, b), Some((a, c)) => (a, c),
None => (body, ""), None => (b, ""),
}; };
if int_part.is_empty() && frac_part.is_empty() { if int_part.is_empty() && frac_part.is_empty() {
return Err(CalcError::Invalid); return Err(CalcError::Invalid);
@@ -107,6 +116,27 @@ impl Rational {
.and_then(|v| v.checked_add(frac_val)) .and_then(|v| v.checked_add(frac_val))
.ok_or(CalcError::Overflow)?; .ok_or(CalcError::Overflow)?;
let num = if neg { -num } else { num }; let num = if neg { -num } else { num };
// Apply the exponent with exact power-of-ten arithmetic:
// value·10^exp = num·10^exp / 10^frac_digits.
let (num, den) = if exp >= 0 {
let e = exp as usize;
if e >= frac_digits {
(
num.checked_mul(pow10(e - frac_digits)?)
.ok_or(CalcError::Overflow)?,
1,
)
} else {
(num, den / pow10(e)?)
}
} else {
(
num,
den.checked_mul(pow10((-exp) as usize)?)
.ok_or(CalcError::Overflow)?,
)
};
Self::new(num, den) Self::new(num, den)
} }
@@ -175,9 +205,14 @@ impl Value {
if !v.is_finite() { if !v.is_finite() {
return Err(CalcError::Overflow); return Err(CalcError::Overflow);
} }
// Prefer exact integer when float is (almost) integral. if v == 0.0 {
if v.fract().abs() < 1e-12 && v.abs() < 1e15 { return Ok(Value::Rat(Rational::from_i128(0)));
return Ok(Value::Rat(Rational::from_i128(v.round() as i128))); }
// Prefer exact integer when float is (almost) integral. Never snap a
// sub-unit magnitude to an integer: that turned 1e-16 into `0`.
let r = v.round();
if r != 0.0 && (v - r).abs() < 1e-12 && v.abs() < 1e15 {
return Ok(Value::Rat(Rational::from_i128(r as i128)));
} }
Ok(Value::Float(v)) Ok(Value::Float(v))
} }
@@ -394,7 +429,7 @@ impl Calculator {
self.entry = d.to_string(); self.entry = d.to_string();
} else if self.entry == "-0" { } else if self.entry == "-0" {
self.entry = format!("-{d}"); self.entry = format!("-{d}");
} else if digit_count(&self.entry) < 16 { } else if digit_count(&self.entry) < 40 && significant_digits(&self.entry) < 16 {
self.entry.push(d); self.entry.push(d);
} }
self.entry_value = None; self.entry_value = None;
@@ -911,6 +946,19 @@ fn tokenize(src: &str) -> Result<Vec<(Tok, Option<Value>)>, CalcError> {
while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') { while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') {
i += 1; i += 1;
} }
// Optional decimal exponent: `1e-9`, `2.5E+3`.
if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') {
let mut j = i + 1;
if j < chars.len() && (chars[j] == '+' || chars[j] == '-') {
j += 1;
}
if j < chars.len() && chars[j].is_ascii_digit() {
i = j;
while i < chars.len() && chars[i].is_ascii_digit() {
i += 1;
}
}
}
let lit: String = chars[start..i].iter().collect(); let lit: String = chars[start..i].iter().collect();
let v = Value::from_entry(&lit)?; let v = Value::from_entry(&lit)?;
out.push((Tok::Num, Some(v))); out.push((Tok::Num, Some(v)));
@@ -984,6 +1032,15 @@ fn digit_count(s: &str) -> usize {
s.chars().filter(|c| c.is_ascii_digit()).count() s.chars().filter(|c| c.is_ascii_digit()).count()
} }
/// Significant digits (leading zeros don't count) — caps precision, not
/// magnitude, so `0.0000000000000001` (1e-16) can be typed.
fn significant_digits(s: &str) -> usize {
s.chars()
.filter(|c| c.is_ascii_digit())
.skip_while(|&c| c == '0')
.count()
}
fn gcd(mut a: u128, mut b: u128) -> u128 { fn gcd(mut a: u128, mut b: u128) -> u128 {
while b != 0 { while b != 0 {
let t = b; let t = b;
@@ -1016,36 +1073,36 @@ fn format_rational(r: Rational) -> String {
} }
fn terminating_decimal(r: Rational) -> Option<String> { fn terminating_decimal(r: Rational) -> Option<String> {
// Terminating iff den = 2^a · 5^b. Digit count after the point is
// k = max(a, b); scale num so the denominator becomes exactly 10^k.
let mut den = r.den; let mut den = r.den;
let (mut a, mut b) = (0u32, 0u32);
while den % 2 == 0 { while den % 2 == 0 {
den /= 2; den /= 2;
a += 1;
} }
while den % 5 == 0 { while den % 5 == 0 {
den /= 5; den /= 5;
b += 1;
} }
if den != 1 { if den != 1 {
return None; return None;
} }
// Scale denominator to a power of 10. let k = a.max(b);
let mut d = r.den; let mut n = r.num.abs();
let mut n = r.num; for _ in 0..(k - a) {
let neg = n < 0; n = n.checked_mul(2)?;
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;
} }
for _ in 0..(k - b) {
n = n.checked_mul(5)?;
}
let mut d: i128 = 1;
for _ in 0..k {
d = d.checked_mul(10)?;
} }
let int_part = n / d; let int_part = n / d;
let mut frac = (n % d).to_string(); let mut frac = (n % d).to_string();
let width = d.to_string().len().saturating_sub(1); while frac.len() < k as usize {
while frac.len() < width {
frac.insert(0, '0'); frac.insert(0, '0');
} }
// trim trailing zeros // trim trailing zeros
@@ -1057,24 +1114,26 @@ fn terminating_decimal(r: Rational) -> Option<String> {
} else { } else {
format!("{int_part}.{frac}") format!("{int_part}.{frac}")
}; };
Some(if neg { format!("-{body}") } else { body }) Some(if r.num < 0 { format!("-{body}") } else { body })
} }
fn format_float(v: f64) -> String { fn format_float(v: f64) -> String {
if !v.is_finite() { if !v.is_finite() || v == 0.0 {
return "0".into(); return "0".into();
} }
// Snap near-integers (guards any float path residue). // Snap near-integers (guards any float path residue); never for |v| < 1,
if (v - v.round()).abs() < 1e-10 && v.abs() < 1e15 { // otherwise tiny results like 1e-16 collapse to `0`.
return format!("{}", v.round() as i64); let r = v.round();
if r != 0.0 && (v - r).abs() < 1e-10 && v.abs() < 1e15 {
return format!("{}", r as i64);
} }
let s = format!("{:.12}", v); // Very small / large magnitudes: scientific notation keeps the value
let s = s.trim_end_matches('0').trim_end_matches('.').to_string(); // visible and re-parseable (`Rational::parse` understands exponents).
if s.is_empty() || s == "-" { if v.abs() < 1e-10 || v.abs() >= 1e16 {
"0".into() return format!("{v:e}");
} else {
s
} }
// Shortest representation that round-trips back to `v` exactly.
format!("{v}")
} }
impl fmt::Display for Rational { impl fmt::Display for Rational {
@@ -1173,4 +1232,107 @@ mod tests {
let r = Rational::parse("1.25").unwrap(); let r = Rational::parse("1.25").unwrap();
assert_eq!(r, Rational::new(5, 4).unwrap()); assert_eq!(r, Rational::new(5, 4).unwrap());
} }
// --- regression tests (rounding / formatting bugs) ---
#[test]
fn quarter_and_eighth_display() {
// terminating_decimal scaled the fraction wrong: 1/4 displayed as "0.5".
let mut c = Calculator::new();
c.input_digit('1');
c.set_op(Op::Div);
c.input_digit('4');
c.equals();
assert_eq!(c.display(), "0.25");
c.clear_all();
c.input_digit('1');
c.set_op(Op::Div);
c.input_digit('8');
c.equals();
assert_eq!(c.display(), "0.125");
}
#[test]
fn five_hundredths_display() {
// 0.05 must survive exact formatting (was displayed as "0.1").
let mut c = Calculator::new();
c.input_digit('0');
c.input_dot();
c.input_digit('0');
c.input_digit('5'); // 0.05
c.set_op(Op::Mul);
c.input_digit('1');
c.equals();
assert_eq!(c.display(), "0.05");
}
#[test]
fn tiny_reciprocal_not_zero() {
// 1/9999999999999999 ≈ 1e-16 was snapped to "0" by format_float.
let mut c = Calculator::new();
for d in "9999999999999999".chars() {
c.input_digit(d);
}
c.reciprocal();
let disp = c.display();
assert!(disp != "0", "tiny reciprocal must not display as 0, got {disp}");
}
#[test]
fn tiny_result_reusable_standard() {
// Float result in scientific notation must be re-parsable as operand.
let mut c = Calculator::new();
for d in "9999999999999999".chars() {
c.input_digit(d);
}
c.reciprocal();
c.set_op(Op::Mul);
c.input_digit('2');
c.equals();
let disp = c.display();
assert!(disp != "0" && disp != "Invalid input", "got {disp}");
}
#[test]
fn sqrt_square_roundtrip_tiny() {
// √(1e-30) = 1e-15 was snapped to 0 by Value::from_f64.
let mut c = Calculator::new();
c.input_dot(); // "0."
for _ in 0..14 {
c.input_digit('0');
}
c.input_digit('1'); // 0.000000000000001 = 1e-15
c.square();
c.sqrt();
let disp = c.display();
assert!(disp != "0", "sqrt(1e-30) must not display as 0, got {disp}");
}
#[test]
fn rational_parse_exponent() {
// Scientific-notation entries must parse to exact fractions.
let r = Rational::parse("1e-15").unwrap();
assert_eq!(r, Rational::new(1, 1_000_000_000_000_000).unwrap());
let r = Rational::parse("1.5e3").unwrap();
assert_eq!(r, Rational::new(1500, 1).unwrap());
let r = Rational::parse("2.5e-2").unwrap();
assert_eq!(r, Rational::new(1, 40).unwrap());
let r = Rational::parse("-1E+3").unwrap();
assert_eq!(r, Rational::new(-1000, 1).unwrap());
assert!(Rational::parse("1e2e3").is_err());
assert!(Rational::parse("1e").is_err());
}
#[test]
fn can_type_one_e_minus_16() {
// Leading zeros must not eat the 16-significant-digit budget.
let mut c = Calculator::new();
c.input_dot();
for _ in 0..15 {
c.input_digit('0');
}
c.input_digit('1');
assert_eq!(c.display(), "0.0000000000000001");
}
} }
+142 -10
View File
@@ -199,7 +199,7 @@ impl Engineering {
self.entry = d.to_string(); self.entry = d.to_string();
} else if self.entry == "-0" { } else if self.entry == "-0" {
self.entry = format!("-{d}"); self.entry = format!("-{d}");
} else if digit_count(&self.entry) < 16 { } else if digit_count(&self.entry) < 40 && significant_digits(&self.entry) < 16 {
self.entry.push(d); self.entry.push(d);
} }
self.sync_expr(); self.sync_expr();
@@ -389,6 +389,26 @@ impl Engineering {
self.sync_expr(); self.sync_expr();
} }
/// Insert `× 10 ^` for scientific-notation entry: `1.5 [×10ˣ] 3 = 1500`.
/// Exponent may be negated with ± before `=` (`10 ^ −3` parses correctly).
pub fn mul_pow10(&mut self) {
if self.error.is_some() {
return;
}
if self.done {
// Continue from the result: 1500 [×10ˣ] 2 = 150000.
self.formula.clear();
self.done = false;
self.typing = true;
}
self.flush_entry();
self.formula.push_str(" × 10 ^ ");
self.entry = "0".into();
self.typing = false;
self.second = false;
self.sync_expr();
}
pub fn equals(&mut self) { pub fn equals(&mut self) {
if self.error.is_some() { if self.error.is_some() {
return; return;
@@ -550,20 +570,31 @@ fn digit_count(s: &str) -> usize {
s.chars().filter(|c| c.is_ascii_digit()).count() s.chars().filter(|c| c.is_ascii_digit()).count()
} }
/// Significant digits (leading zeros don't count) — caps precision, not
/// magnitude, so `0.0000000000000001` (1e-16) can be typed.
fn significant_digits(s: &str) -> usize {
s.chars()
.filter(|c| c.is_ascii_digit())
.skip_while(|&c| c == '0')
.count()
}
fn format_num(v: f64) -> String { fn format_num(v: f64) -> String {
if !v.is_finite() { if !v.is_finite() || v == 0.0 {
return "0".into(); return "0".into();
} }
if (v - v.round()).abs() < 1e-10 && v.abs() < 1e15 { // Snap near-integers; never for |v| < 1 (kept 10^−50 displayed as "0").
return format!("{}", v.round() as i64); let r = v.round();
if r != 0.0 && (v - r).abs() < 1e-10 && v.abs() < 1e15 {
return format!("{}", r as i64);
} }
let s = format!("{:.12}", v); // Scientific notation for extremes; `entry.parse::<f64>()` and the
let s = s.trim_end_matches('0').trim_end_matches('.').to_string(); // tokenizer both accept it, so the result stays reusable.
if s.is_empty() || s == "-" { if v.abs() < 1e-10 || v.abs() >= 1e16 {
"0".into() return format!("{v:e}");
} else {
s
} }
// Shortest round-trip representation.
format!("{v}")
} }
// --- expression evaluator --- // --- expression evaluator ---
@@ -639,6 +670,20 @@ fn tokenize(src: &str) -> Result<Vec<Tok>, SciError> {
while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') { while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') {
i += 1; i += 1;
} }
// Optional decimal exponent: `1e-9`, `2.5E+3`. The standalone
// constant `e` stays an Ident (needs no digit right after).
if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') {
let mut j = i + 1;
if j < chars.len() && (chars[j] == '+' || chars[j] == '-') {
j += 1;
}
if j < chars.len() && chars[j].is_ascii_digit() {
i = j;
while i < chars.len() && chars[i].is_ascii_digit() {
i += 1;
}
}
}
let lit: String = chars[start..i].iter().collect(); let lit: String = chars[start..i].iter().collect();
let v: f64 = lit.parse().map_err(|_| SciError::Invalid)?; let v: f64 = lit.parse().map_err(|_| SciError::Invalid)?;
out.push(Tok::Num(v)); out.push(Tok::Num(v));
@@ -858,4 +903,91 @@ mod tests {
e.equals(); e.equals();
assert_eq!(e.display(), "1024"); assert_eq!(e.display(), "1024");
} }
#[test]
fn mul_pow10_basic() {
// 1.5 ×10ˣ 3 = 1500 (× 10 ^ с приоритетом ^ над ×).
let mut e = Engineering::new();
e.input_digit('1');
e.input_dot();
e.input_digit('5');
e.mul_pow10();
e.input_digit('3');
e.equals();
assert_eq!(e.display(), "1500");
}
#[test]
fn mul_pow10_negative_exponent() {
// 2.5 ×10ˣ ±2 = 0.025.
let mut e = Engineering::new();
e.input_digit('2');
e.input_dot();
e.input_digit('5');
e.mul_pow10();
e.input_digit('2');
e.negate();
e.equals();
assert_eq!(e.display(), "0.025");
}
#[test]
fn mul_pow10_continues_result() {
// 2 × 3 = 6, затем 6 ×10ˣ 2 = 600.
let mut e = Engineering::new();
e.input_digit('2');
e.set_op("×");
e.input_digit('3');
e.equals();
e.mul_pow10();
e.input_digit('2');
e.equals();
assert_eq!(e.display(), "600");
}
#[test]
fn ten_pow_negative_not_zero() {
// 10 ^ −50 ≈ 1e-50 was snapped to "0" by format_num.
let mut e = Engineering::new();
e.input_digit('1');
e.input_digit('0');
e.set_op("^");
e.input_digit('5');
e.input_digit('0');
e.negate();
e.equals();
let disp = e.display();
assert!(disp != "0", "10^−50 must not display as 0, got {disp}");
}
#[test]
fn tiny_float_result_continues_expression() {
// Result in scientific notation must be re-parsable by the tokenizer.
let mut e = Engineering::new();
e.input_digit('1');
e.input_digit('0');
e.set_op("^");
e.input_digit('1');
e.input_digit('6');
e.negate();
e.equals();
assert_eq!(e.display(), "1e-16");
e.set_op("+");
e.input_digit('1');
e.equals();
assert_eq!(e.display(), "1");
}
#[test]
fn can_type_one_e_minus_16() {
// Leading zeros must not eat the 16-significant-digit budget.
let mut e = Engineering::new();
e.input_digit('0');
e.input_dot();
for _ in 0..15 {
e.input_digit('0');
}
e.input_digit('1');
assert_eq!(e.display(), "0.0000000000000001");
}
} }
+1
View File
@@ -532,6 +532,7 @@ fn handle_engineering(calc: &mut Engineering, id: &str) {
"eng:fact" => calc.insert_func("fact"), "eng:fact" => calc.insert_func("fact"),
"eng:exp" => calc.insert_func("exp"), "eng:exp" => calc.insert_func("exp"),
"eng:tenpow" => calc.insert_func("tenpow"), "eng:tenpow" => calc.insert_func("tenpow"),
"eng:mul10pow" => calc.mul_pow10(),
"eng:log" => calc.insert_func("log"), "eng:log" => calc.insert_func("log"),
"eng:ln" => calc.insert_func("ln"), "eng:ln" => calc.insert_func("ln"),
"MC" => calc.memory_clear(), "MC" => calc.memory_clear(),
+31
View File
@@ -699,4 +699,35 @@ mod tests {
assert_eq!(p.word_size(), WordSize::Dint); assert_eq!(p.word_size(), WordSize::Dint);
assert_eq!(p.dec_text().replace(' ', ""), "-1"); assert_eq!(p.dec_text().replace(' ', ""), "-1");
} }
#[test]
fn shift_count_mods_word_size() {
// Locks hardware-style semantics: shift count is taken modulo the
// word size (1 << 100000000 → 1e8 % 64 == 0 → no shift).
let mut p = Programmer::new();
p.input_digit('1');
p.set_op(ProgOp::Lsh);
for ch in "100000000".chars() {
p.input_digit(ch);
}
p.equals();
assert_eq!(p.current_bits(), 1);
}
#[test]
fn shift_out_of_word_gives_zero() {
// 0xFFFF_FFFF_FFFF_FFFF Lsh 63 → only bit 0 survives at bit 63.
let mut p = Programmer::new();
p.set_base(Base::Hex);
for ch in "FFFFFFFFFFFFFFFF".chars() {
p.input_digit(ch);
}
p.set_op(ProgOp::Lsh);
p.set_base(Base::Dec); // type the shift count in decimal
p.input_digit('6');
p.input_digit('3');
p.equals();
// (u64::MAX << 63) & u64::MAX = 0x8000_0000_0000_0000
assert_eq!(p.current_bits(), 0x8000_0000_0000_0000);
}
} }
+19 -12
View File
@@ -1211,7 +1211,7 @@ export component AppWindow inherits Window {
label: root.eng-second ? "³√x" : "√x"; label: root.eng-second ? "³√x" : "√x";
clicked => { root.press(root.eng-second ? "eng:cbrt" : "eng:sqrt"); } clicked => { root.press(root.eng-second ? "eng:cbrt" : "eng:sqrt"); }
} }
FuncBtn { min-h: UiStyle.btn-h-sm; label: "÷"; clicked => { root.press("/"); } } FuncBtn { min-h: UiStyle.btn-h-sm; label: "x*10^y"; clicked => { root.press("eng:mul10pow"); } }
} }
Row { Row {
FuncBtn { FuncBtn {
@@ -1222,14 +1222,14 @@ export component AppWindow inherits Window {
FuncBtn { min-h: UiStyle.btn-h-sm; label: "1/x"; clicked => { root.press("eng:inv"); } } FuncBtn { min-h: UiStyle.btn-h-sm; label: "1/x"; clicked => { root.press("eng:inv"); } }
FuncBtn { min-h: UiStyle.btn-h-sm; label: "|x|"; clicked => { root.press("eng:abs"); } } FuncBtn { min-h: UiStyle.btn-h-sm; label: "|x|"; clicked => { root.press("eng:abs"); } }
FuncBtn { min-h: UiStyle.btn-h-sm; label: "n!"; clicked => { root.press("eng:fact"); } } FuncBtn { min-h: UiStyle.btn-h-sm; label: "n!"; clicked => { root.press("eng:fact"); } }
FuncBtn { min-h: UiStyle.btn-h-sm; label: "×"; clicked => { root.press("*"); } } FuncBtn { min-h: UiStyle.btn-h-sm; label: "÷"; clicked => { root.press("/"); } }
} }
Row { Row {
FuncBtn { min-h: UiStyle.btn-h-sm; label: "x^y"; clicked => { root.press("^"); } } FuncBtn { min-h: UiStyle.btn-h-sm; label: "x^y"; clicked => { root.press("^"); } }
FuncBtn { min-h: UiStyle.btn-h-sm; label: "("; clicked => { root.press("("); } } FuncBtn { min-h: UiStyle.btn-h-sm; label: "("; clicked => { root.press("("); } }
FuncBtn { min-h: UiStyle.btn-h-sm; label: ")"; clicked => { root.press(")"); } } FuncBtn { min-h: UiStyle.btn-h-sm; label: ")"; clicked => { root.press(")"); } }
FuncBtn { min-h: UiStyle.btn-h-sm; label: "CE"; clicked => { root.press("CE"); } } FuncBtn { min-h: UiStyle.btn-h-sm; label: "CE"; clicked => { root.press("CE"); } }
FuncBtn { min-h: UiStyle.btn-h-sm; label: "−"; clicked => { root.press("-"); } } FuncBtn { min-h: UiStyle.btn-h-sm; label: "×"; clicked => { root.press("*"); } }
} }
Row { Row {
FuncBtn { FuncBtn {
@@ -1240,28 +1240,28 @@ export component AppWindow inherits Window {
DigitBtn { min-h: UiStyle.btn-h-sm; label: "7"; clicked => { root.press("7"); } } DigitBtn { min-h: UiStyle.btn-h-sm; label: "7"; clicked => { root.press("7"); } }
DigitBtn { min-h: UiStyle.btn-h-sm; label: "8"; clicked => { root.press("8"); } } DigitBtn { min-h: UiStyle.btn-h-sm; label: "8"; clicked => { root.press("8"); } }
DigitBtn { min-h: UiStyle.btn-h-sm; label: "9"; clicked => { root.press("9"); } } DigitBtn { min-h: UiStyle.btn-h-sm; label: "9"; clicked => { root.press("9"); } }
FuncBtn { min-h: UiStyle.btn-h-sm; label: "+"; clicked => { root.press("+"); } } FuncBtn { min-h: UiStyle.btn-h-sm; label: "−"; clicked => { root.press("-"); } }
} }
Row { Row {
FuncBtn { min-h: UiStyle.btn-h-sm; label: "log"; clicked => { root.press("eng:log"); } } FuncBtn { min-h: UiStyle.btn-h-sm; label: "log"; clicked => { root.press("eng:log"); } }
DigitBtn { min-h: UiStyle.btn-h-sm; label: "4"; clicked => { root.press("4"); } } DigitBtn { min-h: UiStyle.btn-h-sm; label: "4"; clicked => { root.press("4"); } }
DigitBtn { min-h: UiStyle.btn-h-sm; label: "5"; clicked => { root.press("5"); } } DigitBtn { min-h: UiStyle.btn-h-sm; label: "5"; clicked => { root.press("5"); } }
DigitBtn { min-h: UiStyle.btn-h-sm; label: "6"; clicked => { root.press("6"); } } DigitBtn { min-h: UiStyle.btn-h-sm; label: "6"; clicked => { root.press("6"); } }
DigitBtn { min-h: UiStyle.btn-h-sm; label: "±"; clicked => { root.press("neg"); } } FuncBtn { min-h: UiStyle.btn-h-sm; label: "+"; clicked => { root.press("+"); } }
} }
Row { Row {
FuncBtn { min-h: UiStyle.btn-h-sm; label: "ln"; clicked => { root.press("eng:ln"); } } FuncBtn { min-h: UiStyle.btn-h-sm; label: "ln"; clicked => { root.press("eng:ln"); } }
DigitBtn { min-h: UiStyle.btn-h-sm; label: "1"; clicked => { root.press("1"); } } DigitBtn { min-h: UiStyle.btn-h-sm; label: "1"; clicked => { root.press("1"); } }
DigitBtn { min-h: UiStyle.btn-h-sm; label: "2"; clicked => { root.press("2"); } } DigitBtn { min-h: UiStyle.btn-h-sm; label: "2"; clicked => { root.press("2"); } }
DigitBtn { min-h: UiStyle.btn-h-sm; label: "3"; clicked => { root.press("3"); } } DigitBtn { min-h: UiStyle.btn-h-sm; label: "3"; clicked => { root.press("3"); } }
EqBtn { min-h: UiStyle.btn-h-sm; clicked => { root.press("="); } } DigitBtn { min-h: UiStyle.btn-h-sm; label: "±"; clicked => { root.press("neg"); } }
} }
Row { Row {
FuncBtn { min-h: UiStyle.btn-h-sm; label: "exp"; clicked => { root.press("eng:exp"); } }
DigitBtn { min-h: UiStyle.btn-h-sm; label: "0"; clicked => { root.press("0"); } } DigitBtn { min-h: UiStyle.btn-h-sm; label: "0"; clicked => { root.press("0"); } }
DigitBtn { min-h: UiStyle.btn-h-sm; label: "."; clicked => { root.press("."); } } DigitBtn { min-h: UiStyle.btn-h-sm; label: "."; clicked => { root.press("."); } }
FuncBtn { min-h: UiStyle.btn-h-sm; label: "exp"; clicked => { root.press("eng:exp"); } }
Rectangle { min-height: 32px; background: transparent; }
Rectangle { min-height: 32px; background: transparent; } Rectangle { min-height: 32px; background: transparent; }
EqBtn { min-h: UiStyle.btn-h-sm; clicked => { root.press("="); } }
} }
} }
@@ -1293,10 +1293,17 @@ export component AppWindow inherits Window {
if root.mode == 0: GridLayout { if root.mode == 0: GridLayout {
spacing: 6px; spacing: 6px;
Row { Row {
if root.std-panel == 0: FuncBtn { label: "%"; clicked => { root.press("%"); } } // `if` внутри Row у GridLayout ломает раскладку Slint
if root.std-panel == 1: FuncBtn { label: "("; clicked => { root.press("("); } } // (RepeatedItemTree::grid_layout_input_data) — кнопки теряются.
if root.std-panel == 0: FuncBtn { label: "CE"; clicked => { root.press("CE"); } } // Поэтому одна кнопка с тернарником вместо пары условных.
if root.std-panel == 1: FuncBtn { label: ")"; clicked => { root.press(")"); } } FuncBtn {
label: root.std-panel == 0 ? "%" : "(";
clicked => { root.press(root.std-panel == 0 ? "%" : "("); }
}
FuncBtn {
label: root.std-panel == 0 ? "CE" : ")";
clicked => { root.press(root.std-panel == 0 ? "CE" : ")"); }
}
FuncBtn { label: "C"; clicked => { root.press("C"); } } FuncBtn { label: "C"; clicked => { root.press("C"); } }
FuncBtn { label: "⌫"; clicked => { root.press("BS"); } } FuncBtn { label: "⌫"; clicked => { root.press("BS"); } }
} }