diff --git a/Cargo.toml b/Cargo.toml index 835373b..81d316c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,4 +10,4 @@ edition = "2021" [dependencies.iced] git = "https://github.com/iced-rs/iced.git" rev = "cdb18e610a72b4a025d7e1890140393adee5b087" -features = ["highlighter"] +features = ["multi-window"] diff --git a/src/main.rs b/src/main.rs index a3e27dd..d25a049 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,10 +20,11 @@ * */ -use iced::{program, Size}; +use iced::multi_window::Application; use iced::Settings; +use iced::Size; -use crate::ui::window::CalculatorApp; +use crate::ui::calculator_app::CalculatorApp; mod evaluator; @@ -40,7 +41,7 @@ fn main() -> iced::Result { // .with_window_class("KelpieCalcClass", "KelpieCalcIcon"); // let window_settings = iced::window::Settings { - size: Size::new(330.0, 450.0), + size: Size::new(330.0, 420.0), min_size: Some(Size::new(300.0, 420.0)), ..iced::window::Settings::default() }; @@ -51,9 +52,6 @@ fn main() -> iced::Result { .. Settings::default() }; - program("My Calculator", CalculatorApp::update, CalculatorApp::view) - .settings(settings) - .subscription(CalculatorApp::subscription) - .run() + CalculatorApp::run(settings) } diff --git a/src/ui/window.rs b/src/ui/calc_window.rs similarity index 84% rename from src/ui/window.rs rename to src/ui/calc_window.rs index 11a519b..00ef477 100644 --- a/src/ui/window.rs +++ b/src/ui/calc_window.rs @@ -20,43 +20,150 @@ * */ -use iced::{Background, Border, Color, Command, Degrees, Element, event, Event, Font, gradient, Length, Pixels, Radians, Renderer, Shadow, Subscription, Theme, Vector, window}; +// This module contains the logic for the extras window. +// It is broken out for the sake of maintainability and follows the same conventions as +// the main view / update logic of the main Application for ease of understanding + +use iced::{Background, Border, Color, Command, Degrees, Element, Font, gradient, Length, Padding, Pixels, Radians, Shadow, Vector}; use iced::alignment::{Horizontal, Vertical}; use iced::font::{Family, Stretch, Style, Weight}; -use iced::widget::{Button, Column, Container, container, Row, text, text_editor}; +use iced::widget::{Button, Column, container, Container, Row, text, text_editor}; use iced::widget::button::{Appearance, Status}; use iced::widget::text_editor::{Action, Content, Edit, Motion}; +use iced::window::Id; use crate::evaluator::AngleMode; use crate::ui::calculator::Calc; use crate::ui::messages::Message; #[derive(Debug, Default)] -pub(crate) struct CalculatorApp { +pub(super) struct CalcWindow { content: Content, result: Option>, calc: Calc, window_width: u32, window_height: u32, + window_x: i32, + window_y: i32, } -impl CalculatorApp { - pub(crate) fn subscription(&self) -> Subscription { - event::listen_with(|event, _status| { - match event { - Event::Window(_id, window::Event::Resized { width, height }) => { - Some(Message::WindowResized(width, height)) +impl CalcWindow { + + pub fn title(&self) -> String { + "Rusty Calculator".to_string() + } + pub fn update(&mut self, message: Message) -> Command { + match message { + Message::Char(s) => { + for c in s.chars() { + self.content.perform(Action::Edit(Edit::Insert(c))); } - _ => None + Command::none() } - }) - } - - pub(crate) fn view(&self) -> Container { + Message::Func(s) => { + // If we have a selection, we want to surround it with the function + if let Some(sel) = self.content.selection() { + for c in s.chars() { + self.content.perform(Action::Edit(Edit::Insert(c))); + } + self.content.perform(Action::Edit(Edit::Insert('('))); + for c in sel.chars() { + self.content.perform(Action::Edit(Edit::Insert(c))); + } + self.content.perform(Action::Edit(Edit::Insert(')'))); + Command::none() + } else { + // determine if we are at the end of the text. If so surround all text in function call + let cursor = self.content.cursor_position(); + let line_count = self.content.line_count(); + if cursor.0 == line_count - 1 && cursor.1 == self.content.line(cursor.0).unwrap().len() + && cursor != (0,0) { + self.content.perform(Action::Move(Motion::DocumentStart)); + for c in s.chars() { + self.content.perform(Action::Edit(Edit::Insert(c))); + } + self.content.perform(Action::Edit(Edit::Insert('('))); + Command::batch(vec![ + // Send the Message::MoveLeft message + Command::perform(async {}, |_| Message::MoveEnd), + Command::perform(async {}, |_| Message::Char(")".to_string())) + ]) + } else { //otherwise insert the function and move cursor between the parentheses + for c in s.chars() { + self.content.perform(Action::Edit(Edit::Insert(c))); + } + self.content.perform(Action::Edit(Edit::Insert('('))); + self.content.perform(Action::Edit(Edit::Insert(')'))); + Command::perform(async {}, |_| Message::MoveLeft) + } + } + } + Message::EditorAction(action) => { + match action { + Action::Edit(Edit::Enter) => { + self.result = Some(self.calc.evaluate(&self.content.text())) + } + _ => self.content.perform(action) + } + Command::none() + } + Message::Evaluate => { + self.result = Some(self.calc.evaluate(&self.content.text())); + Command::none() + } + Message::Clear => { + self.content.perform(Action::Move(Motion::DocumentStart)); + self.content.perform(Action::Select(Motion::DocumentEnd)); + self.content.perform(Action::Edit(Edit::Delete)); + self.result = None; + Command::none() + } + Message::MoveLeft => { + self.content.perform(Action::Move(Motion::Left)); + Command::none() + } + Message::MoveRight => { + self.content.perform(Action::Move(Motion::Right)); + Command::none() + } + Message::MoveEnd => { + self.content.perform(Action::Move(Motion::DocumentEnd)); + Command::none() + } + Message::BackSpace => { + self.content.perform(Action::Edit(Edit::Backspace)); + Command::none() + } + Message::WindowResized(id, w, h) => { + if id == Id::MAIN { + self.window_width = w.clone(); + self.window_height = h.clone(); + } + Command::none() + } + Message::WindowMoved(id, x, y) => { + if id == Id::MAIN { + self.window_x = x.clone(); + self.window_y = y.clone(); + } + Command::none() + } + Message::ToggleMode => { + self.calc.set_angle_mode(match self.calc.angle_mode() { + AngleMode::Degrees => AngleMode::Radians, + AngleMode::Radians => AngleMode::Gradians, + AngleMode::Gradians => AngleMode::Degrees, + }); + Command::none() + } + _ => { + Command::none() + } + } + } + pub(super) fn view<'a>(&'a self) -> Element { // Get the sizes for the major blocks - let (lcd_height, button_height, button_width) = get_container_sizes(self.window_width, self.window_height); - let font1 = Font { family: Family::Monospace, weight: Weight::Bold, @@ -90,17 +197,17 @@ impl CalculatorApp { style: Style::Normal, }; let result = text(match &self.result { - Some(r) => { - match r { - Ok(v) => { - let formatted = format!("= {0:.1$}", v, 10); - formatted.trim_end_matches('0').trim_end_matches('.').to_string() - } - Err(e) => e.clone() + Some(r) => { + match r { + Ok(v) => { + let formatted = format!("= {0:.1$}", v, 10); + formatted.trim_end_matches('0').trim_end_matches('.').to_string() } + Err(e) => e.clone() } - None => String::from(""), - }) + } + None => String::from(""), + }) .width(Length::Fill) .horizontal_alignment(Horizontal::Right) .font(font2) @@ -116,7 +223,9 @@ impl CalculatorApp { shadow: Default::default(), } }) + .padding(Padding::from(0)) .on_press(Message::ToggleMode) + .height(Length::Shrink) .into(); let con_mode = Container::new(mode) @@ -128,7 +237,6 @@ impl CalculatorApp { let top = Column::with_children([con_mode, lcd, result]).spacing(5); let lcd_container = container(top) .width(Length::Fill) - .height(lcd_height) .style(move |_theme, _status| { container::Appearance { background: Some(Background::Color(Color::from_rgb8(0xd4, 0xed, 0xd4))), @@ -137,10 +245,10 @@ impl CalculatorApp { }) .padding(2); - let w = button_width; - let h = button_height; + let w = Length::FillPortion(1); + let h = Length::FillPortion(1); - // The standard numer buttons + // The standard number buttons let b_one = ButtonBuilder::new("1", w, h).make(); let b_two = ButtonBuilder::new("2", w, h).make(); let b_three = ButtonBuilder::new("3", w, h).make(); @@ -157,7 +265,7 @@ impl CalculatorApp { let b_minus = ButtonBuilder::new("-", w, h).make(); let b_mult = ButtonBuilder::new("x", w, h).msg(Message::Char("*".to_string())).make(); let b_div = ButtonBuilder::new("/", w, h).make(); - let b_pow = ButtonBuilder::new("^", w, h).make(); + let b_pow = ButtonBuilder::new("^", Length::FillPortion(1), h).make(); let b_lparen = ButtonBuilder::new("(", w, h).msg(Message::Func("".to_string())).make(); let b_rparen = ButtonBuilder::new(")", w, h).make(); // Functions @@ -177,18 +285,18 @@ impl CalculatorApp { let b_floor = ButtonBuilder::for_func("floor", w, h).make(); let b_fact = ButtonBuilder::for_func("!", w, h).msg(Message::Func("factorial".to_string())).make(); // Command buttons - let b_equals = ButtonBuilder::new("=", w, h).msg(Message::Evaluate).make(); + let b_equals = ButtonBuilder::new("=", Length::FillPortion(2), h).msg(Message::Evaluate).make(); let b_clear = ButtonBuilder::new("AC", w, h) .msg(Message::Clear) .colors((Color::from_rgb8(0xf0, 0x24, 0x24), Color::from_rgb8(0xD0, 0x24, 0x24))) .make(); let b_left = ButtonBuilder::new("<-", w, h).msg(Message::MoveLeft).make(); let b_right = ButtonBuilder::new("->", w, h).msg(Message::MoveRight).make(); - let b_back = ButtonBuilder::new("<-del", w, h).msg(Message::BackSpace).make(); - let b_more = ButtonBuilder::new("more..", w, h).msg(Message::Menu).make(); + let b_back = ButtonBuilder::new(" Command { - match message { - Message::Char(s) => { - for c in s.chars() { - self.content.perform(Action::Edit(Edit::Insert(c))); - } - Command::none() - } - Message::Func(s) => { - // If we have a selection, we want to surround it with the function - if let Some(sel) = self.content.selection() { - for c in s.chars() { - self.content.perform(Action::Edit(Edit::Insert(c))); - } - self.content.perform(Action::Edit(Edit::Insert('('))); - for c in sel.chars() { - self.content.perform(Action::Edit(Edit::Insert(c))); - } - self.content.perform(Action::Edit(Edit::Insert(')'))); - Command::none() - } else { - // determine if we are at the end of the text. If so surround all text in function call - let cursor = self.content.cursor_position(); - let line_count = self.content.line_count(); + pub(crate) fn position(&self) -> (i32, i32) { + (self.window_x, self.window_y) + } - if cursor.0 == line_count - 1 && cursor.1 == self.content.line(cursor.0).unwrap().len() - && cursor != (0,0) { - self.content.perform(Action::Move(Motion::DocumentStart)); - for c in s.chars() { - self.content.perform(Action::Edit(Edit::Insert(c))); - } - self.content.perform(Action::Edit(Edit::Insert('('))); - Command::batch(vec![ - // Send the Message::MoveLeft message - Command::perform(async {}, |_| Message::MoveEnd), - Command::perform(async {}, |_| Message::Char(")".to_string())) - ]) - } else { //otherwise insert the function and move cursor between the parentheses - for c in s.chars() { - self.content.perform(Action::Edit(Edit::Insert(c))); - } - self.content.perform(Action::Edit(Edit::Insert('('))); - self.content.perform(Action::Edit(Edit::Insert(')'))); - Command::perform(async {}, |_| Message::MoveLeft) - } - } - } - Message::EditorAction(action) => { - match action { - Action::Edit(Edit::Enter) => { - self.result = Some(self.calc.evaluate(&self.content.text())) - } - _ => self.content.perform(action) - } - Command::none() - } - Message::Evaluate => { - self.result = Some(self.calc.evaluate(&self.content.text())); - Command::none() - } - Message::Clear => { - self.content.perform(Action::Move(Motion::DocumentStart)); - self.content.perform(Action::Select(Motion::DocumentEnd)); - self.content.perform(Action::Edit(Edit::Delete)); - self.result = None; - Command::none() - } - Message::MoveLeft => { - self.content.perform(Action::Move(Motion::Left)); - Command::none() - } - Message::MoveRight => { - self.content.perform(Action::Move(Motion::Right)); - Command::none() - } - Message::MoveEnd => { - self.content.perform(Action::Move(Motion::DocumentEnd)); - Command::none() - } - Message::BackSpace => { - self.content.perform(Action::Edit(Edit::Backspace)); - Command::none() - } - Message::WindowResized(w, h) => { - self.window_width = w; - self.window_height = h; - Command::none() - } - Message::ToggleMode => { - self.calc.set_angle_mode(match self.calc.angle_mode() { - AngleMode::Degrees => AngleMode::Radians, - AngleMode::Radians => AngleMode::Gradians, - AngleMode::Gradians => AngleMode::Degrees, - }); - Command::none() - } - Message::Menu => { - // todo Show menu - Command::none() - } - } + pub(crate) fn size(&self) -> (u32, u32) { + (self.window_width, self.window_height) } + } /// A builder for making the button widgets. @@ -438,7 +452,10 @@ impl <'a> ButtonBuilder<'a> { let container = Container::new(self.name) .align_x(Horizontal::Center) .align_y(Vertical::Center) - .clip(false); + .height(Length::Fill) + .width(Length::Fill) + + .clip(true); Button::new(container) .width(self.w) @@ -495,28 +512,4 @@ fn get_style(status: Status, colors: (Color, Color)) -> Appearance { } } } -} - -/// Calculate the sizes for the variable components of the display. -/// These are needed to make sure the display scales sensibly and smoothly as the window is resized. -/// -/// Returns the height of the *LCD* panel, followed by the height and width of the buttons. -/// The returned values may be Length::Fill -fn get_container_sizes(width: u32, height: u32) -> (Length, Length, Length) { - const MIN_LCD_PANEL_HEIGHT: f32 = 110.0; - const MIN_BUTTON_HEIGHT: f32 = 33.0; - const MIN_BUTTON_WIDTH: f32 = 55.0; - const MAX_BUTTON_HEIGHT: f32 = 45.0; - - // The buttons take up rows * (button height + button spacing) + container spacing. - let b_h = ((height as f32 - MIN_LCD_PANEL_HEIGHT) / 8.0 - 4.0).max(MIN_BUTTON_HEIGHT).min(MAX_BUTTON_HEIGHT); - let b_w = ((width as f32 - 4.0) / 5.0 - 4.0).max(MIN_BUTTON_WIDTH); - - let t_panel = if b_h < MAX_BUTTON_HEIGHT { - Length::from(MIN_LCD_PANEL_HEIGHT) - } else { - Length::Fill - }; - - (t_panel, Length::from(b_h), Length::from(b_w)) -} +} \ No newline at end of file diff --git a/src/ui/calculator_app.rs b/src/ui/calculator_app.rs new file mode 100644 index 0000000..5e248f4 --- /dev/null +++ b/src/ui/calculator_app.rs @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2024. + * + * Copyright 2024 Trevor Campbell + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the “Software”), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT + * OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + +use iced::{Command, Element, event, Event, executor, multi_window, Point, Size, Subscription, Theme, window}; +use iced::widget::text; +use iced::window::{Id, Level, Position}; + +use crate::ui::calc_window::CalcWindow; +use crate::ui::messages::Message; +use crate::ui::func_popup::FuncPopup; + +#[derive(Debug, Default)] +pub(crate) struct CalculatorApp { + main_window: CalcWindow, + settings_window: Option<(Id, FuncPopup)>, +} + +impl multi_window::Application for CalculatorApp { + type Executor = executor::Default; + type Message = Message; + type Theme = Theme; + type Flags = (); + + fn new(_flags: Self::Flags) -> (Self, Command) { + ( + CalculatorApp::default(), + Command::none(), + ) + } + + fn subscription(&self) -> Subscription { + event::listen_with(|event, _status| { + match event { + Event::Window(id, window::Event::Resized { width, height}) => { + Some(Message::WindowResized(id, width, height)) + } + Event::Window(id, window::Event::Moved { x, y}) => { + Some(Message::WindowMoved(id, x, y)) + } + _ => None + } + }) + } + + fn view(&self, id: Id) -> Element { + + match id { + Id::MAIN => self.main_window.view(), + _ => match &self.settings_window { + Some((id_settings, settings)) if id == *id_settings => settings.view(), + _ => text("WE HAVE A PROBLEM").into(), + } + } + + } + + fn update(&mut self, message: Message) -> Command { + + let mut commands: Vec> = vec![]; + + commands.push(self.main_window.update(message.clone())); + + + commands.push(match message { + + Message::FuncPopup => { + // Get the position of the main window + let (x,y) = self.main_window.position(); + // window moved events only work on some platforms, so if "(0, 0)" use default + let (w, h) = self.main_window.size(); + let new_pos = if (x, y) == (0, 0) { + Position::Default + } else { + Position::Specific(Point::new((x + w as i32 - 50) as f32, (y + 100) as f32)) + }; + + // Open a settings window and store a reference to it + let (id, spawn_window) = window::spawn(window::Settings { + level: Level::AlwaysOnTop, + position: new_pos, + exit_on_close_request: true, + size: Size::new(250.0, 450.0), + decorations: true, + ..Default::default() + }); + + self.settings_window = Some((id, FuncPopup::default())); + spawn_window + } + _ => { + Command::none() + } + }); + + Command::batch(commands) + } + + fn title(&self, id: Id) -> String { + match id { + Id::MAIN => self.main_window.title(), + _ => match &self.settings_window { + Some((id_settings, settings)) if id == *id_settings => settings.title(), + _ => "Unknown".to_string(), + } + } + } + + +} + diff --git a/src/ui/func_popup.rs b/src/ui/func_popup.rs new file mode 100644 index 0000000..bfb7bb6 --- /dev/null +++ b/src/ui/func_popup.rs @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2024. + * + * Copyright 2024 Trevor Campbell + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the “Software”), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT + * OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + +// This module contains the logic for the extras window. +// It is broken out for the sake of maintainability and follows the same conventions as +// the main view / update logic of the main Application for ease of understanding + +use iced::{Background, Color, Element, gradient, Length, Radians}; +use iced::widget::{Column, container, pick_list}; + +use crate::ui::messages::Message; + +#[derive(Debug, Default)] +pub(super) struct FuncPopup { + + +} + +impl FuncPopup { + + pub fn title(&self) -> String { + "Functions".to_string() + } + + pub(super) fn view(&self) -> Element { + + let functions = vec!["sinh".to_string(), "cosh".to_string(), "tanh".to_string()]; + let constants = vec!["Pi".to_string(), "C (speed of light)".to_string(), "Avagadro's No".to_string()]; + let conversions = vec!["Miles -> Kilometres".to_string(), "Lbs -> Kgs".to_string(), "X -Y".to_string()]; + + let col: Element = Column::with_children([ + pick_list(functions, None::, |selected| Message::Func(selected)) + .placeholder("Functions") + .width(Length::Fill) + .into(), + pick_list(constants, None::, |selected| Message::Func(selected)) + .placeholder("Constants") + .width(Length::Fill) + .into(), + pick_list(conversions, None::, |selected| Message::Func(selected)) + .placeholder("Conversions") + .width(Length::Fill) + .into(), + + ]).spacing(10).into(); + + container(col) + .width(Length::Fill) + .height(Length::Fill) + .style(move |_theme, _status| { + let gradient = gradient::Linear::new(Radians(135.0)) + .add_stop(0.0, Color::from_rgb8(0x00, 0x00, 0x00)) + .add_stop(0.25, Color::from_rgb8(0x14, 0x14, 0x14)) + .add_stop(0.50, Color::from_rgb8(0x28, 0x28, 0x28)) + .add_stop(0.75, Color::from_rgb8(0x3c, 0x3c, 0x3c)) + .add_stop(1.0, Color::from_rgb8(0x50, 0x50, 0x50)) + .into(); + + container::Appearance { + background: Some(Background::Gradient(gradient)), + ..Default::default() + } + }) + .padding(10).into() + } + +} \ No newline at end of file diff --git a/src/ui/messages.rs b/src/ui/messages.rs index 92767cd..70377dd 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -21,10 +21,12 @@ */ use iced::widget::text_editor::Action; +use iced::window::Id; #[derive(Debug, Clone)] pub enum Message { - WindowResized(u32, u32), + WindowResized(Id, u32, u32), + WindowMoved(Id, i32, i32), EditorAction(Action), Char(String), Func(String), @@ -35,5 +37,5 @@ pub enum Message { Clear, Evaluate, ToggleMode, - Menu, + FuncPopup, } \ No newline at end of file diff --git a/src/ui/mod.rs b/src/ui/mod.rs index a0e4078..d15ff0e 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -22,5 +22,7 @@ pub(crate) mod calculator; mod messages; -pub(crate) mod window; +pub(crate) mod calculator_app; +mod func_popup; +mod calc_window;