Compare commits

..
3 Commits
Author SHA1 Message Date
alex 3c14906e40 deb packaging: build-deb.sh + packaging/deb, icons from ico/
- scripts/build-deb.sh: binary built in ubuntu:22.04 (glibc 2.35 baseline),
  Depends via dpkg-shlibdeps, DEB_REVISION (0.1.28-1), changelog.Debian.gz,
  postinst/postrm cache refresh, REPO=1 local apt index, self-checks
  (version/upgrade path, stale binary, desktop-file-validate)
- packaging/deb/: control.in, rcalc.desktop, appdata metainfo, changelog, hooks
- ico/: new app icon in 32/48/64/96/256/512, used for the hicolor slots
- convert.rs: fix clippy::approx_constant error in roundtrip test (PI constant)
- README: install/update/release instructions
2026-09-17 19:48:05 +03:00
alex 93e193f7fa 300826 2026-08-30 12:22:00 +03:00
alex b18dacec07 0124 2026-08-30 12:22:00 +03:00
26 changed files with 1156 additions and 151 deletions
+5
View File
@@ -1,8 +1,13 @@
/target
/target-appimage
/target-deb
/.cache
/dist/*.AppImage
/dist/rcalc.AppDir/
/dist/deb-root/
/dist/*.deb
/dist/Packages
/dist/Packages.gz
/dist/.glibc-build.txt
.rcalc_last_bump
/target-windows
Generated
+1 -1
View File
@@ -3904,7 +3904,7 @@ dependencies = [
[[package]]
name = "rcalc"
version = "0.1.21"
version = "0.1.28"
dependencies = [
"slint",
"slint-build",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rcalc"
version = "0.1.21"
version = "0.1.28"
edition = "2021"
build = "build.rs"
+71 -2
View File
@@ -12,6 +12,70 @@ cargo run --release
./dist/rcalc
```
## Install as a Debian package
```bash
./scripts/build-deb.sh # → dist/rcalc_<version>-<rev>_<arch>.deb
sudo apt install ./dist/rcalc_*_amd64.deb
rcalc # or start it from the application menu
```
The binary is compiled in an Ubuntu 22.04 container (Docker) by default, so the
package keeps a glibc 2.35 baseline and installs on Ubuntu 22.04 / 24.04 / 26.04.
`Depends:` are generated from the ELF with `dpkg-shlibdeps`.
Menu/taskbar icons come from `ico/` — every PNG there is placed into the
hicolor slot matching its real pixel size (file names do not matter), and the
remaining smaller sizes are downsampled from the biggest one. Without `ico/`
the icons are resized from `assets/icon.png`. The icon inside the window
(`ui/app.slint`) and the Windows `rcalc.exe` icon still use `assets/`.
Useful switches:
| Variable | Effect |
| --- | --- |
| `DEB_REVISION=` | packaging revision, `1` by default → `Version: 0.1.28-1` |
| `REPO=1` | also write `dist/Packages(.gz)` so `dist/` works as a local apt repo |
| `SKIP_DOCKER=1` | build on the host (needs this host's glibc on the target) |
| `PREBUILT=1` | package the existing `dist/rcalc`, no compile |
| `NO_STRIP=1` | keep symbols (bigger package) |
| `KEEP_STAGE=1` | leave `dist/deb-root/` for inspection |
| `DOCKER_IMAGE=` | other build image, e.g. `debian:12` for an older baseline |
| `DEB_MAINTAINER=` | `"Name <mail>"` in `Maintainer:` (default: git user) |
### Version and updates
Package version = `<version from Cargo.toml>-<DEB_REVISION>`, e.g. `0.1.28-1`.
* changed app code → bump `version` in `Cargo.toml` (it is also the window title);
* changed only packaging (icon, `.desktop`, `Depends`, rebuilt binary) → `DEB_REVISION=2`.
`dpkg` compares these in order (`0.1.28-1 < 0.1.28-2 < 0.1.29-1`, and
`0.1.9 < 0.1.10`), so every new build is an upgrade, never a downgrade. The
script prints the resulting upgrade path and warns if the version being built is
not newer than the installed one — an `apt upgrade` of such a package does nothing.
Release notes live in `packaging/deb/changelog` and are installed as
`/usr/share/doc/rcalc/changelog.Debian.gz`.
Update in place (config in `~/.config/rcalc/config.json` is a user file, it survives):
```bash
sudo apt install ./dist/rcalc_0.1.28-2_amd64.deb
```
Or keep `dist/` as a local repository and update with plain `apt` (build with
`REPO=1`, then point apt at the folder):
```bash
echo 'deb [trusted=yes] file:/path/to/RCalc/dist ./' | sudo tee /etc/apt/sources.list.d/rcalc.list
sudo apt update && sudo apt install rcalc # later: sudo apt upgrade
```
> If `scripts/install-desktop.sh` was ever run, `~/.local/bin/rcalc` (a symlink to
> `dist/rcalc`) shadows `/usr/bin/rcalc` — remove it after switching to the package,
> otherwise `rcalc` in the terminal still starts the old binary. The script warns
> about this during the build.
### Icon (Linux / Wayland)
On **Wayland** the window `icon` property is ignored — the desktop uses
@@ -27,8 +91,9 @@ clicking a random binary path, so KDE/GNOME can match `app_id=rcalc` to
## Features
- Modes: **Standard**, **Programmer**, **Convert**
- Standard: `+ − × ÷`, `%`, `±`, `1/x`, `x²`, `√x`, memory
- Modes: **Standard**, **Engineering**, **Programmer**, **Convert**, **CRC**, **Settings**
- Standard: `+ − × ÷`, `%`, `±`, `1/x`, `x²`, `√x`, memory, formula panel
- Engineering: trig functions, log/pow, DEG/RAD, SI prefixes
- Programmer: simultaneous **HEX / DEC / OCT / BIN**, sizes **QWORD / DWORD / DINT / WORD / INT / BYTE**,
bitwise ops, clickable bits
(WORD/DWORD — unsigned DEC; INT/DINT — signed)
@@ -46,3 +111,7 @@ clicking a random binary path, so KDE/GNOME can match `app_id=rcalc` to
- `src/main.rs` — UI wiring
- `ui/app.slint` — Slint interface
- `dist/rcalc` — release binary
- `scripts/build-deb.sh` + `packaging/deb/` — .deb package (control, desktop
file, AppStream metainfo, maintainer scripts)
- `scripts/build-appimage.sh` — portable AppImage
- `scripts/install-desktop.sh` — per-user launcher + icon (no root needed)
+5 -80
View File
@@ -1,7 +1,4 @@
use std::env;
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
fn main() {
let version = resolve_app_version();
@@ -38,82 +35,10 @@ fn embed_windows_icon() {
/// 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.
/// Always the version from `Cargo.toml`. Раньше каждая release-сборка
/// инкрементировала patch в Cargo.toml посреди сборки — из-за этого
/// имя AppImage (читается скриптом до сборки) отставало от версии,
/// зашитой в бинарник. Теперь версия меняется только вручную.
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<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)
env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0".into())
}
Vendored
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+12
View File
@@ -0,0 +1,12 @@
rcalc (0.1.28-1) unstable; urgency=medium
* Upstream 0.1.28 (version from Cargo.toml), first Debian packaging.
* Binary is built in an Ubuntu 22.04 container (glibc 2.35 baseline), so the
package installs on Ubuntu 22.04 / 24.04 / 26.04.
* Ships /usr/bin/rcalc, /usr/share/applications/rcalc.desktop, hicolor icons
taken from ico/, AppStream metainfo and postinst/postrm cache refresh hooks.
* Depends are generated from the ELF with dpkg-shlibdeps.
* Build fix: clippy::approx_constant error in the convert round-trip test
(3.1415927 -> std::f32::consts::PI).
-- alexMB5300 <prom-ing@yandex.ru> Thu, 17 Sep 2026 19:12:31 +0300
+23
View File
@@ -0,0 +1,23 @@
Package: rcalc
Version: @VERSION@
Architecture: @ARCHITECTURE@
Maintainer: @MAINTAINER@
Section: math
Priority: optional
Installed-Size: @INSTALLED_SIZE@
Depends: @DEPENDS@
Recommends: libegl1, libgl1, libx11-6, libwayland-client0
Description: desktop calculator with programmer, engineering and convert tools
Calculator inspired by the Windows Calculator (standard mode), written in
Rust with the Slint UI toolkit. Modes: Standard, Engineering, Programmer,
Convert, CRC and Settings.
.
Programmer mode shows HEX / DEC / OCT / BIN at once, with QWORD / DWORD /
DINT / WORD / INT / BYTE word sizes, bitwise operators and clickable bits.
.
Convert mode maps a float32 to two 16-bit PLC words in every byte order
(AB CD / CD AB / BA DC / DC BA), approximates electronic gear ratios as
num/den and converts Char / Hex / Dec ASCII codes.
.
CRC mode computes Modbus RTU CRC-16 and Modbus ASCII LRC from a string of
hex bytes, e.g. "01 03 00 00 00 0A".
+31
View File
@@ -0,0 +1,31 @@
#!/bin/sh
# Maintainer script of the generated rcalc .deb (see scripts/build-deb.sh).
set -e
case "$1" in
configure)
;;
abort-upgrade|abort-remove|abort-deconfigure)
exit 0
;;
*)
echo "postinst called with unknown argument \`$1'" >&2
exit 1
;;
esac
# Refresh the XDG caches. Never fail the installation because of them: the
# tools may be absent (minimal systems, chroot, dpkg -x in a container).
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database -q /usr/share/applications || true
fi
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true
fi
if command -v appstreamcli >/dev/null 2>&1; then
appstreamcli refresh --force >/dev/null 2>&1 || true
fi
exit 0
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
# Maintainer script of the generated rcalc .deb (see scripts/build-deb.sh).
set -e
case "$1" in
remove|upgrade|disappear|failed-upgrade|abort-install|abort-upgrade|purge)
;;
*)
echo "postrm called with unknown argument \`$1'" >&2
exit 1
;;
esac
# Rebuild the caches after the files were (or are about to be) removed.
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database -q /usr/share/applications || true
fi
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true
fi
if command -v appstreamcli >/dev/null 2>&1; then
appstreamcli refresh --force >/dev/null 2>&1 || true
fi
exit 0
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- @RELEASE_DATE@ = UTC date of the build; @VERSION@ comes from Cargo.toml. -->
<component type="desktop-application">
<id>rcalc.desktop</id>
<metadata_license>CC0-1.0</metadata_license>
<!-- LICENSE file is missing in the repository: NOASSERTION until it is added. -->
<project_license>NOASSERTION</project_license>
<name>Rcalc</name>
<summary>Calculator with programmer, engineering, convert and CRC modes</summary>
<description>
<p>
Desktop calculator inspired by the Windows Calculator. Standard mode uses
exact rational arithmetic, so 0.1 + 0.2 really gives 0.3.
</p>
<ul>
<li>Standard: + − × ÷, %, ±, 1/x, x², √x, memory, formula panel</li>
<li>Engineering: trigonometry, log/pow, DEG/RAD, SI prefixes</li>
<li>Programmer: simultaneous HEX / DEC / OCT / BIN, QWORD … BYTE word
sizes, bitwise ops, clickable bits</li>
<li>Convert: float32 to two 16-bit words (AB CD / CD AB / BA DC / DC BA),
gear ratio num/den, ASCII codes</li>
<li>CRC: Modbus RTU CRC-16 and Modbus ASCII LRC from hex bytes</li>
<li>Full keyboard input, three themes, three button sizes</li>
</ul>
</description>
<categories>
<category>Utility</category>
<category>Calculator</category>
<category>Science</category>
</categories>
<releases>
<release version="@VERSION@" date="@RELEASE_DATE@"/>
</releases>
<content_rating type="oars-1.1"/>
</component>
+13
View File
@@ -0,0 +1,13 @@
[Desktop Entry]
Type=Application
Name=Rcalc
GenericName=Calculator
Comment=Calculator with Programmer, Engineering, Convert and CRC tools
Exec=rcalc
Icon=rcalc
Terminal=false
Categories=Utility;Calculator;
Keywords=calculator;programmer;hex;bitwise;modbus;crc;lrc;
StartupNotify=true
# Wayland app_id / X11 WM_CLASS — must match set_xdg_app_id("rcalc") in src/main.rs
StartupWMClass=rcalc
+10 -3
View File
@@ -101,6 +101,9 @@ package_appimage() {
echo "Bundling libraries with linuxdeploy…"
export APPIMAGE_EXTRACT_AND_RUN=1
export OUTPUT="${IMAGE_NAME}"
# Marker: mtime boundary to find the FRESH linuxdeploy output.
local marker="${OUT_DIR}/.pack-start"
touch "${marker}"
(
cd "${OUT_DIR}"
"${linuxdeploy}" \
@@ -111,11 +114,15 @@ package_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
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
echo "error: AppImage was not produced" >&2
echo "error: AppImage was not produced (linuxdeploy failed?)" >&2
exit 1
fi
if [[ "$(basename "$built")" != "${IMAGE_NAME}" ]]; then
+439
View File
@@ -0,0 +1,439 @@
#!/usr/bin/env bash
# Build a Debian package (.deb) for rcalc.
#
# By default the release binary is compiled inside Ubuntu 22.04 (glibc 2.35) —
# the same baseline and the same cargo cache (target-appimage) as
# scripts/build-appimage.sh — so the package installs on Ubuntu 22.04 / 24.04
# and not only on this rolling host distro.
#
# Package layout (FHS):
# /usr/bin/rcalc stripped release binary
# /usr/share/applications/rcalc.desktop launcher (Exec=rcalc)
# /usr/share/icons/hicolor/*x*/apps/rcalc.png icons from ico/ (or assets/)
# /usr/share/metainfo/rcalc.appdata.xml AppStream metadata
# /usr/share/doc/rcalc/README.md
#
# Env switches:
# DEB_REVISION packaging revision appended to the Cargo.toml version
# (0.1.28 + 1 → "Version: 0.1.28-1"); bump it for updates that
# change only the package, not the app
# REPO=1 also generate dist/Packages(.gz) → dist/ usable as an apt repo
# SKIP_DOCKER=1 build on the host instead of Docker (fast, but the deb will
# then require this host's glibc — see DEPENDS below)
# PREBUILT=1 do not compile, package the existing dist/rcalc as-is
# NO_STRIP=1 keep symbols in the binary (much bigger package)
# KEEP_STAGE=1 leave dist/deb-root/ around for inspection
# DOCKER_IMAGE build container image (default: ubuntu:22.04)
# DEB_MAINTAINER "Name <mail>" (default: git user.name/user.email)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PKG="rcalc"
OUT_DIR="${OUT_DIR:-${ROOT}/dist}"
META_DIR="${ROOT}/packaging/deb"
STAGE="${OUT_DIR}/deb-root"
ARCH="$(dpkg --print-architecture 2>/dev/null || uname -m)"
VERSION="$(grep -m1 '^version' "${ROOT}/Cargo.toml" | sed 's/.*"\(.*\)".*/\1/')"
# Debian version = upstream (Cargo.toml) + packaging revision: "0.1.28-1".
# The revision lets you ship an update (fixed desktop file, new icon, rebuilt
# binary) WITHOUT touching Cargo.toml; dpkg compares 0.1.28-1 < 0.1.28-2 <
# 0.1.29-1 correctly. DEB_REVISION is the knob for that.
DEB_REVISION="${DEB_REVISION:-1}"
DEB_VERSION="${VERSION}-${DEB_REVISION}"
DEB_FILE="${OUT_DIR}/${PKG}_${DEB_VERSION}_${ARCH}.deb"
DOCKER_IMAGE="${DOCKER_IMAGE:-ubuntu:22.04}"
SKIP_DOCKER="${SKIP_DOCKER:-0}"
PREBUILT="${PREBUILT:-0}"
NO_STRIP="${NO_STRIP:-0}"
KEEP_STAGE="${KEEP_STAGE:-0}"
# Nested $( ) with quotes inside ${:- } is parsed unreliably by bash — resolve first.
GIT_NAME="$(git -C "${ROOT}" config user.name 2>/dev/null || true)"
GIT_MAIL="$(git -C "${ROOT}" config user.email 2>/dev/null || true)"
MAINTAINER="${DEB_MAINTAINER:-${GIT_NAME:-${PKG}-packaging} <${GIT_MAIL:-localhost}>}"
need_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "error: required command not found: $1" >&2
exit 1
}
}
# ---------------------------------------------------------------- binary ----
build_binary_docker() {
need_cmd docker
echo "Building release binary in ${DOCKER_IMAGE} (glibc 2.35 baseline)…"
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
'
}
build_binary_host() {
echo "Building release binary on the host ($(ldd --version | head -1))…"
(cd "${ROOT}" && cargo build --release)
install -D -m755 "${ROOT}/target/release/${PKG}" "${OUT_DIR}/${PKG}"
ldd --version | head -1 > "${OUT_DIR}/.glibc-build.txt" || true
}
ensure_binary() {
mkdir -p "${OUT_DIR}"
if [[ "${PREBUILT}" == "1" ]]; then
echo "PREBUILT=1: packaging ${OUT_DIR}/${PKG} as-is"
elif [[ "${SKIP_DOCKER}" == "1" ]]; then
build_binary_host
else
build_binary_docker
fi
if [[ ! -x "${OUT_DIR}/${PKG}" ]]; then
echo "error: ${OUT_DIR}/${PKG} missing — run without PREBUILT=1" >&2
exit 1
fi
}
# ---------------------------------------------------------------- staging ---
stage_tree() {
rm -rf "${STAGE}"
mkdir -p \
"${STAGE}/DEBIAN" \
"${STAGE}/usr/bin" \
"${STAGE}/usr/share/applications" \
"${STAGE}/usr/share/metainfo" \
"${STAGE}/usr/share/doc/${PKG}"
install -m755 "${OUT_DIR}/${PKG}" "${STAGE}/usr/bin/${PKG}"
if [[ "${NO_STRIP}" != "1" ]] && command -v strip >/dev/null 2>&1; then
strip --strip-unneeded "${STAGE}/usr/bin/${PKG}"
fi
install -m644 "${META_DIR}/${PKG}.desktop" \
"${STAGE}/usr/share/applications/${PKG}.desktop"
sed -e "s|@VERSION@|${VERSION}|" \
-e "s|@RELEASE_DATE@|$(date -u +%Y-%m-%d)|" \
"${META_DIR}/${PKG}.appdata.xml.in" \
> "${STAGE}/usr/share/metainfo/${PKG}.appdata.xml"
gzip -9 -c "${ROOT}/README.md" > "${STAGE}/usr/share/doc/${PKG}/README.md.gz"
echo "Binary: $(file -b "${STAGE}/usr/bin/${PKG}" | cut -d, -f1-3)"
}
# Icons for the desktop entry: /usr/share/icons/hicolor/<N>x<N>/apps/rcalc.png
#
# Source of truth, in order:
# 1. ico/*.png — ready-made sizes dropped in by hand. The REAL pixel size of
# each file decides which hicolor slot it fills (file names are ignored),
# and ico/ wins for the whole family: mixing ico/ artwork with assets/
# artwork would show a different picture depending on the icon size.
# 2. assets/icon.png resized to the standard sizes (needs ImageMagick).
# 3. the three PNGs bundled in assets/, as-is.
stage_icons() {
local dst="${STAGE}/usr/share/icons/hicolor"
local prepared=0 master="" master_size=0 size
local magick=""
for tool in magick convert; do
if command -v "${tool}" >/dev/null 2>&1; then
magick="${tool}"
break
fi
done
if [[ -d "${ROOT}/ico" ]] && [[ -n "${magick}" ]]; then
for f in "${ROOT}"/ico/*.png; do
[[ -f "${f}" ]] || continue
local w h
read -r w h <<<"$(identify -format '%w %h' "${f}" 2>/dev/null || echo '0 0')" || true
[[ "${w}" == "${h}" ]] && [[ "${w}" -gt 0 ]] || continue
if (( w > master_size )); then
master="${f}"
master_size="${w}"
fi
local slot="${dst}/${w}x${w}/apps/${PKG}.png"
if [[ ! -e "${slot}" ]]; then
mkdir -p "${dst}/${w}x${w}/apps"
install -m644 "${f}" "${slot}"
prepared=$((prepared + 1))
fi
done
fi
if (( prepared > 0 )); then
# Fill the remaining standard sizes from the biggest ico/ file — never
# upscale, so a 512px source does not produce a blurry 1024px icon.
if [[ -n "${magick}" ]]; then
for size in 16 24 32 48 64 96 128 256 512; do
[[ -e "${dst}/${size}x${size}/apps/${PKG}.png" ]] && continue
(( size <= master_size )) || continue
mkdir -p "${dst}/${size}x${size}/apps"
"${magick}" "${master}" -resize "${size}x${size}" \
"${dst}/${size}x${size}/apps/${PKG}.png"
done
fi
echo "Icons: ${prepared} ready-made from ico/ (master ${master_size}px)"
return 0
fi
if [[ -n "${magick}" ]]; then
for size in 16 24 32 48 64 128 256 512 1024; do
mkdir -p "${dst}/${size}x${size}/apps"
"${magick}" "${ROOT}/assets/icon.png" -resize "${size}x${size}" \
"${dst}/${size}x${size}/apps/${PKG}.png"
done
echo "Icons: resized from assets/icon.png (1024px master)"
else
install -D -m644 "${ROOT}/assets/icon-64.png" "${dst}/64x64/apps/${PKG}.png"
install -D -m644 "${ROOT}/assets/icon-256.png" "${dst}/256x256/apps/${PKG}.png"
install -D -m644 "${ROOT}/assets/icon.png" "${dst}/1024x1024/apps/${PKG}.png"
echo "Icons: assets/*.png as-is (ImageMagick not found)"
fi
}
# ------------------------------------------------------------ dependencies --
# Real library dependencies, read from the ELF itself. GL / X11 / Wayland
# libraries are dlopen()ed by winit at runtime, so they never show up in
# NEEDED — they are listed in Recommends in control.in instead.
compute_depends() {
local depends=""
if [[ "${USE_SHLIBDEPS:-1}" == "1" ]] && command -v dpkg-shlibdeps >/dev/null 2>&1; then
local tmp
tmp="$(mktemp -d)"
mkdir -p "${tmp}/debian"
# dpkg-shlibdeps insists on reading a debian/control next to it.
cat > "${tmp}/debian/control" <<EOF
Source: ${PKG}
Package: ${PKG}
Architecture: ${ARCH}
Depends: \${shlibs:Depends}
Description: temporary, for dpkg-shlibdeps
EOF
: > "${tmp}/debian/${PKG}.substvars"
(cd "${tmp}" && dpkg-shlibdeps -T"debian/${PKG}.substvars" "${STAGE}/usr/bin/${PKG}") \
>/dev/null 2>&1 || true
depends="$(sed -n 's/^shlibs:Depends=//p' "${tmp}/debian/${PKG}.substvars")"
rm -rf "${tmp}"
fi
if [[ -z "${depends}" ]]; then
echo "warning: dpkg-shlibdeps gave nothing, using a hand-written Depends list" >&2
depends='libc6, libfontconfig1, libgcc-s1'
fi
printf '%s' "${depends}"
}
# ---------------------------------------------------------------- control ---
stage_control() {
local installed_size depends
installed_size="$(du -s -k --exclude="${STAGE}/DEBIAN" "${STAGE}" | awk '{print $1}')"
depends="$(compute_depends)"
sed -e "s|@VERSION@|${DEB_VERSION}|" \
-e "s|@ARCHITECTURE@|${ARCH}|" \
-e "s|@MAINTAINER@|${MAINTAINER}|" \
-e "s|@INSTALLED_SIZE@|${installed_size}|" \
-e "s|@DEPENDS@|${depends}|" \
"${META_DIR}/control.in" > "${STAGE}/DEBIAN/control"
# Maintainer scripts must be executables owned by root in the archive.
install -m755 "${META_DIR}/postinst" "${STAGE}/DEBIAN/postinst"
install -m755 "${META_DIR}/postrm" "${STAGE}/DEBIAN/postrm"
(cd "${STAGE}" && find . -path ./DEBIAN -prune -o -type f -print \
| sed 's|^\./||' | sort | xargs -r md5sum) > "${STAGE}/DEBIAN/md5sums"
echo "Version: ${DEB_VERSION} (upstream ${VERSION} + revision ${DEB_REVISION})"
echo "Architecture: ${ARCH}"
echo "Maintainer: ${MAINTAINER}"
echo "Installed-Size: ${installed_size} KB"
echo "Depends: ${depends}"
}
# ------------------------------------------------------- version / update ---
# What "apt upgrade" needs: a version that is strictly greater than the one
# already installed, and a package name that does not change. Both are checked
# here so a release is never silently unpromotable.
check_version() {
case "${VERSION}" in
[0-9]*) ;;
*) echo "error: upstream version '${VERSION}' must start with a digit (dpkg rule)" >&2; exit 1 ;;
esac
if [[ ! "${VERSION}" =~ ^[0-9A-Za-z.+-~:]+$ || ! "${DEB_REVISION}" =~ ^[0-9A-Za-z.+-~]+$ ]]; then
echo "error: bad version '${DEB_VERSION}' — allowed chars are letters, digits and . + - ~ :" >&2
exit 1
fi
local installed
installed="$(dpkg-query -W -f='${Version}' "${PKG}" 2>/dev/null || true)"
if [[ -n "${installed}" ]]; then
if dpkg --compare-versions "${installed}" ge "${DEB_VERSION}"; then
echo "warning: ${PKG} ${installed} is ALREADY installed and is not older than ${DEB_VERSION}" >&2
echo " → 'apt upgrade' will do nothing. Bump Cargo.toml or DEB_REVISION." >&2
else
echo "Upgrade path: ${installed} → ${DEB_VERSION} (apt upgrade will pick this up)"
fi
else
echo "Upgrade path: fresh install of ${DEB_VERSION}"
fi
# scripts/install-desktop.sh links ~/.local/bin/rcalc → dist/rcalc, and
# ~/.local/bin comes before /usr/bin in PATH: the packaged binary would be
# shadowed by an old copy even after a successful upgrade.
if [[ -e "${HOME}/.local/bin/${PKG}" ]]; then
echo "warning: ${HOME}/.local/bin/${PKG} exists and shadows /usr/bin/${PKG}" >&2
echo " (installed by scripts/install-desktop.sh — remove it after switching to the .deb)" >&2
fi
}
# /usr/share/doc/rcalc/changelog.Debian.gz — Debian policy expects a changelog,
# and it is the human-readable history of what every version changed.
# packaging/deb/changelog (kept in git) wins; otherwise one entry is generated
# from the current commit so the package is never changelog-less.
stage_changelog() {
local doc="${STAGE}/usr/share/doc/${PKG}"
mkdir -p "${doc}"
local plain="${doc}/changelog.Debian" # gzip → changelog.Debian.gz (Debian-side history)
if [[ -f "${META_DIR}/changelog" ]]; then
cp "${META_DIR}/changelog" "${plain}"
echo "Changelog: packaging/deb/changelog"
else
local githash gitsubject
githash="$(git -C "${ROOT}" rev-parse --short HEAD 2>/dev/null || echo unknown)"
gitsubject="$(git -C "${ROOT}" log -1 --pretty=%s 2>/dev/null || echo packaging)"
{
printf '%s (%s) unstable; urgency=medium\n\n' "${PKG}" "${DEB_VERSION}"
printf ' * Upstream %s, packaged as %s.\n' "${VERSION}" "${DEB_VERSION}"
if [[ -f "${OUT_DIR}/.glibc-build.txt" ]]; then
printf ' * Built against %s\n' "$(cat "${OUT_DIR}/.glibc-build.txt")"
fi
printf ' * git %s: %s\n\n' "${githash}" "${gitsubject}"
printf ' -- %s %s\n' "${MAINTAINER}" "$(date -R)"
} > "${plain}"
echo "Changelog: generated (add packaging/deb/changelog to keep real history)"
fi
gzip -9n "${plain}" # -n: no timestamp → reproducible .gz
rm -f "${plain}"
}
# REPO=1 → turn dist/ into a flat apt repository, so updates can be installed
# with plain `apt update && apt upgrade` instead of pointing at a .deb file.
make_repo() {
[[ "${REPO:-0}" == "1" ]] || return 0
need_cmd dpkg-scanpackages
(
cd "${OUT_DIR}"
# One pass: Packages is the index, Packages.gz the compressed copy apt reads.
# Keep both files: apt reads Packages.gz, humans diff/read Packages.
dpkg-scanpackages --multiversion . /dev/null > Packages 2>/dev/null
gzip -9n -c Packages > Packages.gz
)
echo "Repo index: ${OUT_DIR}/Packages.gz ($(grep -c '^Package:' "${OUT_DIR}/Packages") entries)"
echo " apt one-liner: echo 'deb [trusted=yes] file:${OUT_DIR} ./' \\"
echo " | sudo tee /etc/apt/sources.list.d/${PKG}.list"
echo " then: sudo apt update && sudo apt install ${PKG}"
}
# ------------------------------------------------------------------ build ---
build_package() {
need_cmd dpkg-deb
rm -f "${DEB_FILE}"
# The stage tree inherits the build user's umask (often 002 → group-writable).
# Debian expects 755 dirs / 644 data files, plus the exec bits on the binary
# and the maintainer scripts.
find "${STAGE}" -type d -exec chmod 0755 {} +
find "${STAGE}" -type f -exec chmod 0644 {} +
chmod 0755 "${STAGE}/usr/bin/${PKG}" \
"${STAGE}/DEBIAN/postinst" "${STAGE}/DEBIAN/postrm"
# --root-owner-group: the stage tree is owned by the build user, the archive
# must not be (dpkg rejects foreign uids unless forced).
dpkg-deb --root-owner-group --build "${STAGE}" "${DEB_FILE}"
}
verify_package() {
local ok=1
# Fields straight from the archive (dpkg-deb --info output is localised).
dpkg-deb --field "${DEB_FILE}"
if command -v desktop-file-validate >/dev/null 2>&1; then
if desktop-file-validate "${STAGE}/usr/share/applications/${PKG}.desktop"; then
echo "desktop-file-validate: OK"
else
echo "warning: the desktop file did not validate" >&2
ok=0
fi
fi
# dist/rcalc can be stale: build.rs embeds APP_VERSION from Cargo.toml only at
# compile time, and the AppImage packaging had exactly that bug (old binary in
# a new image). Anything newer than the packaged binary means "rebuild it".
local stale
stale="$(find "${ROOT}/src" "${ROOT}/ui" "${ROOT}/build.rs" "${ROOT}/Cargo.toml" \
-newer "${OUT_DIR}/${PKG}" -print -quit 2>/dev/null || true)"
if [[ -n "${stale}" ]]; then
echo "warning: ${stale} is newer than the packaged binary" >&2
echo " (PREBUILT=1 / SKIP_DOCKER=1 reused an old dist/${PKG} — rebuild to be safe)" >&2
ok=0
fi
echo "Files in package: $(dpkg-deb -c "${DEB_FILE}" | grep -c '^-')"
if [[ "${KEEP_STAGE}" != "1" ]]; then
rm -rf "${STAGE}"
fi
echo
ls -l "${DEB_FILE}"
[[ "${ok}" == "1" ]] || echo "(check the warnings above)"
}
check_version
ensure_binary
stage_tree
stage_icons
stage_changelog
stage_control
build_package
make_repo
verify_package
echo
echo "OK: ${DEB_FILE}"
echo "Version: ${DEB_VERSION} (upstream ${VERSION} + revision ${DEB_REVISION})"
echo "Install: sudo apt install '${DEB_FILE}'"
echo "Next release: bump version in Cargo.toml (app changes) — or just"
echo " DEB_REVISION=2 ./scripts/build-deb.sh (packaging/icon only),"
echo " then install the new .deb: apt replaces the old one in place."
echo "Run: rcalc (or start it from the application menu)"
echo "Remove: sudo apt remove ${PKG}"
+90 -4
View File
@@ -391,7 +391,9 @@ impl Converter {
if !ch.is_ascii_hexdigit() {
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();
} else if self.draft.len() < 4 {
self.draft.push(ch);
@@ -478,6 +480,11 @@ impl Converter {
if t.is_empty() || t == "-" || t == "." || t == "-." {
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>() {
Ok(v) => {
self.float_bits = v.to_bits();
@@ -777,8 +784,20 @@ fn format_float(v: f32) -> String {
"Inf".into()
};
}
let s = format!("{v}");
s
if v == 0.0 {
// 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),
@@ -839,7 +858,7 @@ mod tests {
#[test]
fn roundtrip_all_endians() {
let samples = [0.0f32, -1.0, 3.1415927, 12345.678, f32::MIN_POSITIVE];
let samples = [0.0f32, -1.0, std::f32::consts::PI, 12345.678, f32::MIN_POSITIVE];
for e in Endian::all() {
for &f in &samples {
let (w0, w1) = float_to_words(f, e);
@@ -889,4 +908,71 @@ mod tests {
assert_eq!(c.ascii_code(), b'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");
}
}
+200 -38
View File
@@ -77,18 +77,27 @@ impl Rational {
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> {
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 };
// Split off an optional exponent: `1e-15`, `2.5E+3`.
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('.') {
Some((a, b)) => (a, b),
None => (body, ""),
let (int_part, frac_part) = match b.split_once('.') {
Some((a, c)) => (a, c),
None => (b, ""),
};
if int_part.is_empty() && frac_part.is_empty() {
return Err(CalcError::Invalid);
@@ -107,6 +116,27 @@ impl Rational {
.and_then(|v| v.checked_add(frac_val))
.ok_or(CalcError::Overflow)?;
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)
}
@@ -175,9 +205,14 @@ impl Value {
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)));
if v == 0.0 {
return Ok(Value::Rat(Rational::from_i128(0)));
}
// 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))
}
@@ -394,7 +429,7 @@ impl Calculator {
self.entry = d.to_string();
} else if self.entry == "-0" {
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_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] == '.') {
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 v = Value::from_entry(&lit)?;
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()
}
/// 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 {
while b != 0 {
let t = b;
@@ -1016,36 +1073,36 @@ fn format_rational(r: Rational) -> 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 a, mut b) = (0u32, 0u32);
while den % 2 == 0 {
den /= 2;
a += 1;
}
while den % 5 == 0 {
den /= 5;
b += 1;
}
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 k = a.max(b);
let mut n = r.num.abs();
for _ in 0..(k - a) {
n = n.checked_mul(2)?;
}
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 mut frac = (n % d).to_string();
let width = d.to_string().len().saturating_sub(1);
while frac.len() < width {
while frac.len() < k as usize {
frac.insert(0, '0');
}
// trim trailing zeros
@@ -1057,24 +1114,26 @@ fn terminating_decimal(r: Rational) -> Option<String> {
} else {
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 {
if !v.is_finite() {
if !v.is_finite() || v == 0.0 {
return "0".into();
}
// Snap near-integers (guards any float path residue).
if (v - v.round()).abs() < 1e-10 && v.abs() < 1e15 {
return format!("{}", v.round() as i64);
// Snap near-integers (guards any float path residue); never for |v| < 1,
// otherwise tiny results like 1e-16 collapse to `0`.
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);
let s = s.trim_end_matches('0').trim_end_matches('.').to_string();
if s.is_empty() || s == "-" {
"0".into()
} else {
s
// Very small / large magnitudes: scientific notation keeps the value
// visible and re-parseable (`Rational::parse` understands exponents).
if v.abs() < 1e-10 || v.abs() >= 1e16 {
return format!("{v:e}");
}
// Shortest representation that round-trips back to `v` exactly.
format!("{v}")
}
impl fmt::Display for Rational {
@@ -1173,4 +1232,107 @@ mod tests {
let r = Rational::parse("1.25").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();
} else if self.entry == "-0" {
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.sync_expr();
@@ -389,6 +389,26 @@ impl Engineering {
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) {
if self.error.is_some() {
return;
@@ -550,20 +570,31 @@ fn digit_count(s: &str) -> usize {
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 {
if !v.is_finite() {
if !v.is_finite() || v == 0.0 {
return "0".into();
}
if (v - v.round()).abs() < 1e-10 && v.abs() < 1e15 {
return format!("{}", v.round() as i64);
// Snap near-integers; never for |v| < 1 (kept 10^−50 displayed as "0").
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);
let s = s.trim_end_matches('0').trim_end_matches('.').to_string();
if s.is_empty() || s == "-" {
"0".into()
} else {
s
// Scientific notation for extremes; `entry.parse::<f64>()` and the
// tokenizer both accept it, so the result stays reusable.
if v.abs() < 1e-10 || v.abs() >= 1e16 {
return format!("{v:e}");
}
// Shortest round-trip representation.
format!("{v}")
}
// --- 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] == '.') {
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 v: f64 = lit.parse().map_err(|_| SciError::Invalid)?;
out.push(Tok::Num(v));
@@ -858,4 +903,91 @@ mod tests {
e.equals();
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:exp" => calc.insert_func("exp"),
"eng:tenpow" => calc.insert_func("tenpow"),
"eng:mul10pow" => calc.mul_pow10(),
"eng:log" => calc.insert_func("log"),
"eng:ln" => calc.insert_func("ln"),
"MC" => calc.memory_clear(),
+31
View File
@@ -699,4 +699,35 @@ mod tests {
assert_eq!(p.word_size(), WordSize::Dint);
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";
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 {
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: "|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: "×"; clicked => { root.press("*"); } }
FuncBtn { min-h: UiStyle.btn-h-sm; label: "÷"; clicked => { root.press("/"); } }
}
Row {
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: "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 {
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: "8"; clicked => { root.press("8"); } }
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 {
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: "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: "±"; clicked => { root.press("neg"); } }
FuncBtn { min-h: UiStyle.btn-h-sm; label: "+"; clicked => { root.press("+"); } }
}
Row {
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: "2"; clicked => { root.press("2"); } }
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 {
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: "."; 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; }
EqBtn { min-h: UiStyle.btn-h-sm; clicked => { root.press("="); } }
}
}
@@ -1293,10 +1293,17 @@ export component AppWindow inherits Window {
if root.mode == 0: GridLayout {
spacing: 6px;
Row {
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(")"); } }
// `if` внутри Row у GridLayout ломает раскладку Slint
// (RepeatedItemTree::grid_layout_input_data) — кнопки теряются.
// Поэтому одна кнопка с тернарником вместо пары условных.
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: "⌫"; clicked => { root.press("BS"); } }
}