summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorPolesznyák Márk László <116908301+pml68@users.noreply.github.com>2025-03-23 02:49:57 +0100
committerGitHub <noreply@github.com>2025-03-23 02:49:57 +0100
commit3076889c00116b22f022792471253e7188c6e93e (patch)
treebff0cda7a9152e9f94d3176bbf5acaf879394f5f /src
parentfeat: update to Rust 2024 (diff)
parentfeat: finish `ApplyOptions` impls (diff)
downloadiced-builder-3076889c00116b22f022792471253e7188c6e93e.tar.gz
Merge pull request #7 from pml68/feat/options-backend
Options backend done (for now)
Diffstat (limited to 'src')
-rw-r--r--src/config.rs3
-rw-r--r--src/dialogs.rs19
-rw-r--r--src/environment.rs13
-rw-r--r--src/error.rs6
-rw-r--r--src/main.rs147
-rw-r--r--src/options.rs334
-rw-r--r--src/panes/code_view.rs3
-rw-r--r--src/types.rs14
-rw-r--r--src/types/project.rs7
-rwxr-xr-xsrc/types/rendered_element.rs105
-rw-r--r--src/values.rs17
-rw-r--r--src/values/alignment.rs65
-rw-r--r--src/values/content_fit.rs69
-rw-r--r--src/values/length.rs145
-rw-r--r--src/values/line_height.rs117
-rw-r--r--src/values/padding.rs201
-rw-r--r--src/values/pixels.rs18
-rwxr-xr-xsrc/values/rotation.rs114
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))
+ }
+}