Merge origin/main into macos

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Will Temple
2026-05-16 16:38:10 -04:00
66 changed files with 9558 additions and 2326 deletions

View 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"] }

View File

@@ -0,0 +1,379 @@
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("".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!("", "^"), sci!("π", "π"), sci!("e", "e"), sci!("fact(", "fact(")])
}
}
}

View File

@@ -0,0 +1,185 @@
mod components;
mod state;
use ruin_app::prelude::*;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{fmt, EnvFilter};
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");
}
}

View 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()
}

View File

@@ -101,6 +101,7 @@ async fn run_demo() -> Result<(), Box<dyn Error>> {
window.surface_target(),
initial_size.width as u32,
initial_size.height as u32,
ruin_ui::Color::rgb(0x08, 0x0C, 0x14),
)
.expect("wgpu renderer should initialize");
let state = Rc::new(RefCell::new(WorkerState {

View File

@@ -795,6 +795,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
let LayoutSnapshot {
scene,
interaction_tree: next_interaction_tree,
..
} = build_snapshot(
viewport,
version,
@@ -1077,6 +1078,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
let LayoutSnapshot {
scene,
interaction_tree: next_interaction_tree,
..
} = build_snapshot(
viewport,
version,

View File

@@ -36,6 +36,7 @@ fn main() -> Result<(), Box<dyn Error>> {
window.surface_target(),
scene.logical_size.width as u32,
scene.logical_size.height as u32,
ruin_ui::Color::rgb(0x08, 0x0C, 0x14),
)?;
println!("Opening RUIN Wayland / wgpu demo window...");