Lots of owork on a calculator example, transparency, etc.
This commit is contained in:
15
examples/calculator/Cargo.toml
Normal file
15
examples/calculator/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "calculator"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "calculator"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
ruin_app = { path = "../../lib/ruin_app" }
|
||||
ruin_ui = { path = "../../lib/ui" }
|
||||
ruin-runtime = { path = "../../lib/runtime" }
|
||||
meval = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
375
examples/calculator/src/components.rs
Normal file
375
examples/calculator/src/components.rs
Normal file
@@ -0,0 +1,375 @@
|
||||
use ruin_app::prelude::*;
|
||||
use ruin_ui::{BoxShadow, BoxShadowKind, Point, TextFontFamily};
|
||||
|
||||
use crate::state::{CalcMode, CalcState};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[component]
|
||||
pub fn Display(state: CalcState) -> impl IntoView {
|
||||
let expression = state.expression.clone();
|
||||
let is_error = state.is_error.clone();
|
||||
let history = state.history.clone();
|
||||
|
||||
let result_color = use_memo({
|
||||
let is_error = is_error.clone();
|
||||
move || {
|
||||
if is_error.get() {
|
||||
Color::rgba(200, 30, 30, 245)
|
||||
} else {
|
||||
Color::rgba(20, 140, 20, 245)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let result_text = use_memo({
|
||||
let result = state.result.clone();
|
||||
move || {
|
||||
let r = result.get();
|
||||
if r.is_empty() { " ".to_string() } else { format!("= {r}") }
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
block(
|
||||
padding = 12.0,
|
||||
gap = 8.0,
|
||||
// min_height (content) sized to cover scroll_box + expression + result lines
|
||||
// so the window auto-sizes correctly despite text min-size being 0.
|
||||
// Content: scroll_box(100) + expr_text(31) + result_text(20) + 2×gap(16) = 167
|
||||
min_height = 167.0,
|
||||
align_self = AlignItems::Stretch,
|
||||
align_items = AlignItems::End,
|
||||
background = Color::rgba(255, 255, 255, 200),
|
||||
border_radius = 12.0,
|
||||
border = (1.0, Color::rgba(0, 0, 0, 60)),
|
||||
shadow = BoxShadow::new(Point::new(2.0, 2.0), 8.0, 2.0, Color::rgba(0, 0, 0, 40), BoxShadowKind::Outer),
|
||||
) {
|
||||
block(flex = 1.0, min_height = 0.0) {}
|
||||
|
||||
// History (oldest at top, newest at bottom, scrollable)
|
||||
scroll_box(
|
||||
height = 100.0,
|
||||
offset_y = state.history_scroll.clone(),
|
||||
align_self = AlignItems::Stretch,
|
||||
) {
|
||||
column(padding = 4.0, gap = 2.0, align_items = AlignItems::End) {
|
||||
history.with(|entries| {
|
||||
entries
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|e| {
|
||||
let expr = e.expression.clone();
|
||||
let res = e.result.clone();
|
||||
let s = state.clone();
|
||||
let res_for_press = res.clone();
|
||||
view! {
|
||||
button(
|
||||
align_self = AlignItems::End,
|
||||
padding = 4.0,
|
||||
background = Color::rgba(0, 0, 0, 0),
|
||||
border_radius = 4.0,
|
||||
on_press = move |_| s.use_history_result(res_for_press.clone()),
|
||||
) {
|
||||
row(gap = 6.0, align_self = AlignItems::End) {
|
||||
text(size = 12.0, color = Color::rgba(0, 0, 0, 200), font_family = TextFontFamily::Monospace, wrap = TextWrap::None, weight = FontWeight::Normal) {
|
||||
expr.clone()
|
||||
}
|
||||
text(size = 12.0, color = Color::rgba(20, 130, 20, 230), font_family = TextFontFamily::Monospace, wrap = TextWrap::None, weight = FontWeight::Normal) {
|
||||
format!("= {res}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Current expression
|
||||
text(
|
||||
size = 26.0,
|
||||
font_family = TextFontFamily::Monospace,
|
||||
color = Color::rgba(0, 0, 0, 245),
|
||||
wrap = TextWrap::None,
|
||||
weight = FontWeight::Normal,
|
||||
pointer_events = false,
|
||||
) {
|
||||
expression.get()
|
||||
}
|
||||
|
||||
// Result / error line
|
||||
if is_error.get() {
|
||||
view! {
|
||||
text(
|
||||
size = 16.0,
|
||||
font_family = TextFontFamily::Monospace,
|
||||
color = result_color.get(),
|
||||
wrap = TextWrap::None,
|
||||
weight = FontWeight::Normal,
|
||||
pointer_events = false,
|
||||
) {
|
||||
result_text.get()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
view! {
|
||||
block(min_height = 0.0) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mode toggle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[component]
|
||||
pub fn ModeToggle(state: CalcState) -> impl IntoView {
|
||||
let mode = state.mode.clone();
|
||||
|
||||
view! {
|
||||
row(gap = 6.0, align_self = AlignItems::Stretch) {
|
||||
toggle_button("Basic", mode.get() == CalcMode::Basic, {
|
||||
let state = state.clone();
|
||||
move || state.toggle_mode()
|
||||
})
|
||||
toggle_button("Scientific", mode.get() == CalcMode::Scientific, {
|
||||
let state = state.clone();
|
||||
move || state.toggle_mode()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Button helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn calc_button(label: String, bg: Color, on_press: impl Fn() + 'static) -> View {
|
||||
let widget_ref = use_widget_ref::<BlockWidget>();
|
||||
let interaction = use_interaction_state(widget_ref.clone());
|
||||
|
||||
button()
|
||||
.widget_ref(widget_ref)
|
||||
.flex(1.0)
|
||||
.min_width(54.0)
|
||||
// min_height = content height for 20px bold text (20 * 1.2 = 24).
|
||||
// This matches the actual rendered line height so window sizing is accurate
|
||||
// and the text is visually centered by the symmetric 8px top/bottom padding.
|
||||
.min_height(24.0)
|
||||
.padding(Edges::symmetric(8.0, 12.0))
|
||||
.align_items(AlignItems::Center)
|
||||
.background(interactive_background(bg, interaction))
|
||||
.border_radius(8.0)
|
||||
.border((0.5, interactive_border(interaction)))
|
||||
.shadow(interactive_shadow(interaction))
|
||||
.on_press(move |_| on_press())
|
||||
.children(
|
||||
text()
|
||||
.size(20.0)
|
||||
.weight(FontWeight::Bold)
|
||||
.selectable(false)
|
||||
.pointer_events(false)
|
||||
.children(label),
|
||||
)
|
||||
}
|
||||
|
||||
fn btn_row(buttons: Vec<View>) -> View {
|
||||
row().gap(6.0).children(buttons)
|
||||
}
|
||||
|
||||
fn toggle_button(label: &'static str, active: bool, on_press: impl Fn() + 'static) -> View {
|
||||
let widget_ref = use_widget_ref::<BlockWidget>();
|
||||
let interaction = use_interaction_state(widget_ref.clone());
|
||||
let base = if active {
|
||||
Color::rgba(60, 100, 220, 180)
|
||||
} else {
|
||||
Color::rgba(255, 255, 255, 90)
|
||||
};
|
||||
|
||||
button()
|
||||
.widget_ref(widget_ref)
|
||||
.flex(1.0)
|
||||
.padding(8.0)
|
||||
.min_height(17.0)
|
||||
.align_items(AlignItems::Center)
|
||||
.background(interactive_background(base, interaction))
|
||||
.border_radius(8.0)
|
||||
.border((0.5, interactive_border(interaction)))
|
||||
.shadow(interactive_shadow(interaction))
|
||||
.on_press(move |_| on_press())
|
||||
.children(
|
||||
text()
|
||||
.size(14.0)
|
||||
.weight(FontWeight::Bold)
|
||||
.selectable(false)
|
||||
.pointer_events(false)
|
||||
.children(label),
|
||||
)
|
||||
}
|
||||
|
||||
fn interactive_background(base: Color, interaction: InteractionState) -> Color {
|
||||
if interaction.pressed {
|
||||
mix(base, Color::BLACK, 0.22)
|
||||
} else if interaction.hovered {
|
||||
mix(base, Color::WHITE, 0.22)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
fn interactive_border(interaction: InteractionState) -> Color {
|
||||
if interaction.pressed {
|
||||
Color::rgba(0, 0, 0, 96)
|
||||
} else if interaction.hovered {
|
||||
Color::rgba(0, 0, 0, 72)
|
||||
} else {
|
||||
Color::rgba(0, 0, 0, 35)
|
||||
}
|
||||
}
|
||||
|
||||
fn interactive_shadow(interaction: InteractionState) -> BoxShadow {
|
||||
if interaction.pressed {
|
||||
BoxShadow::new(
|
||||
Point::new(0.0, 0.0),
|
||||
2.0,
|
||||
0.0,
|
||||
Color::rgba(0, 0, 0, 36),
|
||||
BoxShadowKind::Outer,
|
||||
)
|
||||
} else if interaction.hovered {
|
||||
BoxShadow::new(
|
||||
Point::new(0.0, 3.0),
|
||||
14.0,
|
||||
0.0,
|
||||
Color::rgba(0, 0, 0, 42),
|
||||
BoxShadowKind::Outer,
|
||||
)
|
||||
} else {
|
||||
BoxShadow::new(
|
||||
Point::new(0.0, 1.0),
|
||||
6.0,
|
||||
0.0,
|
||||
Color::rgba(0, 0, 0, 18),
|
||||
BoxShadowKind::Outer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn mix(base: Color, overlay: Color, amount: f32) -> Color {
|
||||
let amount = amount.clamp(0.0, 1.0);
|
||||
let blend = |from: u8, to: u8| -> u8 {
|
||||
((from as f32) + ((to as f32) - (from as f32)) * amount).round() as u8
|
||||
};
|
||||
|
||||
Color::rgba(
|
||||
blend(base.r, overlay.r),
|
||||
blend(base.g, overlay.g),
|
||||
blend(base.b, overlay.b),
|
||||
base.a,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keypad (basic)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[component]
|
||||
pub fn Keypad(state: CalcState) -> impl IntoView {
|
||||
let bg_num = Color::rgba(255, 255, 255, 90);
|
||||
let bg_op = Color::rgba(210, 230, 255, 140);
|
||||
let bg_eq = Color::rgba(160, 230, 160, 160);
|
||||
let bg_fn = Color::rgba(255, 200, 180, 140);
|
||||
|
||||
macro_rules! digit {
|
||||
($ch:expr) => {{
|
||||
let s = state.clone();
|
||||
let t = $ch.to_string();
|
||||
let label = t.clone();
|
||||
calc_button(label, bg_num, move || s.input(&t))
|
||||
}};
|
||||
}
|
||||
macro_rules! op {
|
||||
($ch:expr) => {{
|
||||
let s = state.clone();
|
||||
let t = $ch.to_string();
|
||||
let label = t.clone();
|
||||
calc_button(label, bg_op, move || s.input(&t))
|
||||
}};
|
||||
}
|
||||
|
||||
let clear_btn = {
|
||||
let s = state.clone();
|
||||
calc_button("C".to_string(), bg_fn, move || s.clear())
|
||||
};
|
||||
let paren_btn = {
|
||||
let s = state.clone();
|
||||
calc_button("( )".to_string(), bg_num, move || s.input_paren())
|
||||
};
|
||||
let pct_btn = {
|
||||
let s = state.clone();
|
||||
calc_button("%".to_string(), bg_num, move || s.input("%"))
|
||||
};
|
||||
let bs_btn = {
|
||||
let s = state.clone();
|
||||
calc_button("⌫".to_string(), bg_fn, move || s.backspace())
|
||||
};
|
||||
let dot_btn = {
|
||||
let s = state.clone();
|
||||
calc_button(".".to_string(), bg_num, move || s.input("."))
|
||||
};
|
||||
let eq_btn = {
|
||||
let s = state.clone();
|
||||
calc_button("=".to_string(), bg_eq, move || s.evaluate())
|
||||
};
|
||||
|
||||
view! {
|
||||
column(gap = 6.0, align_self = AlignItems::Stretch) {
|
||||
btn_row(vec![clear_btn, paren_btn, pct_btn, bs_btn])
|
||||
btn_row(vec![digit!('7'), digit!('8'), digit!('9'), op!('÷')])
|
||||
btn_row(vec![digit!('4'), digit!('5'), digit!('6'), op!('×')])
|
||||
btn_row(vec![digit!('1'), digit!('2'), digit!('3'), op!('-')])
|
||||
btn_row(vec![digit!('0'), dot_btn, eq_btn, op!('+')])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scientific panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[component]
|
||||
pub fn ScientificPanel(state: CalcState) -> impl IntoView {
|
||||
let bg = Color::rgba(230, 215, 255, 140);
|
||||
|
||||
macro_rules! sci {
|
||||
($label:expr, $insert:expr) => {{
|
||||
let s = state.clone();
|
||||
let ins: &'static str = $insert;
|
||||
calc_button($label.to_string(), bg, move || s.input(ins))
|
||||
}};
|
||||
}
|
||||
|
||||
let sq_btn = {
|
||||
let s = state.clone();
|
||||
calc_button("x²".to_string(), bg, move || {
|
||||
s.expression.update(|e| {
|
||||
let wrapped = format!("({e})^2");
|
||||
*e = wrapped;
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
view! {
|
||||
column(gap = 6.0, align_self = AlignItems::Stretch) {
|
||||
btn_row(vec![sci!("sin(", "sin("), sci!("cos(", "cos("), sci!("tan(", "tan("), sci!("asin(", "asin(")])
|
||||
btn_row(vec![sci!("log(", "log("), sci!("ln(", "ln("), sci!("√(", "√("), sq_btn])
|
||||
btn_row(vec![sci!("xʸ", "^"), sci!("π", "π"), sci!("e", "e"), sci!("fact(", "fact(")])
|
||||
}
|
||||
}
|
||||
}
|
||||
161
examples/calculator/src/main.rs
Normal file
161
examples/calculator/src/main.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
mod components;
|
||||
mod state;
|
||||
|
||||
use ruin_app::prelude::*;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tracing_subscriber::{EnvFilter, fmt};
|
||||
|
||||
use components::{Display, Keypad, ModeToggle, ScientificPanel};
|
||||
use state::{CalcMode, CalcState};
|
||||
|
||||
#[ruin_runtime::async_main]
|
||||
async fn main() -> ruin_app::Result<()> {
|
||||
init_tracing();
|
||||
|
||||
App::new()
|
||||
.window(
|
||||
Window::new()
|
||||
.title("Calculator")
|
||||
.app_id("dev.ruin.calculator")
|
||||
.base_color(Color::rgba(255, 255, 255, 80))
|
||||
.resizable(false),
|
||||
)
|
||||
.mount(view! { CalculatorApp() {} })
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
EnvFilter::new(
|
||||
"info,ruin_app::resize=trace,ruin_app::interaction=trace,\
|
||||
ruin_ui_platform_wayland::resize=trace,ruin_ui_platform_wayland::perf=debug,\
|
||||
ruin_ui_platform_wayland::event_bridge=trace",
|
||||
)
|
||||
});
|
||||
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(fmt::layer().with_target(true).without_time())
|
||||
.with(filter)
|
||||
.try_init();
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn CalculatorApp() -> impl IntoView {
|
||||
let state = CalcState::new();
|
||||
|
||||
// ---- Keyboard shortcuts (application-scoped) ----
|
||||
|
||||
for ch in "0123456789.+-*/%()".chars() {
|
||||
let s = state.clone();
|
||||
let display = match ch {
|
||||
'*' => "×",
|
||||
'/' => "÷",
|
||||
_ => "",
|
||||
};
|
||||
let text = if display.is_empty() {
|
||||
ch.to_string()
|
||||
} else {
|
||||
display.to_string()
|
||||
};
|
||||
use_shortcut(Shortcut::new(Key::Character(ch)), ShortcutScope::Application, {
|
||||
let text = text.clone();
|
||||
move || s.input(&text)
|
||||
});
|
||||
}
|
||||
|
||||
// = key also evaluates
|
||||
{
|
||||
let s = state.clone();
|
||||
use_shortcut(Shortcut::new(Key::Character('=')), ShortcutScope::Application, move || s.evaluate());
|
||||
}
|
||||
{
|
||||
let s = state.clone();
|
||||
use_shortcut(Shortcut::new(Key::Enter), ShortcutScope::Application, move || s.evaluate());
|
||||
}
|
||||
{
|
||||
let s = state.clone();
|
||||
use_shortcut(Shortcut::new(Key::Backspace), ShortcutScope::Application, move || s.backspace());
|
||||
}
|
||||
{
|
||||
let s = state.clone();
|
||||
use_shortcut(Shortcut::new(Key::Escape), ShortcutScope::Application, move || s.clear());
|
||||
}
|
||||
{
|
||||
let s = state.clone();
|
||||
use_shortcut(Shortcut::new(Key::Delete), ShortcutScope::Application, move || s.clear());
|
||||
}
|
||||
|
||||
// ---- Layout ----
|
||||
|
||||
let mode = state.mode.clone();
|
||||
|
||||
let text_style = PartialTextStyle {
|
||||
color: Some(Color::BLACK),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if mode.get() == CalcMode::Scientific {
|
||||
view! {
|
||||
column(
|
||||
gap = 10.0,
|
||||
padding = 12.0,
|
||||
align_items = AlignItems::Stretch,
|
||||
text_style = text_style,
|
||||
) {
|
||||
Display(state = state.clone()) {}
|
||||
ModeToggle(state = state.clone()) {}
|
||||
ScientificPanel(state = state.clone()) {}
|
||||
Keypad(state = state.clone()) {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
view! {
|
||||
column(
|
||||
gap = 10.0,
|
||||
padding = 12.0,
|
||||
align_items = AlignItems::Stretch,
|
||||
text_style = text_style,
|
||||
) {
|
||||
Display(state = state.clone()) {}
|
||||
ModeToggle(state = state.clone()) {}
|
||||
Keypad(state = state.clone()) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[component]
|
||||
fn StateProbe(slot: Rc<RefCell<Option<CalcState>>>) -> impl IntoView {
|
||||
let state = CalcState::new();
|
||||
*slot.borrow_mut() = Some(state);
|
||||
block().children(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_evaluation_replaces_expression_and_preserves_history() {
|
||||
let slot = Rc::new(RefCell::new(None::<CalcState>));
|
||||
let _ = ruin_app::__render_mountable_for_test(&view! {
|
||||
StateProbe(slot = Rc::clone(&slot)) {}
|
||||
});
|
||||
|
||||
let state = slot.borrow().clone().expect("state should be captured");
|
||||
let _ = state.expression.set("2+2".to_string());
|
||||
state.evaluate();
|
||||
|
||||
assert_eq!(state.expression.get(), "4");
|
||||
assert_eq!(state.result.get(), "");
|
||||
assert_eq!(state.is_error.get(), false);
|
||||
assert_eq!(state.history.get().len(), 1);
|
||||
assert_eq!(state.history.get()[0].expression, "2+2");
|
||||
assert_eq!(state.history.get()[0].result, "4");
|
||||
}
|
||||
}
|
||||
198
examples/calculator/src/state.rs
Normal file
198
examples/calculator/src/state.rs
Normal file
@@ -0,0 +1,198 @@
|
||||
use ruin_app::prelude::*;
|
||||
|
||||
type EvalResult = std::result::Result<f64, String>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub enum CalcMode {
|
||||
#[default]
|
||||
Basic,
|
||||
Scientific,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HistoryEntry {
|
||||
pub expression: String,
|
||||
pub result: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CalcState {
|
||||
/// The expression currently shown in the input display.
|
||||
pub expression: Signal<String>,
|
||||
/// The result of the last evaluation (or error message). Empty until first `=`.
|
||||
pub result: Signal<String>,
|
||||
/// Whether the last evaluation produced an error.
|
||||
pub is_error: Signal<bool>,
|
||||
/// Past evaluations, newest first.
|
||||
pub history: Signal<Vec<HistoryEntry>>,
|
||||
pub mode: Signal<CalcMode>,
|
||||
/// When true, the next digit/operator input clears the expression first.
|
||||
pub fresh: Signal<bool>,
|
||||
/// Current scroll offset for the history scroll box.
|
||||
pub history_scroll: Signal<f32>,
|
||||
}
|
||||
|
||||
impl CalcState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
expression: use_signal(|| String::new()),
|
||||
result: use_signal(|| String::new()),
|
||||
is_error: use_signal(|| false),
|
||||
history: use_signal(|| Vec::new()),
|
||||
mode: use_signal(|| CalcMode::Basic),
|
||||
fresh: use_signal(|| false),
|
||||
history_scroll: use_signal(|| 10_000.0_f32),
|
||||
}
|
||||
}
|
||||
|
||||
/// Append `text` to the expression. If `fresh` is set (just evaluated),
|
||||
/// clear the expression first so digits don't append to the old result.
|
||||
pub fn input(&self, text: &str) {
|
||||
if self.fresh.get() {
|
||||
// Starting fresh after a result: if it's an operator continue
|
||||
// from the result, otherwise start a new expression.
|
||||
let is_operator = matches!(text, "+" | "-" | "×" | "÷" | "^" | "%");
|
||||
if !is_operator {
|
||||
let _ = self.expression.set(String::new());
|
||||
}
|
||||
let _ = self.fresh.set(false);
|
||||
}
|
||||
self.expression.update(|e| e.push_str(text));
|
||||
}
|
||||
|
||||
/// Delete the last character (or close-paren group) from the expression.
|
||||
pub fn backspace(&self) {
|
||||
if self.fresh.get() {
|
||||
let _ = self.fresh.set(false);
|
||||
let _ = self.expression.set(String::new());
|
||||
return;
|
||||
}
|
||||
self.expression.update(|e| {
|
||||
// Pop a full UTF-8 character
|
||||
let new_len = e
|
||||
.char_indices()
|
||||
.next_back()
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0);
|
||||
e.truncate(new_len);
|
||||
});
|
||||
}
|
||||
|
||||
/// Clear the expression, result, and fresh flag.
|
||||
pub fn clear(&self) {
|
||||
let _ = self.expression.set(String::new());
|
||||
let _ = self.result.set(String::new());
|
||||
let _ = self.is_error.set(false);
|
||||
let _ = self.fresh.set(false);
|
||||
}
|
||||
|
||||
/// Evaluate the current expression, update result, push to history.
|
||||
pub fn evaluate(&self) {
|
||||
let expr = self.expression.with(|e| e.clone());
|
||||
if expr.is_empty() {
|
||||
return;
|
||||
}
|
||||
match evaluate(&expr) {
|
||||
Ok(value) => {
|
||||
let result_str = format_result(value);
|
||||
let _ = self.expression.set(result_str.clone());
|
||||
let _ = self.result.set(String::new());
|
||||
let _ = self.is_error.set(false);
|
||||
self.history.update(|h| {
|
||||
h.insert(
|
||||
0,
|
||||
HistoryEntry {
|
||||
expression: expr.clone(),
|
||||
result: result_str,
|
||||
},
|
||||
);
|
||||
h.truncate(50);
|
||||
});
|
||||
}
|
||||
Err(msg) => {
|
||||
let _ = self.result.set(msg);
|
||||
let _ = self.is_error.set(true);
|
||||
}
|
||||
}
|
||||
let _ = self.fresh.set(true);
|
||||
let _ = self.history_scroll.set(10_000.0);
|
||||
}
|
||||
|
||||
/// Set the expression to a previously computed result (e.g., when clicking a history entry).
|
||||
pub fn use_history_result(&self, result: String) {
|
||||
let _ = self.expression.set(result);
|
||||
let _ = self.result.set(String::new());
|
||||
let _ = self.is_error.set(false);
|
||||
let _ = self.fresh.set(false);
|
||||
}
|
||||
|
||||
pub fn toggle_mode(&self) {
|
||||
self.mode.update(|m| {
|
||||
*m = match *m {
|
||||
CalcMode::Basic => CalcMode::Scientific,
|
||||
CalcMode::Scientific => CalcMode::Basic,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/// Insert the right parenthesis character based on current balance.
|
||||
pub fn input_paren(&self) {
|
||||
let open = self
|
||||
.expression
|
||||
.with(|e| e.chars().filter(|&c| c == '(').count());
|
||||
let close = self
|
||||
.expression
|
||||
.with(|e| e.chars().filter(|&c| c == ')').count());
|
||||
self.input(if open > close { ")" } else { "(" });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Evaluation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Evaluate `raw_expr`, preprocessing display symbols to meval-compatible syntax.
|
||||
pub fn evaluate(raw_expr: &str) -> EvalResult {
|
||||
let expr = raw_expr
|
||||
.replace('×', "*")
|
||||
.replace('÷', "/")
|
||||
.replace('π', "pi")
|
||||
// √( is inserted by the scientific button; meval knows sqrt
|
||||
.replace("√", "sqrt");
|
||||
|
||||
let mut ctx = meval::Context::new();
|
||||
// Add factorial — only integers 0..=20 to avoid overflow
|
||||
ctx.func("fact", |x: f64| {
|
||||
if x < 0.0 || x.fract() != 0.0 || x > 20.0 {
|
||||
return f64::NAN;
|
||||
}
|
||||
(1..=(x as u64)).product::<u64>() as f64
|
||||
});
|
||||
|
||||
meval::eval_str_with_context(&expr, ctx).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Format an f64 result: use integer display when possible, otherwise up to 10 sig figs.
|
||||
fn format_result(value: f64) -> String {
|
||||
if value.is_nan() {
|
||||
return "Error".to_string();
|
||||
}
|
||||
if value.is_infinite() {
|
||||
return if value > 0.0 { "∞".to_string() } else { "-∞".to_string() };
|
||||
}
|
||||
if value.fract() == 0.0 && value.abs() < 1e15 {
|
||||
return format!("{}", value as i64);
|
||||
}
|
||||
// Up to 10 significant figures, strip trailing zeros
|
||||
let s = format!("{:.10}", value);
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
s.to_string()
|
||||
}
|
||||
Reference in New Issue
Block a user