diff options
Diffstat (limited to '')
| -rw-r--r-- | src/config.rs | 3 | ||||
| -rw-r--r-- | src/dialogs.rs | 19 | ||||
| -rw-r--r-- | src/environment.rs | 13 | ||||
| -rw-r--r-- | src/error.rs | 6 | ||||
| -rw-r--r-- | src/main.rs | 147 | ||||
| -rw-r--r-- | src/options.rs | 334 | ||||
| -rw-r--r-- | src/panes/code_view.rs | 3 | ||||
| -rw-r--r-- | src/types.rs | 14 | ||||
| -rw-r--r-- | src/types/project.rs | 7 | ||||
| -rwxr-xr-x | src/types/rendered_element.rs | 105 | ||||
| -rw-r--r-- | src/values.rs | 17 | ||||
| -rw-r--r-- | src/values/alignment.rs | 65 | ||||
| -rw-r--r-- | src/values/content_fit.rs | 69 | ||||
| -rw-r--r-- | src/values/length.rs | 145 | ||||
| -rw-r--r-- | src/values/line_height.rs | 117 | ||||
| -rw-r--r-- | src/values/padding.rs | 201 | ||||
| -rw-r--r-- | src/values/pixels.rs | 18 | ||||
| -rwxr-xr-x | src/values/rotation.rs | 114 |
18 files changed, 1286 insertions, 111 deletions
diff --git a/src/config.rs b/src/config.rs index 975437f..1da1239 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,6 @@ +// (c) 2022-2024 Cory Forsstrom, Casper Rogild Storm, Calvin Lee, Andrew Baldwin, Reza Alizadeh Majd +// (c) 2024-2025 Polesznyák Márk László + use std::path::PathBuf; use serde::Deserialize; diff --git a/src/dialogs.rs b/src/dialogs.rs index 2d916b1..5954ff5 100644 --- a/src/dialogs.rs +++ b/src/dialogs.rs @@ -1,21 +1,26 @@ -use rfd::{MessageButtons, MessageDialog, MessageDialogResult, MessageLevel}; +use rfd::{ + AsyncMessageDialog, MessageButtons, MessageDialog, MessageDialogResult, + MessageLevel, +}; -pub fn error_dialog(description: impl Into<String>) { - let _ = MessageDialog::new() +pub async fn error_dialog(description: impl Into<String>) { + let _ = AsyncMessageDialog::new() .set_level(MessageLevel::Error) .set_buttons(MessageButtons::Ok) .set_title("Oops! Something went wrong.") .set_description(description) - .show(); + .show() + .await; } -pub fn warning_dialog(description: impl Into<String>) { - let _ = MessageDialog::new() +pub async fn warning_dialog(description: impl Into<String>) { + let _ = AsyncMessageDialog::new() .set_level(MessageLevel::Warning) .set_buttons(MessageButtons::Ok) .set_title("Heads up!") .set_description(description) - .show(); + .show() + .await; } pub fn unsaved_changes_dialog(description: impl Into<String>) -> bool { diff --git a/src/environment.rs b/src/environment.rs index 1ebb81b..8efc425 100644 --- a/src/environment.rs +++ b/src/environment.rs @@ -1,7 +1,20 @@ +// (c) 2023-2025 Cory Forsstrom, Casper Rogild Storm +// (c) 2024-2025 Polesznyák Márk László + use std::env; use std::path::PathBuf; pub const CONFIG_FILE_NAME: &str = "config.toml"; +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const GIT_HASH: Option<&str> = option_env!("GIT_HASH"); + +pub fn formatted_version() -> String { + let hash = GIT_HASH + .map(|hash| format!(" ({hash})")) + .unwrap_or_default(); + + format!("{}{hash}", VERSION) +} pub fn config_dir() -> PathBuf { portable_dir().unwrap_or_else(platform_specific_config_dir) diff --git a/src/error.rs b/src/error.rs index 002a1fc..9e1ee9d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -7,16 +7,16 @@ use thiserror::Error; #[error(transparent)] pub enum Error { IO(Arc<io::Error>), - #[error("config does not exist")] + #[error("Config does not exist")] ConfigMissing, #[error("JSON parsing error: {0}")] SerdeJSON(Arc<serde_json::Error>), #[error("TOML parsing error: {0}")] SerdeTOML(#[from] toml::de::Error), RustFmt(Arc<rust_format::Error>), - #[error("the element tree contains no matching element")] + #[error("The element tree contains no matching element")] NonExistentElement, - #[error("the file dialog has been closed without selecting a valid option")] + #[error("The file dialog has been closed without selecting a valid option")] DialogClosed, #[error("{0}")] Other(String), diff --git a/src/main.rs b/src/main.rs index 13510b9..336378e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,19 +4,21 @@ mod environment; mod error; #[allow(clippy::all, dead_code)] mod icon; +mod options; mod panes; mod theme; mod types; +mod values; mod widget; use std::path::PathBuf; +use std::sync::Arc; use config::Config; use dialogs::{error_dialog, unsaved_changes_dialog, warning_dialog}; use error::Error; use iced::advanced::widget::Id; -use iced::widget::pane_grid::{self, Pane, PaneGrid}; -use iced::widget::{Column, container, pick_list, row, text_editor}; +use iced::widget::{Column, container, pane_grid, pick_list, row, text_editor}; use iced::{Alignment, Element, Length, Task, Theme, clipboard, keyboard}; use iced_anim::transition::Easing; use iced_anim::{Animated, Animation}; @@ -25,13 +27,12 @@ use tokio::runtime; use types::{Action, DesignerPane, ElementName, Message, Project}; fn main() -> Result<(), Box<dyn std::error::Error>> { - let mut args = std::env::args(); - let _ = args.next(); - - let version = args.next().is_some_and(|s| s == "--version" || s == "-V"); + let version = std::env::args() + .nth(1) + .is_some_and(|s| s == "--version" || s == "-V"); if version { - println!("{}", env!("CARGO_PKG_VERSION")); + println!("iced-builder {}", environment::formatted_version()); println!("{}", env!("CARGO_PKG_REPOSITORY")); return Ok(()); @@ -59,10 +60,10 @@ struct App { is_loading: bool, project_path: Option<PathBuf>, project: Project, - config: Config, + config: Arc<Config>, theme: Animated<Theme>, pane_state: pane_grid::State<Panes>, - focus: Option<Pane>, + focus: Option<pane_grid::Pane>, designer_page: DesignerPane, element_list: &'static [ElementName], editor_content: text_editor::Content, @@ -85,24 +86,25 @@ impl App { }, ); - let config = config_load.unwrap_or_default(); + let config = Arc::new(config_load.unwrap_or_default()); let theme = config.selected_theme(); - let mut task = Task::none(); - - if let Some(path) = config.last_project.clone() { + let task = if let Some(path) = config.last_project.clone() { if path.exists() && path.is_file() { - task = Task::perform( + Task::perform( Project::from_path(path, config.clone()), Message::FileOpened, - ); + ) } else { - warning_dialog(format!( + Task::future(warning_dialog(format!( "The file {} does not exist, or isn't a file.", path.to_string_lossy() - )); + ))) + .discard() } - } + } else { + Task::none() + }; ( Self { @@ -146,32 +148,37 @@ impl App { match message { Message::SwitchTheme(event) => { self.theme.update(event); + + Task::none() } - Message::CopyCode => { - return clipboard::write(self.editor_content.text()); + Message::CopyCode => clipboard::write(self.editor_content.text()), + Message::SwitchPage(page) => { + self.designer_page = page; + Task::none() } - Message::SwitchPage(page) => self.designer_page = page, Message::EditorAction(action) => { if let text_editor::Action::Scroll { lines: _ } = action { self.editor_content.perform(action); } + Task::none() } Message::RefreshEditorContent => { match self.project.app_code(&self.config) { Ok(code) => { self.editor_content = text_editor::Content::with_text(&code); + Task::none() } - Err(error) => error_dialog(error), + Err(error) => Task::future(error_dialog(error)).discard(), } } Message::DropNewElement(name, point, _) => { - return iced_drop::zones_on_point( + iced_drop::zones_on_point( move |zones| Message::HandleNew(name.clone(), zones), point, None, None, - ); + ) } Message::HandleNew(name, zones) => { let ids: Vec<Id> = zones.into_iter().map(|z| z.0).collect(); @@ -182,25 +189,29 @@ impl App { self.project.element_tree.as_mut(), action, ); + self.is_dirty = true; match result { Ok(Some(ref element)) => { self.project.element_tree = Some(element.clone()); } - Err(error) => error_dialog(error), + Err(error) => { + return Task::future(error_dialog(error)) + .map(|_| Message::RefreshEditorContent); + } _ => {} } - - self.is_dirty = true; - return Task::done(Message::RefreshEditorContent); + Task::done(Message::RefreshEditorContent) + } else { + Task::none() } } Message::MoveElement(element, point, _) => { - return iced_drop::zones_on_point( + iced_drop::zones_on_point( move |zones| Message::HandleMove(element.clone(), zones), point, None, None, - ); + ) } Message::HandleMove(element, zones) => { let ids: Vec<Id> = zones.into_iter().map(|z| z.0).collect(); @@ -209,33 +220,38 @@ impl App { let action = Action::new( &ids, eltree_clone.as_ref(), - Some(element.get_id()), + Some(element.id()), ); let result = element.handle_action( self.project.element_tree.as_mut(), action, ); if let Err(error) = result { - error_dialog(error); + return Task::future(error_dialog(error)).discard(); } self.is_dirty = true; - return Task::done(Message::RefreshEditorContent); + Task::done(Message::RefreshEditorContent) + } else { + Task::none() } } Message::PaneResized(pane_grid::ResizeEvent { split, ratio }) => { self.pane_state.resize(split, ratio); + Task::none() } Message::PaneClicked(pane) => { self.focus = Some(pane); + Task::none() } Message::PaneDragged(pane_grid::DragEvent::Dropped { pane, target, }) => { self.pane_state.drop(pane, target); + Task::none() } - Message::PaneDragged(_) => {} + Message::PaneDragged(_) => Task::none(), Message::NewFile => { if !self.is_loading { if !self.is_dirty { @@ -251,26 +267,32 @@ impl App { self.editor_content = text_editor::Content::new(); } } + + Task::none() } Message::OpenFile => { if !self.is_loading { if !self.is_dirty { self.is_loading = true; - return Task::perform( + Task::perform( Project::from_file(self.config.clone()), Message::FileOpened, - ); + ) } else if unsaved_changes_dialog( "You have unsaved changes. Do you wish to discard these and open another project?", ) { self.is_dirty = false; self.is_loading = true; - return Task::perform( + Task::perform( Project::from_file(self.config.clone()), Message::FileOpened, - ); + ) + } else { + Task::none() } + } else { + Task::none() } } Message::FileOpened(result) => { @@ -281,31 +303,35 @@ impl App { Ok((path, project)) => { self.project = project; self.project_path = Some(path); - return Task::done(Message::RefreshEditorContent); + Task::done(Message::RefreshEditorContent) } - Err(error) => error_dialog(error), + Err(error) => Task::future(error_dialog(error)).discard(), } } Message::SaveFile => { if !self.is_loading { self.is_loading = true; - return Task::perform( + Task::perform( self.project .clone() .write_to_file(self.project_path.clone()), Message::FileSaved, - ); + ) + } else { + Task::none() } } Message::SaveFileAs => { if !self.is_loading { self.is_loading = true; - return Task::perform( + Task::perform( self.project.clone().write_to_file(None), Message::FileSaved, - ); + ) + } else { + Task::none() } } Message::FileSaved(result) => { @@ -315,13 +341,12 @@ impl App { Ok(path) => { self.project_path = Some(path); self.is_dirty = false; + Task::none() } - Err(error) => error_dialog(error), + Err(error) => Task::future(error_dialog(error)).discard(), } } } - - Task::none() } fn subscription(&self) -> iced::Subscription<Message> { @@ -354,8 +379,9 @@ impl App { |theme| Message::SwitchTheme(theme.into()) )] .width(200); - let pane_grid = - PaneGrid::new(&self.pane_state, |id, pane, _is_maximized| { + let pane_grid = pane_grid::PaneGrid::new( + &self.pane_state, + |id, pane, _is_maximized| { let is_focused = Some(id) == self.focus; match pane { Panes::Designer => match &self.designer_page { @@ -364,23 +390,22 @@ impl App { self.project.get_theme(&self.config), is_focused, ), - DesignerPane::CodeView => code_view::view( - &self.editor_content, - self.theme.value().clone(), - is_focused, - ), + DesignerPane::CodeView => { + code_view::view(&self.editor_content, is_focused) + } }, Panes::ElementList => { element_list::view(self.element_list, is_focused) } } - }) - .width(Length::Fill) - .height(Length::Fill) - .spacing(10) - .on_resize(10, Message::PaneResized) - .on_click(Message::PaneClicked) - .on_drag(Message::PaneDragged); + }, + ) + .width(Length::Fill) + .height(Length::Fill) + .spacing(10) + .on_resize(10, Message::PaneResized) + .on_click(Message::PaneClicked) + .on_drag(Message::PaneDragged); let content = Column::new() .push(header) diff --git a/src/options.rs b/src/options.rs new file mode 100644 index 0000000..2dc25d7 --- /dev/null +++ b/src/options.rs @@ -0,0 +1,334 @@ +use std::collections::BTreeMap; +use std::str::FromStr; + +use iced::widget::text::LineHeight; +#[allow(unused_imports)] +use iced::widget::{Button, Column, Container, Image, Row, Svg, Text}; +use iced::{Alignment, ContentFit, Length, Padding, Pixels, Rotation}; + +use crate::values::Value; + +pub trait ApplyOptions { + fn apply_options(self, options: BTreeMap<String, Option<String>>) -> Self; +} + +impl<Message> ApplyOptions for Button<'_, Message> { + fn apply_options(self, options: BTreeMap<String, Option<String>>) -> Self { + let mut button = self; + + if let Some(width) = options.get("width").expect("width key") { + let width = Length::from_str(width).unwrap(); + button = button.width(width); + } + + if let Some(height) = options.get("height").expect("height key") { + let height = Length::from_str(height).unwrap(); + button = button.height(height); + } + + if let Some(padding) = options.get("padding").expect("padding key") { + let padding = Padding::from_str(padding).unwrap(); + button = button.padding(padding); + } + + if let Some(clip) = options.get("clip").expect("clip key") { + let clip = bool::from_str(clip).unwrap(); + button = button.clip(clip); + } + + button + } +} + +impl ApplyOptions for Text<'_> { + fn apply_options(self, options: BTreeMap<String, Option<String>>) -> Self { + let mut text = self; + + if let Some(size) = options.get("size").expect("size key") { + let size = Pixels::from_str(size).unwrap(); + text = text.size(size); + } + + if let Some(line_height) = + options.get("line_height").expect("line_height key") + { + let line_height = LineHeight::from_str(line_height).unwrap(); + text = text.line_height(line_height); + } + + if let Some(width) = options.get("width").expect("width key") { + let width = Length::from_str(width).unwrap(); + text = text.width(width); + } + + if let Some(height) = options.get("height").expect("height key") { + let height = Length::from_str(height).unwrap(); + text = text.height(height); + } + + if let Some(align_x) = options.get("align_x").expect("align_x key") { + let align_x = Alignment::from_str(align_x).unwrap(); + text = text.align_x(align_x); + } + + if let Some(align_y) = options.get("align_y").expect("align_y key") { + let align_y = Alignment::from_str(align_y).unwrap(); + text = text.align_y(align_y); + } + + text + } +} + +impl<Message> ApplyOptions for Container<'_, Message> { + fn apply_options(self, options: BTreeMap<String, Option<String>>) -> Self { + let mut container = self; + + if let Some(padding) = options.get("padding").expect("padding key") { + let padding = Padding::from_str(padding).unwrap(); + container = container.padding(padding); + } + + if let Some(width) = options.get("width").expect("width key") { + let width = Length::from_str(width).unwrap(); + container = container.width(width); + } + + if let Some(height) = options.get("height").expect("height key") { + let height = Length::from_str(height).unwrap(); + container = container.height(height); + } + + if let Some(max_width) = + options.get("max_width").expect("max_width key") + { + let max_width = Pixels::from_str(max_width).unwrap(); + container = container.max_width(max_width); + } + + if let Some(max_height) = + options.get("max_height").expect("max_height key") + { + let max_height = Pixels::from_str(max_height).unwrap(); + container = container.max_height(max_height); + } + + if let Some(center_x) = options.get("center_x").expect("center_x key") { + let center_x = Length::from_str(center_x).unwrap(); + container = container.center_x(center_x); + } + + if let Some(center_y) = options.get("center_y").expect("center_y key") { + let center_y = Length::from_str(center_y).unwrap(); + container = container.center_y(center_y); + } + + if let Some(center) = options.get("center").expect("center key") { + let center = Length::from_str(center).unwrap(); + container = container.center(center); + } + + if let Some(align_left) = + options.get("align_left").expect("align_left key") + { + let align_left = Length::from_str(align_left).unwrap(); + container = container.align_left(align_left); + } + + if let Some(align_right) = + options.get("align_right").expect("align_right key") + { + let align_right = Length::from_str(align_right).unwrap(); + container = container.align_right(align_right); + } + + if let Some(align_top) = + options.get("align_top").expect("align_top key") + { + let align_top = Length::from_str(align_top).unwrap(); + container = container.align_top(align_top); + } + + if let Some(align_bottom) = + options.get("align_bottom").expect("align_bottom key") + { + let align_bottom = Length::from_str(align_bottom).unwrap(); + container = container.align_bottom(align_bottom); + } + + if let Some(align_x) = options.get("align_x").expect("align_x key") { + let align_x = Alignment::from_str(align_x).unwrap(); + container = container.align_x(align_x); + } + + if let Some(align_y) = options.get("align_y").expect("align_y key") { + let align_y = Alignment::from_str(align_y).unwrap(); + container = container.align_y(align_y); + } + + if let Some(clip) = options.get("clip").expect("clip key") { + let clip = bool::from_str(clip).unwrap(); + container = container.clip(clip); + } + + container + } +} + +impl<Message> ApplyOptions for Column<'_, Message> { + fn apply_options(self, options: BTreeMap<String, Option<String>>) -> Self { + let mut column = self; + + if let Some(spacing) = options.get("spacing").expect("spacing key") { + let spacing = Pixels::from_str(spacing).unwrap(); + column = column.spacing(spacing); + } + + if let Some(padding) = options.get("padding").expect("padding key") { + let padding = Padding::from_str(padding).unwrap(); + column = column.padding(padding); + } + + if let Some(width) = options.get("width").expect("width key") { + let width = Length::from_str(width).unwrap(); + column = column.width(width); + } + + if let Some(height) = options.get("height").expect("height key") { + let height = Length::from_str(height).unwrap(); + column = column.height(height); + } + + if let Some(max_width) = + options.get("max_width").expect("max_width key") + { + let max_width = Pixels::from_str(max_width).unwrap(); + column = column.max_width(max_width); + } + + if let Some(align_x) = options.get("align_x").expect("align_x key") { + let align_x = Alignment::from_str(align_x).unwrap(); + column = column.align_x(align_x); + } + + if let Some(clip) = options.get("clip").expect("clip key") { + let clip = bool::from_str(clip).unwrap(); + column = column.clip(clip); + } + + column + } +} + +impl<Message> ApplyOptions for Row<'_, Message> { + fn apply_options(self, options: BTreeMap<String, Option<String>>) -> Self { + let mut row = self; + + if let Some(spacing) = options.get("spacing").expect("spacing key") { + let spacing = Pixels::from_str(spacing).unwrap(); + row = row.spacing(spacing); + } + + if let Some(padding) = options.get("padding").expect("padding key") { + let padding = Padding::from_str(padding).unwrap(); + row = row.padding(padding); + } + + if let Some(width) = options.get("width").expect("width key") { + let width = Length::from_str(width).unwrap(); + row = row.width(width); + } + + if let Some(height) = options.get("height").expect("height key") { + let height = Length::from_str(height).unwrap(); + row = row.height(height); + } + + if let Some(align_y) = options.get("align_y").expect("align_y key") { + let align_y = Alignment::from_str(align_y).unwrap(); + row = row.align_y(align_y); + } + + if let Some(clip) = options.get("clip").expect("clip key") { + let clip = bool::from_str(clip).unwrap(); + row = row.clip(clip); + } + + row + } +} + +impl<Handle> ApplyOptions for Image<Handle> { + fn apply_options(self, options: BTreeMap<String, Option<String>>) -> Self { + let mut image = self; + + if let Some(width) = options.get("width").expect("width key") { + let width = Length::from_str(width).unwrap(); + image = image.width(width); + } + + if let Some(height) = options.get("height").expect("height key") { + let height = Length::from_str(height).unwrap(); + image = image.height(height); + } + + if let Some(content_fit) = + options.get("content_fit").expect("content_fit key") + { + let content_fit = ContentFit::from_str(content_fit).unwrap(); + image = image.content_fit(content_fit); + } + + if let Some(rotation) = options.get("rotation").expect("rotation key") { + let rotation = Rotation::from_str(rotation).unwrap(); + image = image.rotation(rotation); + } + + if let Some(opacity) = options.get("opacity").expect("opacity key") { + let opacity = f32::from_str(opacity).unwrap(); + image = image.opacity(opacity); + } + + if let Some(scale) = options.get("scale").expect("scale key") { + let scale = f32::from_str(scale).unwrap(); + image = image.scale(scale); + } + + image + } +} + +impl ApplyOptions for Svg<'_> { + fn apply_options(self, options: BTreeMap<String, Option<String>>) -> Self { + let mut svg = self; + + if let Some(width) = options.get("width").expect("width key") { + let width = Length::from_str(width).unwrap(); + svg = svg.width(width); + } + + if let Some(height) = options.get("height").expect("height key") { + let height = Length::from_str(height).unwrap(); + svg = svg.height(height); + } + + if let Some(content_fit) = + options.get("content_fit").expect("content_fit key") + { + let content_fit = ContentFit::from_str(content_fit).unwrap(); + svg = svg.content_fit(content_fit); + } + + if let Some(rotation) = options.get("rotation").expect("rotation key") { + let rotation = Rotation::from_str(rotation).unwrap(); + svg = svg.rotation(rotation); + } + + if let Some(opacity) = options.get("opacity").expect("opacity key") { + let opacity = f32::from_str(opacity).unwrap(); + svg = svg.opacity(opacity); + } + + svg + } +} diff --git a/src/panes/code_view.rs b/src/panes/code_view.rs index e133078..551347c 100644 --- a/src/panes/code_view.rs +++ b/src/panes/code_view.rs @@ -20,7 +20,6 @@ fn highlight_style(theme: &Theme, scope: &Scope) -> Format<Font> { pub fn view( editor_content: &text_editor::Content, - theme: Theme, is_focused: bool, ) -> pane_grid::Content<'_, Message> { let title = row![ @@ -47,7 +46,7 @@ pub fn view( .on_action(Message::EditorAction) .font(Font::MONOSPACE) .highlight_with::<Highlighter>( - Settings::new(vec![], highlight_style, theme, "rs"), + Settings::new(vec![], highlight_style, "rs"), Highlight::to_format, ) .style(|theme, _| { diff --git a/src/types.rs b/src/types.rs index 2b743cd..73728e3 100644 --- a/src/types.rs +++ b/src/types.rs @@ -5,7 +5,7 @@ pub mod rendered_element; use std::path::PathBuf; pub use element_name::ElementName; -use iced::Theme; +use iced::advanced::widget::Id; use iced::widget::{pane_grid, text_editor}; use iced_anim::Event; pub use project::Project; @@ -15,21 +15,15 @@ use crate::Error; #[derive(Debug, Clone)] pub enum Message { - SwitchTheme(Event<Theme>), + SwitchTheme(Event<iced::Theme>), CopyCode, SwitchPage(DesignerPane), EditorAction(text_editor::Action), RefreshEditorContent, DropNewElement(ElementName, iced::Point, iced::Rectangle), - HandleNew( - ElementName, - Vec<(iced::advanced::widget::Id, iced::Rectangle)>, - ), + HandleNew(ElementName, Vec<(Id, iced::Rectangle)>), MoveElement(RenderedElement, iced::Point, iced::Rectangle), - HandleMove( - RenderedElement, - Vec<(iced::advanced::widget::Id, iced::Rectangle)>, - ), + HandleMove(RenderedElement, Vec<(Id, iced::Rectangle)>), PaneResized(pane_grid::ResizeEvent), PaneClicked(pane_grid::Pane), PaneDragged(pane_grid::DragEvent), diff --git a/src/types/project.rs b/src/types/project.rs index e665d5e..721783e 100644 --- a/src/types/project.rs +++ b/src/types/project.rs @@ -1,4 +1,5 @@ use std::path::{Path, PathBuf}; +use std::sync::Arc; extern crate fxhash; use fxhash::FxHashMap; @@ -58,7 +59,7 @@ impl Project { pub async fn from_path( path: PathBuf, - config: Config, + config: Arc<Config>, ) -> Result<(PathBuf, Self), Error> { let contents = tokio::fs::read_to_string(&path).await?; let mut project: Self = serde_json::from_str(&contents)?; @@ -68,7 +69,9 @@ impl Project { Ok((path, project)) } - pub async fn from_file(config: Config) -> Result<(PathBuf, Self), Error> { + pub async fn from_file( + config: Arc<Config>, + ) -> Result<(PathBuf, Self), Error> { let picked_file = rfd::AsyncFileDialog::new() .set_title("Open a JSON file...") .add_filter("*.json, *.JSON", &["json", "JSON"]) diff --git a/src/types/rendered_element.rs b/src/types/rendered_element.rs index e321449..ee3def8 100755 --- a/src/types/rendered_element.rs +++ b/src/types/rendered_element.rs @@ -4,9 +4,9 @@ use iced::advanced::widget::Id; use iced::{Element, Length, widget}; use serde::{Deserialize, Serialize}; -use super::ElementName; use crate::Error; -use crate::types::Message; +use crate::options::ApplyOptions; +use crate::types::{ElementName, Message}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RenderedElement { @@ -36,12 +36,12 @@ impl RenderedElement { } } - pub fn get_id(&self) -> &Id { + pub fn id(&self) -> &Id { &self.id } pub fn find_by_id(&mut self, id: &Id) -> Option<&mut Self> { - if self.get_id() == id { + if self.id() == id { Some(self) } else if let Some(child_elements) = self.child_elements.as_mut() { for element in child_elements { @@ -111,7 +111,7 @@ impl RenderedElement { pub fn insert_after(&mut self, id: &Id, element: &RenderedElement) { if let Some(child_elements) = self.child_elements.as_mut() { if let Some(index) = - child_elements.iter().position(|x| x.get_id() == id) + child_elements.iter().position(|x| x.id() == id) { child_elements.insert(index + 1, element.clone()); } else { @@ -158,25 +158,25 @@ impl RenderedElement { fn preset_options(mut self, options: &[&str]) -> Self { for opt in options { - let _ = self.options.insert(opt.to_string(), None); + let _ = self.options.insert((*opt).to_string(), None); } self } - pub fn option<'a>(mut self, option: &'a str, value: &'a str) -> Self { + pub fn option(mut self, option: String, value: String) -> Self { let _ = self .options - .entry(option.to_owned()) - .and_modify(|opt| *opt = Some(value.to_owned())); + .entry(option) + .and_modify(|opt| *opt = Some(value)); self } - pub fn into_element<'a>(self) -> Element<'a, Message> { + pub fn text_view<'a>(self) -> Element<'a, Message> { let mut children = widget::column![]; if let Some(els) = self.child_elements.clone() { for el in els { - children = children.push(el.clone().into_element()); + children = children.push(el.clone().text_view()); } } iced_drop::droppable( @@ -191,7 +191,7 @@ impl RenderedElement { .padding(10) .style(widget::container::bordered_box), ) - .id(self.get_id().clone()) + .id(self.id().clone()) .drag_hide(true) .on_drop(move |point, rect| { Message::MoveElement(self.clone(), point, rect) @@ -313,28 +313,34 @@ impl std::fmt::Display for RenderedElement { impl<'a> From<RenderedElement> for Element<'a, Message> { fn from(value: RenderedElement) -> Self { - let child_elements = match value.child_elements { - Some(ref elements) => elements.clone(), - None => vec![], - }; + let copy = value.clone(); + let child_elements = copy.child_elements.unwrap_or_default(); - let content: Element<'a, Message> = match value.name.clone() { + let content: Element<'a, Message> = match copy.name { ElementName::Text(s) => { - if s == String::new() { + if s.is_empty() { widget::text("New Text").into() } else { widget::text(s).into() } } ElementName::Button(s) => { - if s == String::new() { - widget::button(widget::text("New Button")).into() + if s.is_empty() { + widget::button(widget::text("New Button")) + .apply_options(copy.options) + .into() } else { - widget::button(widget::text(s)).into() + widget::button(widget::text(s)) + .apply_options(copy.options) + .into() } } - ElementName::Svg(p) => widget::svg(p).into(), - ElementName::Image(p) => widget::image(p).into(), + ElementName::Svg(p) => { + widget::svg(p).apply_options(copy.options).into() + } + ElementName::Image(p) => { + widget::image(p).apply_options(copy.options).into() + } ElementName::Container => { widget::container(if child_elements.len() == 1 { child_elements[0].clone().into() @@ -348,15 +354,17 @@ impl<'a> From<RenderedElement> for Element<'a, Message> { child_elements.into_iter().map(Into::into).collect(), ) .padding(20) + .apply_options(copy.options) .into(), ElementName::Column => widget::Column::from_vec( child_elements.into_iter().map(Into::into).collect(), ) .padding(20) + .apply_options(copy.options) .into(), }; iced_drop::droppable(content) - .id(value.get_id().clone()) + .id(value.id().clone()) .drag_hide(true) .on_drop(move |point, rect| { Message::MoveElement(value.clone(), point, rect) @@ -433,19 +441,35 @@ pub fn text(text: &str) -> RenderedElement { "line_height", "width", "height", + "align_x", + "align_y", ]) } pub fn button(text: &str) -> RenderedElement { RenderedElement::new(ElementName::Button(text.to_owned())) + .preset_options(&["width", "height", "padding", "clip"]) } pub fn svg(path: &str) -> RenderedElement { - RenderedElement::new(ElementName::Svg(path.to_owned())) + RenderedElement::new(ElementName::Svg(path.to_owned())).preset_options(&[ + "width", + "height", + "content_fit", + "rotation", + "opacity", + ]) } pub fn image(path: &str) -> RenderedElement { - RenderedElement::new(ElementName::Image(path.to_owned())) + RenderedElement::new(ElementName::Image(path.to_owned())).preset_options(&[ + "width", + "height", + "content_fit", + "rotation", + "opacity", + "scale", + ]) } pub fn container(content: Option<RenderedElement>) -> RenderedElement { @@ -453,10 +477,30 @@ pub fn container(content: Option<RenderedElement>) -> RenderedElement { Some(el) => RenderedElement::with(ElementName::Container, vec![el]), None => RenderedElement::with(ElementName::Container, vec![]), } + .preset_options(&[ + "padding", + "width", + "height", + "max_width", + "max_height", + "center_x", + "center_y", + "center", + "align_left", + "align_right", + "align_top", + "align_bottom", + "align_x", + "align_y", + "clip", + ]) } pub fn row(child_elements: Option<Vec<RenderedElement>>) -> RenderedElement { RenderedElement::with(ElementName::Row, child_elements.unwrap_or_default()) + .preset_options(&[ + "spacing", "padding", "width", "height", "align_y", "clip", + ]) } pub fn column(child_elements: Option<Vec<RenderedElement>>) -> RenderedElement { @@ -464,4 +508,13 @@ pub fn column(child_elements: Option<Vec<RenderedElement>>) -> RenderedElement { ElementName::Column, child_elements.unwrap_or_default(), ) + .preset_options(&[ + "spacing", + "padding", + "width", + "height", + "max_width", + "align_x", + "clip", + ]) } diff --git a/src/values.rs b/src/values.rs new file mode 100644 index 0000000..d2dae74 --- /dev/null +++ b/src/values.rs @@ -0,0 +1,17 @@ +mod alignment; +mod content_fit; +mod length; +mod line_height; +mod padding; +mod pixels; +mod rotation; + +pub trait Value: Sized { + type Err; + + fn from_str(s: &str) -> Result<Self, Self::Err>; + + // TODO: remove this once RenderedElement's options field is redone + #[allow(dead_code)] + fn to_string(&self) -> String; +} diff --git a/src/values/alignment.rs b/src/values/alignment.rs new file mode 100644 index 0000000..7e9fcab --- /dev/null +++ b/src/values/alignment.rs @@ -0,0 +1,65 @@ +use iced::Alignment; + +use super::Value; + +#[derive(Debug, thiserror::Error, Clone, PartialEq)] +pub enum ParseAlignmentError { + #[error("cannot parse rotation from empty string")] + Empty, + #[error("invalid variant")] + InvalidVariant, +} + +impl Value for Alignment { + type Err = ParseAlignmentError; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + let s = s.trim(); + + if s.is_empty() { + return Err(ParseAlignmentError::Empty); + } + + match s { + "start" => Ok(Self::Start), + "center" => Ok(Self::Center), + "end" => Ok(Self::End), + _ => Err(ParseAlignmentError::InvalidVariant), + } + } + + fn to_string(&self) -> String { + match self { + Self::Start => String::from("start"), + Self::Center => String::from("center"), + Self::End => String::from("end"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn can_parse_with_spaces() { + assert_eq!(Alignment::from_str(" start"), Ok(Alignment::Start)); + + assert_eq!(Alignment::from_str(" center "), Ok(Alignment::Center)); + + assert_eq!(Alignment::from_str("end "), Ok(Alignment::End)) + } + + #[test] + fn cant_parse_invalid_variant() { + assert_eq!( + Alignment::from_str("middle"), + Err(ParseAlignmentError::InvalidVariant) + ) + } + + #[test] + fn cant_parse_empty_string() { + assert_eq!(Alignment::from_str(" "), Err(ParseAlignmentError::Empty)) + } +} diff --git a/src/values/content_fit.rs b/src/values/content_fit.rs new file mode 100644 index 0000000..053431f --- /dev/null +++ b/src/values/content_fit.rs @@ -0,0 +1,69 @@ +use iced::ContentFit; + +use super::Value; + +#[derive(Debug, thiserror::Error, Clone, PartialEq)] +pub enum ParseContentFitError { + #[error("invalid variant")] + InvalidVariant, +} + +impl Value for ContentFit { + type Err = ParseContentFitError; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + let s = s.trim(); + + if s.is_empty() { + Ok(Self::default()) + } else { + match s { + "fill" => Ok(Self::Fill), + "none" => Ok(Self::None), + "cover" => Ok(Self::Cover), + "contain" => Ok(Self::Contain), + "scale_down" => Ok(Self::ScaleDown), + _ => Err(ParseContentFitError::InvalidVariant), + } + } + } + + fn to_string(&self) -> String { + match self { + Self::Fill => String::from("fill"), + Self::None => String::from("none"), + Self::Cover => String::from("cover"), + Self::Contain => String::from("contain"), + Self::ScaleDown => String::from("scale_down"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn can_parse_with_spaces() { + assert_eq!(ContentFit::from_str(" fill"), Ok(ContentFit::Fill)); + + assert_eq!(ContentFit::from_str(" none "), Ok(ContentFit::None)); + + assert_eq!(ContentFit::from_str("cover "), Ok(ContentFit::Cover)); + + assert_eq!(ContentFit::from_str("contain"), Ok(ContentFit::Contain)); + + assert_eq!( + ContentFit::from_str("scale_down"), + Ok(ContentFit::ScaleDown) + ) + } + + #[test] + fn cant_parse_invalid_variant() { + assert_eq!( + ContentFit::from_str("clip"), + Err(ParseContentFitError::InvalidVariant) + ) + } +} diff --git a/src/values/length.rs b/src/values/length.rs new file mode 100644 index 0000000..556f8ff --- /dev/null +++ b/src/values/length.rs @@ -0,0 +1,145 @@ +use std::num::{ParseFloatError, ParseIntError}; +use std::str::FromStr; + +use iced::Length; + +use super::Value; + +#[derive(Debug, thiserror::Error, Clone, PartialEq)] +pub enum ParseLengthError { + #[error("float parsing error: {0}")] + ParseFloatError(ParseFloatError), + #[error("int parsing error: {0}")] + ParseIntError(ParseIntError), + #[error("invalid type")] + InvalidType, + #[error("invalid prefix")] + InvalidPrefix, + #[error("missing prefix")] + MissingPrefix, + #[error("cannot parse length from empty string")] + Empty, +} + +impl From<ParseFloatError> for ParseLengthError { + fn from(value: ParseFloatError) -> Self { + Self::ParseFloatError(value) + } +} + +impl From<ParseIntError> for ParseLengthError { + fn from(value: ParseIntError) -> Self { + Self::ParseIntError(value) + } +} + +impl Value for Length { + type Err = ParseLengthError; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + let s = s.trim(); + + if s.is_empty() { + return Err(ParseLengthError::Empty); + } + + if !s.contains(|c: char| c.is_ascii_digit()) { + match s { + "fill" => Ok(Self::Fill), + "shrink" => Ok(Self::Shrink), + _ => Err(ParseLengthError::InvalidType), + } + } else { + if s.starts_with(|c: char| c.is_ascii_digit()) { + return Err(ParseLengthError::MissingPrefix); + } + + let (prefix, value) = s.split_at(2); + match prefix.to_lowercase().as_str() { + "fx" => Ok(Self::Fixed(f32::from_str(value)?)), + "fp" => Ok(Self::FillPortion(u16::from_str(value)?)), + _ => Err(ParseLengthError::InvalidPrefix), + } + } + } + + fn to_string(&self) -> String { + match self { + Self::Fill => String::from("fill"), + Self::Shrink => String::from("shrink"), + Self::Fixed(value) => format!("fx{}", value), + Self::FillPortion(value) => format!("fp{}", value), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn can_parse_fill() { + assert_eq!(Length::from_str("fill"), Ok(Length::Fill)) + } + + #[test] + fn can_parse_shrink_with_space() { + assert_eq!(Length::from_str("shrink "), Ok(Length::Shrink)) + } + + #[test] + fn can_parse_fill_portion() { + assert_eq!(Length::from_str("fp15"), Ok(Length::FillPortion(15))) + } + + #[test] + fn can_parse_fixed_with_spaces() { + assert_eq!(Length::from_str(" fx3.1 "), Ok(Length::Fixed(3.1))) + } + + #[test] + fn cant_parse_invalid_type() { + assert_eq!( + Length::from_str("fillportion"), + Err(ParseLengthError::InvalidType) + ) + } + + #[test] + fn cant_parse_invalid_prefix() { + assert_eq!( + Length::from_str("f2.0"), + Err(ParseLengthError::InvalidPrefix), + ) + } + + #[test] + fn cant_parse_invalid_float() { + assert_eq!( + Length::from_str(" fx2.a"), + Err(ParseLengthError::ParseFloatError( + f32::from_str("2.a").expect_err("float parse should fail") + )) + ) + } + + #[test] + fn cant_parse_invalid_integer() { + assert_eq!( + Length::from_str("fp1a "), + Err(ParseLengthError::ParseIntError( + u16::from_str("1a").expect_err("integer parse should fail") + )) + ) + } + + #[test] + fn cant_parse_with_missing_prefix() { + assert_eq!(Length::from_str("24"), Err(ParseLengthError::MissingPrefix)) + } + + #[test] + fn cant_parse_empty_string() { + assert_eq!(Length::from_str(" "), Err(ParseLengthError::Empty)) + } +} diff --git a/src/values/line_height.rs b/src/values/line_height.rs new file mode 100644 index 0000000..0ea1524 --- /dev/null +++ b/src/values/line_height.rs @@ -0,0 +1,117 @@ +use std::num::ParseFloatError; +use std::str::FromStr; + +use iced::Pixels; +use iced::advanced::text::LineHeight; + +use super::Value; + +#[derive(Debug, thiserror::Error, Clone, PartialEq)] +pub enum ParseLineHeightError { + #[error("float parsing error: {0}")] + ParseFloatError(ParseFloatError), + #[error("missing prefix")] + MissingPrefix, + #[error("invalid prefix")] + InvalidPrefix, + #[error("cannot parse line height from empty string")] + Empty, +} + +impl From<ParseFloatError> for ParseLineHeightError { + fn from(value: ParseFloatError) -> Self { + Self::ParseFloatError(value) + } +} + +impl Value for LineHeight { + type Err = ParseLineHeightError; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + let s = s.trim(); + + if s.is_empty() { + return Err(ParseLineHeightError::Empty); + } + + if s.starts_with(|c: char| !c.is_ascii_digit()) { + let (prefix, value) = s.split_at(1); + match prefix.to_lowercase().as_str() { + "r" => Ok(Self::Relative(f32::from_str(value)?)), + "a" => Ok(Self::Absolute(Pixels::from_str(value)?)), + _ => Err(ParseLineHeightError::InvalidPrefix), + } + } else { + Err(ParseLineHeightError::MissingPrefix) + } + } + + fn to_string(&self) -> String { + match self { + Self::Relative(value) => format!("r{}", value), + Self::Absolute(value) => format!("a{}", value.0), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn can_parse_with_r_prefix() { + assert_eq!( + LineHeight::from_str(" r3.2"), + Ok(LineHeight::Relative(3.2)) + ); + + assert_eq!( + LineHeight::from_str(" R6.5 "), + Ok(LineHeight::Relative(6.5)) + ) + } + + #[test] + fn can_parse_with_a_prefix() { + assert_eq!( + LineHeight::from_str("a9.4 "), + Ok(LineHeight::Absolute(Pixels(9.4))) + ); + + assert_eq!( + LineHeight::from_str("A1.3"), + Ok(LineHeight::Absolute(Pixels(1.3))) + ) + } + + #[test] + fn cant_parse_with_missing_prefix() { + assert_eq!( + LineHeight::from_str("5.1"), + Err(ParseLineHeightError::MissingPrefix) + ) + } + + #[test] + fn cant_parse_invalid_prefix() { + assert_eq!( + LineHeight::from_str("g21"), + Err(ParseLineHeightError::InvalidPrefix) + ) + } + + #[test] + fn cant_parse_invalid_float() { + assert_eq!( + LineHeight::from_str("a2f"), + Err(ParseLineHeightError::ParseFloatError( + f32::from_str("2f").expect_err("float parse should fail") + )) + ) + } + + #[test] + fn cant_parse_empty_string() { + assert_eq!(LineHeight::from_str(" "), Err(ParseLineHeightError::Empty)) + } +} diff --git a/src/values/padding.rs b/src/values/padding.rs new file mode 100644 index 0000000..b6d3947 --- /dev/null +++ b/src/values/padding.rs @@ -0,0 +1,201 @@ +use std::num::ParseFloatError; +use std::str::FromStr; + +use iced::Padding; + +use super::Value; + +#[derive(Debug, thiserror::Error, Clone, PartialEq)] +pub enum ParsePaddingError { + #[error("wrong number of values: {0}, expected 1-4")] + WrongNumberOfValues(usize), + #[error("float parsing error: {0}")] + ParseFloatError(ParseFloatError), + #[error("missing bracket")] + MissingBracket, + #[error("cannot parse padding from empty string")] + Empty, +} + +impl From<ParseFloatError> for ParsePaddingError { + fn from(value: ParseFloatError) -> Self { + Self::ParseFloatError(value) + } +} + +impl Value for Padding { + type Err = ParsePaddingError; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + let s = s.trim(); + + if s.is_empty() { + return Err(ParsePaddingError::Empty); + } + + if !s.contains(['[', ',', ']']) { + let value = f32::from_str(s)?; + Ok(Padding { + top: value, + right: value, + bottom: value, + left: value, + }) + } else { + let values = s + .strip_prefix('[') + .and_then(|s| s.strip_suffix(']')) + .ok_or(ParsePaddingError::MissingBracket)? + .split(',') + .map(str::trim) + .map(f32::from_str) + .collect::<Result<Vec<_>, _>>()?; + + match values.len() { + 1 => Ok(Padding { + top: values[0], + right: values[0], + bottom: values[0], + left: values[0], + }), + 2 => Ok(Padding { + top: values[0], + right: values[1], + bottom: values[0], + left: values[1], + }), + 3 => Ok(Padding { + top: values[0], + right: values[1], + bottom: values[2], + left: values[1], + }), + 4 => Ok(Padding { + top: values[0], + right: values[1], + bottom: values[2], + left: values[3], + }), + other => Err(ParsePaddingError::WrongNumberOfValues(other)), + } + } + } + + fn to_string(&self) -> String { + format!( + "[{}, {}, {}, {}]", + self.top, self.right, self.bottom, self.left + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn can_parse_single_value() { + assert_eq!( + Padding::from_str("[1.5]"), + Ok(Padding { + top: 1.5, + right: 1.5, + bottom: 1.5, + left: 1.5, + }), + ) + } + + #[test] + fn can_parse_single_value_without_brackets() { + assert_eq!( + Padding::from_str("1.5"), + Ok(Padding { + top: 1.5, + right: 1.5, + bottom: 1.5, + left: 1.5, + }), + ) + } + + #[test] + fn can_parse_two_values() { + assert_eq!( + Padding::from_str("[3.2, 6.7]"), + Ok(Padding { + top: 3.2, + right: 6.7, + bottom: 3.2, + left: 6.7, + }), + ) + } + + #[test] + fn can_parse_three_values() { + assert_eq!( + Padding::from_str("[4.8, 8.1,5.9]"), + Ok(Padding { + top: 4.8, + right: 8.1, + bottom: 5.9, + left: 8.1, + }), + ) + } + + #[test] + fn can_parse_four_values() { + assert_eq!( + Padding::from_str("[35.4,74.6 ,53.1, 25.0]"), + Ok(Padding { + top: 35.4, + right: 74.6, + bottom: 53.1, + left: 25.0, + }), + ) + } + + #[test] + fn cant_parse_five_values() { + assert_eq!( + Padding::from_str("[1,2,3,4,5]"), + Err(ParsePaddingError::WrongNumberOfValues(5)), + ) + } + + #[test] + fn cant_parse_invalid_floats() { + assert_eq!( + Padding::from_str("[1f,2,3,4]"), + Err(ParsePaddingError::ParseFloatError( + f32::from_str("1f").expect_err("float parse should fail") + )) + ) + } + + #[test] + fn cant_parse_with_missing_bracket() { + assert_eq!( + Padding::from_str("1,2,3,4,5]"), + Err(ParsePaddingError::MissingBracket) + ); + + assert_eq!( + Padding::from_str("[1,2,3,4,5"), + Err(ParsePaddingError::MissingBracket) + ); + + assert_eq!( + Padding::from_str("1,2,3,4,5"), + Err(ParsePaddingError::MissingBracket) + ) + } + + #[test] + fn cant_parse_empty_string() { + assert_eq!(Padding::from_str(" "), Err(ParsePaddingError::Empty)) + } +} diff --git a/src/values/pixels.rs b/src/values/pixels.rs new file mode 100644 index 0000000..b2b0047 --- /dev/null +++ b/src/values/pixels.rs @@ -0,0 +1,18 @@ +use std::num::ParseFloatError; +use std::str::FromStr; + +use iced::Pixels; + +use super::Value; + +impl Value for Pixels { + type Err = ParseFloatError; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + Ok(Pixels(f32::from_str(s.trim())?)) + } + + fn to_string(&self) -> String { + self.0.to_string() + } +} diff --git a/src/values/rotation.rs b/src/values/rotation.rs new file mode 100755 index 0000000..90e3f84 --- /dev/null +++ b/src/values/rotation.rs @@ -0,0 +1,114 @@ +use std::num::ParseFloatError; +use std::str::FromStr; + +use iced::{Radians, Rotation}; + +use super::Value; + +#[derive(Debug, thiserror::Error, Clone, PartialEq)] +pub enum ParseRotationError { + #[error("float parsing error: {0}")] + ParseFloatError(ParseFloatError), + #[error("invalid prefix")] + InvalidPrefix, + #[error("cannot parse rotation from empty string")] + Empty, +} + +impl From<ParseFloatError> for ParseRotationError { + fn from(value: ParseFloatError) -> Self { + Self::ParseFloatError(value) + } +} + +impl Value for Rotation { + type Err = ParseRotationError; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + let s = s.trim(); + + if s.is_empty() { + return Err(ParseRotationError::Empty); + } + + if s.starts_with(|c: char| !c.is_ascii_digit()) { + let (prefix, value) = s.split_at(1); + match prefix.to_lowercase().as_str() { + "s" => Ok(Rotation::Solid(Radians(f32::from_str(value)?))), + "f" => Ok(Rotation::Floating(Radians(f32::from_str(value)?))), + _ => Err(ParseRotationError::InvalidPrefix), + } + } else { + Ok(Rotation::Floating(Radians(f32::from_str(s)?))) + } + } + + fn to_string(&self) -> String { + match self { + Self::Floating(value) => format!("f{}", value), + Self::Solid(value) => format!("s{}", value), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn can_parse_without_prefix() { + assert_eq!( + Rotation::from_str("10.5"), + Ok(Rotation::Floating(Radians(10.5))) + ) + } + + #[test] + fn can_parse_with_s_prefix() { + assert_eq!( + Rotation::from_str(" s12.3"), + Ok(Rotation::Solid(Radians(12.3))) + ); + + assert_eq!( + Rotation::from_str("S9.4"), + Ok(Rotation::Solid(Radians(9.4))) + ) + } + + #[test] + fn can_parse_with_f_prefix() { + assert_eq!( + Rotation::from_str("f16.9"), + Ok(Rotation::Floating(Radians(16.9))) + ); + + assert_eq!( + Rotation::from_str("F21.45 "), + Ok(Rotation::Floating(Radians(21.45))) + ) + } + + #[test] + fn cant_parse_invalid_prefix() { + assert_eq!( + Rotation::from_str("a6.0"), + Err(ParseRotationError::InvalidPrefix) + ) + } + + #[test] + fn cant_parse_invalid_float() { + assert_eq!( + Rotation::from_str("3.a"), + Err(ParseRotationError::ParseFloatError( + f32::from_str("3.a").expect_err("float parse should fail") + )) + ) + } + + #[test] + fn cant_parse_empty_string() { + assert_eq!(Rotation::from_str(" "), Err(ParseRotationError::Empty)) + } +} |
