From 6b31f7a7f5c7b69a69e8496d4692a325c5b1607f Mon Sep 17 00:00:00 2001 From: unsecretised Date: Tue, 14 Jul 2026 09:10:58 +0800 Subject: [PATCH 1/2] switch to evalexpr for maths --- Cargo.lock | 7 + Cargo.toml | 1 + src/app/tile/update.rs | 17 +- src/calculator.rs | 431 ----------------------------------------- src/commands.rs | 9 - src/main.rs | 1 - 6 files changed, 18 insertions(+), 448 deletions(-) delete mode 100644 src/calculator.rs diff --git a/Cargo.lock b/Cargo.lock index db25a4b..17dc356 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1204,6 +1204,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "evalexpr" +version = "13.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25929004897f2bbab309121a60400d36992f6d911d09baa6c172f6cc55706601" + [[package]] name = "event-listener" version = "5.4.1" @@ -4277,6 +4283,7 @@ dependencies = [ "block2 0.6.2", "crossbeam-channel", "emojis", + "evalexpr", "global-hotkey", "hex", "iced", diff --git a/Cargo.toml b/Cargo.toml index c4533de..49e2551 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ arboard = "3.6.1" block2 = "0.6.2" crossbeam-channel = "0.5.15" emojis = "0.8.0" +evalexpr = "13.1.0" global-hotkey = "0.7.0" hex = "0.4.3" iced = { version = "0.14.0", features = ["image", "tokio"] } diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index a1c92af..b97b2b9 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -38,7 +38,8 @@ use crate::app::settings_window_settings; use crate::app::tile::AppIndex; use crate::app::{Message, Page, tile::Tile}; use crate::autoupdate::download_latest_app; -use crate::calculator::Expr; + +use crate::clipboard::ClipBoardContentType; use crate::commands::Function; use crate::config::Config; use crate::config::MainPage; @@ -69,7 +70,7 @@ fn extract_target(url: &Url) -> Option { enum QueryAction { OpenWebsite(String), UnitConversions(Vec), - Calculation(Expr), + Calculation(String), GoogleSearch(String), ShellCommand(String), ShowFavourites, @@ -94,8 +95,8 @@ fn classify_query_action(page: &Page, query: &str, query_lc: &str) -> Option Task { .map(|conversion| conversion.to_app()) .collect(); return resize_task(id, tile.results.len() as u32); - } else if let Ok(res) = Expr::from_str(&tile.query) { + } else if let Ok(res) = evalexpr::eval(&tile.query) { tile.results.push(App { ranking: 0, - open_command: AppCommand::Function(Function::Calculate(res.clone())), + open_command: AppCommand::Function(Function::CopyToClipboard( + ClipBoardContentType::Text(res.to_string()), + )), desc: RUSTCAST_DESC_NAME.to_string(), icons: AppIcon::None, - display_name: res.eval().map(|x| x.to_string()).unwrap_or("".to_string()), + display_name: res.to_string(), search_name: "".to_string(), }); return single_item_resize_task(id); diff --git a/src/calculator.rs b/src/calculator.rs deleted file mode 100644 index db9ec73..0000000 --- a/src/calculator.rs +++ /dev/null @@ -1,431 +0,0 @@ -//! A small expression parser/evaluator supporting: -//! - + - * / ^ with precedence -//! - parentheses -//! - unary +/- -//! - ln(x) -//! - log(x) (base 10) -//! - log(base, x) -//! -//! Examples: -//! "2 + 3*4" => 14 -//! "2^(1+2)" => 8 -//! "-(3 + 4)" => -7 -//! "ln(2.7182818)" => ~1 -//! "log(100)" => 2 -//! "log(2, 8)" => 3 - -#[derive(Debug, Clone, PartialEq)] -pub enum Expr { - Number(f64), - Unary { - op: UnaryOp, - rhs: Box, - }, - Binary { - op: BinOp, - lhs: Box, - rhs: Box, - }, - Func { - name: String, - args: Vec, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum UnaryOp { - Plus, - Minus, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum BinOp { - Add, - Sub, - Mul, - Div, - Pow, -} - -impl Expr { - pub fn eval(&self) -> Option { - use BinOp::*; - use UnaryOp::*; - match self { - Expr::Number(x) => Some(*x), - - Expr::Unary { op, rhs } => { - let v = rhs.eval()?; - Some(match op { - Plus => v, - Minus => -v, - }) - } - - Expr::Binary { op, lhs, rhs } => { - let a = lhs.eval()?; - let b = rhs.eval()?; - match op { - Add => Some(a + b), - Sub => Some(a - b), - Mul => Some(a * b), - Div => Some(a / b), - Pow => Some(a.powf(b)), - } - } - - Expr::Func { name, args } => { - let name = name.as_str(); - match name { - "ln" => { - if args.len() != 1 { - return None; - } - Some(args[0].eval()?.ln()) - } - "log" => match args.len() { - 1 => Some(args[0].eval()?.log10()), - 2 => { - let base = args[0].eval()?; - let x = args[1].eval()?; - Some(x.log(base)) - } - _ => None, - }, - _ => None, - } - } - } - } - - #[allow(clippy::should_implement_trait)] - pub fn from_str(s: &str) -> Result { - let mut p = Parser::new(s); - let expr = p.parse_expr()?; - p.expect(Token::End)?; - Ok(expr) - } -} - -/* ---------------- Tokenizer ---------------- */ - -#[derive(Debug, Clone, PartialEq)] -enum Token { - Number(f64), - Ident(String), - Plus, - Minus, - Star, - Slash, - Caret, - LParen, - RParen, - Comma, - End, -} - -struct Lexer<'a> { - input: &'a str, - i: usize, -} - -impl<'a> Lexer<'a> { - fn new(input: &'a str) -> Self { - Self { input, i: 0 } - } - - fn peek_char(&self) -> Option { - self.input[self.i..].chars().next() - } - - fn bump_char(&mut self) -> Option { - let c = self.peek_char()?; - self.i += c.len_utf8(); - Some(c) - } - - fn skip_ws(&mut self) { - while matches!(self.peek_char(), Some(c) if c.is_whitespace()) { - self.bump_char(); - } - } - - fn next_token(&mut self) -> Result { - self.skip_ws(); - let c = match self.peek_char() { - Some(c) => c, - None => return Ok(Token::End), - }; - - // single-char tokens - let tok = match c { - '+' => { - self.bump_char(); - Token::Plus - } - '-' => { - self.bump_char(); - Token::Minus - } - '*' => { - self.bump_char(); - Token::Star - } - '/' => { - self.bump_char(); - Token::Slash - } - '^' => { - self.bump_char(); - Token::Caret - } - '(' => { - self.bump_char(); - Token::LParen - } - ')' => { - self.bump_char(); - Token::RParen - } - ',' => { - self.bump_char(); - Token::Comma - } - _ => { - // number or identifier - if c.is_ascii_digit() || c == '.' { - return self.lex_number(); - } else if c.is_ascii_alphabetic() || c == '_' { - return self.lex_ident(); - } else { - return Err(format!("Unexpected character: {c}")); - } - } - }; - Ok(tok) - } - - fn lex_number(&mut self) -> Result { - // Simple float lexer: digits/./e/E/+/- in exponent - let start = self.i; - let mut seen_e = false; - - while let Some(c) = self.peek_char() { - if c.is_ascii_digit() || c == '.' { - self.bump_char(); - continue; - } - if (c == 'e' || c == 'E') && !seen_e { - seen_e = true; - self.bump_char(); - // optional sign after exponent - if matches!(self.peek_char(), Some('+' | '-')) { - self.bump_char(); - } - continue; - } - break; - } - - let s = &self.input[start..self.i]; - let n = s - .parse::() - .map_err(|_| format!("Invalid number: {s}"))?; - Ok(Token::Number(n)) - } - - fn lex_ident(&mut self) -> Result { - let start = self.i; - while let Some(c) = self.peek_char() { - if c.is_ascii_alphanumeric() || c == '_' { - self.bump_char(); - } else { - break; - } - } - Ok(Token::Ident(self.input[start..self.i].to_string())) - } -} - -/* ---------------- Parser ---------------- */ - -struct Parser<'a> { - lex: Lexer<'a>, - cur: Token, -} - -impl<'a> Parser<'a> { - fn new(input: &'a str) -> Self { - let mut lex = Lexer::new(input); - let cur = lex.next_token().unwrap_or(Token::End); - Self { lex, cur } - } - - fn bump(&mut self) -> Result<(), String> { - self.cur = self.lex.next_token()?; - Ok(()) - } - - fn expect(&mut self, t: Token) -> Result<(), String> { - if self.cur == t { - self.bump() - } else { - Err(format!("Expected {:?}, found {:?}", t, self.cur)) - } - } - - fn parse_expr(&mut self) -> Result { - // expr = term (('+'|'-') term)* - let mut node = self.parse_term()?; - loop { - let op = match self.cur { - Token::Plus => BinOp::Add, - Token::Minus => BinOp::Sub, - _ => break, - }; - self.bump()?; - let rhs = self.parse_term()?; - node = Expr::Binary { - op, - lhs: Box::new(node), - rhs: Box::new(rhs), - }; - } - Ok(node) - } - - fn parse_term(&mut self) -> Result { - // term = power (('*'|'/') power)* - let mut node = self.parse_power()?; - loop { - let op = match self.cur { - Token::Star => BinOp::Mul, - Token::Slash => BinOp::Div, - _ => break, - }; - self.bump()?; - let rhs = self.parse_power()?; - node = Expr::Binary { - op, - lhs: Box::new(node), - rhs: Box::new(rhs), - }; - } - Ok(node) - } - - fn parse_power(&mut self) -> Result { - // power = unary ('^' power)? (right associative) - let lhs = self.parse_unary()?; - if self.cur == Token::Caret { - self.bump()?; - let rhs = self.parse_power()?; - Ok(Expr::Binary { - op: BinOp::Pow, - lhs: Box::new(lhs), - rhs: Box::new(rhs), - }) - } else { - Ok(lhs) - } - } - - fn parse_unary(&mut self) -> Result { - // unary = ('+'|'-')* primary - match self.cur { - Token::Plus => { - self.bump()?; - Ok(Expr::Unary { - op: UnaryOp::Plus, - rhs: Box::new(self.parse_unary()?), - }) - } - Token::Minus => { - self.bump()?; - Ok(Expr::Unary { - op: UnaryOp::Minus, - rhs: Box::new(self.parse_unary()?), - }) - } - _ => self.parse_primary(), - } - } - - fn parse_primary(&mut self) -> Result { - match &self.cur { - Token::Number(n) => { - let v = *n; - self.bump()?; - Ok(Expr::Number(v)) - } - Token::LParen => { - self.bump()?; - let e = self.parse_expr()?; - self.expect(Token::RParen)?; - Ok(e) - } - Token::Ident(name) => { - let name = name.clone(); - self.bump()?; - // function call must be ident '(' ... - self.expect(Token::LParen)?; - let mut args = Vec::new(); - if self.cur != Token::RParen { - loop { - args.push(self.parse_expr()?); - if self.cur == Token::Comma { - self.bump()?; - continue; - } - break; - } - } - self.expect(Token::RParen)?; - Ok(Expr::Func { name, args }) - } - _ => Err(format!("Unexpected token: {:?}", self.cur)), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn eval(input: &str) -> f64 { - Expr::from_str(input).unwrap().eval().unwrap() - } - - fn approx_eq(left: f64, right: f64) { - assert!((left - right).abs() < 1e-9, "{left} != {right}"); - } - - #[test] - fn calculator_respects_precedence_and_parentheses() { - approx_eq(eval("2 + 3 * 4"), 14.0); - approx_eq(eval("2^(1+2)"), 8.0); - approx_eq(eval("-(3 + 4)"), -7.0); - } - - #[test] - fn calculator_supports_logs_and_unary_ops() { - approx_eq(eval("ln(2.718281828459045)"), 1.0); - approx_eq(eval("log(1000)"), 3.0); - approx_eq(eval("log(2, 8)"), 3.0); - approx_eq(eval("+5"), 5.0); - } - - #[test] - fn calculator_handles_scientific_notation() { - approx_eq(eval("1e2 + 5"), 105.0); - } - - #[test] - fn calculator_rejects_invalid_input() { - assert!(Expr::from_str("2 +").is_err()); - assert!(Expr::from_str("foo(1)").is_ok()); - assert!(Expr::from_str("foo(1)").unwrap().eval().is_none()); - assert!(Expr::from_str("log(1, 2, 3)").unwrap().eval().is_none()); - assert!(Expr::from_str(")").is_err()); - } -} diff --git a/src/commands.rs b/src/commands.rs index 7889c71..f9d70e1 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -8,7 +8,6 @@ use objc2_foundation::NSURL; use crate::{ app::apps::{App, AppCommand, AppIcon}, - calculator::Expr, clipboard::ClipBoardContentType, config::Config, quit::{terminate_all_apps, terminate_app}, @@ -26,7 +25,6 @@ pub enum Function { RandomVar(i32), // Easter egg function CopyToClipboard(ClipBoardContentType), GoogleSearch(String), - Calculate(Expr), Quit, TileWindow(crate::platform::macos::window::TilePosition), } @@ -106,13 +104,6 @@ impl Function { }); } - Function::Calculate(expr) => { - Clipboard::new() - .unwrap() - .set_text(expr.eval().map(|x| x.to_string()).unwrap_or("".to_string())) - .unwrap_or(()); - } - Function::CopyToClipboard(clipboard_content) => match clipboard_content { ClipBoardContentType::Text(text) => { Clipboard::new().unwrap().set_text(text).ok(); diff --git a/src/main.rs b/src/main.rs index 82bc442..d9df578 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,6 @@ mod app; mod autoupdate; -mod calculator; mod clipboard; mod commands; mod config; From 848ba654a5228968c904feaa35ebb633b9b07e26 Mon Sep 17 00:00:00 2001 From: unsecretised Date: Tue, 14 Jul 2026 09:49:59 +0800 Subject: [PATCH 2/2] add "load 100 more items" to clipboard --- src/app.rs | 1 + src/app/pages/clipboard.rs | 5 +++++ src/app/tile/elm.rs | 9 +++++---- src/app/tile/update.rs | 15 ++++++++++----- src/database.rs | 6 +++--- 5 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/app.rs b/src/app.rs index aaafdca..fc14b39 100644 --- a/src/app.rs +++ b/src/app.rs @@ -169,6 +169,7 @@ pub enum Message { SimulatePaste(i32), OpenSettingsWindow, SettingsWindowOpened(window::Id), + LoadClipboardData(u32), } #[derive(Debug, Clone)] diff --git a/src/app/pages/clipboard.rs b/src/app/pages/clipboard.rs index e50e34d..43e32a8 100644 --- a/src/app/pages/clipboard.rs +++ b/src/app/pages/clipboard.rs @@ -78,6 +78,11 @@ pub fn clipboard_view( .map(|(i, content)| { content.render_row(i == focussed_id as usize, &row_render_theme) }), + ).push( + Button::new("Load 100 more items") + .width(Length::Fill) + .on_press(Message::LoadClipboardData(clipboard_content.len() as u32 + 100)) + .style(open_button_style) ) .width((WINDOW_WIDTH + 50.) / 3.), Direction::Vertical(Scrollbar::hidden()), diff --git a/src/app/tile/elm.rs b/src/app/tile/elm.rs index 4c3a49b..eeb2fd7 100644 --- a/src/app/tile/elm.rs +++ b/src/app/tile/elm.rs @@ -81,8 +81,6 @@ pub fn new(hotkeys: Hotkeys, config: &Config) -> (Tile, Task) { let conn = database::initialise_database(); - let clipboard_content = load_clipboard(&conn); - ( Tile { update_available: false, @@ -101,7 +99,7 @@ pub fn new(hotkeys: Hotkeys, config: &Config) -> (Tile, Task) { config: config.clone(), ranking, theme: config.theme.to_owned().clone().into(), - clipboard_content, + clipboard_content: vec![], tray_icon: None, sender: None, page: Page::Main, @@ -114,7 +112,10 @@ pub fn new(hotkeys: Hotkeys, config: &Config) -> (Tile, Task) { previous_input_source: None, conn, }, - Task::batch([open.map(|_| Message::OpenWindow)]), + Task::batch([ + open.map(|_| Message::OpenWindow), + Task::done(Message::LoadClipboardData(300)), + ]), ) } diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index b97b2b9..f4b834e 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -45,6 +45,7 @@ use crate::config::Config; use crate::config::MainPage; use crate::config::Position; use crate::config::ThemeMode; +use crate::database::load_clipboard; use crate::database::store_clipboard_content; use crate::debounce::DebouncePolicy; use crate::platform::macos::events::Event; @@ -147,6 +148,13 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { tile.events = Event::get_events(tile.config.event_duration); Task::none() } + + Message::LoadClipboardData(limit) => { + info!("Loading {} clipboard items.", limit); + tile.clipboard_content = load_clipboard(&tile.conn, limit); + Task::none() + } + Message::UriReceived(uri) => { let Ok(url) = Url::parse(&uri) else { return Task::none(); @@ -660,14 +668,10 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { } match action { Editable::Create(content) => { - if tile.clipboard_content.len() >= 300 { - // hard limit of 300 items at once - tile.clipboard_content.pop(); - } - if !tile.clipboard_content.contains(&content) { store_clipboard_content(&tile.conn, &content); tile.clipboard_content.insert(0, content); + tile.clipboard_content.truncate(300); return Task::none(); } @@ -685,6 +689,7 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { tile.clipboard_content = new_content_vec; tile.clipboard_content.insert(0, content); + tile.clipboard_content.truncate(300); } Editable::Delete(content) => { tile.clipboard_content = tile diff --git a/src/database.rs b/src/database.rs index 071ebf7..85859cf 100644 --- a/src/database.rs +++ b/src/database.rs @@ -35,17 +35,17 @@ pub fn initialise_database() -> Connection { conn } -pub fn load_clipboard(conn: &Connection) -> Vec { +pub fn load_clipboard(conn: &Connection, limit: u32) -> Vec { let Ok(mut stmt) = conn.prepare( "SELECT content_type, text_content, blob_content FROM clipboard_entries ORDER BY created_at DESC - LIMIT 300", + LIMIT ?1", ) else { return vec![]; }; - stmt.query_map([], |row| { + stmt.query_map([limit], |row| { let content_type: String = row.get(0)?; match content_type.as_str() { "text" => Ok(ClipBoardContentType::Text(row.get(1)?)),