diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/config.rs | 121 | ||||
| -rw-r--r-- | src/dialogs.rs | 30 | ||||
| -rw-r--r-- | src/environment.rs | 43 | ||||
| -rw-r--r-- | src/error.rs | 55 | ||||
| -rw-r--r-- | src/icon.rs | 23 | ||||
| -rw-r--r-- | src/main.rs | 382 | ||||
| -rw-r--r-- | src/panes.rs | 4 | ||||
| -rw-r--r-- | src/panes/code_view.rs | 50 | ||||
| -rw-r--r-- | src/panes/designer_view.rs | 37 | ||||
| -rw-r--r-- | src/panes/element_list.rs | 49 | ||||
| -rw-r--r-- | src/panes/style.rs | 40 | ||||
| -rw-r--r-- | src/theme.rs | 381 | ||||
| -rw-r--r-- | src/types.rs | 48 | ||||
| -rw-r--r-- | src/types/element_name.rs | 85 | ||||
| -rw-r--r-- | src/types/project.rs | 165 | ||||
| -rwxr-xr-x | src/types/rendered_element.rs | 468 | ||||
| -rw-r--r-- | src/widget.rs | 21 |
17 files changed, 2002 insertions, 0 deletions
diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..9d29af7 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,121 @@ +use std::path::PathBuf; + +use serde::Deserialize; +use tokio_stream::wrappers::ReadDirStream; +use tokio_stream::StreamExt; + +use crate::theme::{theme_from_str, theme_index, Appearance, Theme}; +use crate::{environment, Error}; + +#[derive(Debug, Clone, Default)] +pub struct Config { + pub theme: Appearance, + pub last_project: Option<PathBuf>, +} + +impl Config { + pub fn selected_theme(&self) -> iced::Theme { + self.theme.selected.clone() + } + + pub fn config_dir() -> PathBuf { + let dir = environment::config_dir(); + + if !dir.exists() { + std::fs::create_dir_all(dir.as_path()) + .expect("expected permissions to create config folder"); + } + dir + } + + pub fn themes_dir() -> PathBuf { + let dir = Self::config_dir().join("themes"); + + if !dir.exists() { + std::fs::create_dir_all(dir.as_path()) + .expect("expected permissions to create themes folder"); + } + dir + } + + pub fn config_file_path() -> PathBuf { + Self::config_dir().join(environment::CONFIG_FILE_NAME) + } + + pub async fn load() -> Result<Self, Error> { + use tokio::fs; + + #[derive(Deserialize)] + pub struct Configuration { + #[serde(default)] + pub theme: String, + pub last_project: Option<PathBuf>, + } + + let path = Self::config_file_path(); + if !path.try_exists()? { + return Err(Error::ConfigMissing); + } + + let content = fs::read_to_string(path).await?; + + let Configuration { + theme, + last_project, + } = toml::from_str(content.as_ref())?; + + let theme = Self::load_theme(theme).await.unwrap_or_default(); + + Ok(Self { + theme, + last_project, + }) + } + + pub async fn load_theme(theme_name: String) -> Result<Appearance, Error> { + use tokio::fs; + + let read_entry = |entry: fs::DirEntry| async move { + let content = fs::read_to_string(entry.path()).await.ok()?; + + let theme: Theme = toml::from_str(content.as_ref()).ok()?; + let name = entry.path().file_stem()?.to_string_lossy().to_string(); + + Some(theme.into_iced_theme(name)) + }; + + let mut all = iced::Theme::ALL.to_owned(); + let mut selected = iced::Theme::default(); + + if theme_index(&theme_name, iced::Theme::ALL).is_some() { + selected = theme_from_str(None, &theme_name); + } + + let mut stream = + ReadDirStream::new(fs::read_dir(Self::themes_dir()).await?); + while let Some(entry) = stream.next().await { + let Ok(entry) = entry else { + continue; + }; + + let Some(file_name) = entry.file_name().to_str().map(String::from) + else { + continue; + }; + + if let Some(file_name) = file_name.strip_suffix(".toml") { + if let Some(theme) = read_entry(entry).await { + if file_name == theme_name { + selected = theme.clone(); + } + all.push(theme); + } + } + } + + Ok(Appearance { + selected, + all: all.into(), + }) + } +} diff --git a/src/dialogs.rs b/src/dialogs.rs new file mode 100644 index 0000000..2d916b1 --- /dev/null +++ b/src/dialogs.rs @@ -0,0 +1,30 @@ +use rfd::{MessageButtons, MessageDialog, MessageDialogResult, MessageLevel}; + +pub fn error_dialog(description: impl Into<String>) { + let _ = MessageDialog::new() + .set_level(MessageLevel::Error) + .set_buttons(MessageButtons::Ok) + .set_title("Oops! Something went wrong.") + .set_description(description) + .show(); +} + +pub fn warning_dialog(description: impl Into<String>) { + let _ = MessageDialog::new() + .set_level(MessageLevel::Warning) + .set_buttons(MessageButtons::Ok) + .set_title("Heads up!") + .set_description(description) + .show(); +} + +pub fn unsaved_changes_dialog(description: impl Into<String>) -> bool { + let result = MessageDialog::new() + .set_level(MessageLevel::Warning) + .set_buttons(MessageButtons::OkCancel) + .set_title("Unsaved changes") + .set_description(description) + .show(); + + matches!(result, MessageDialogResult::Ok) +} diff --git a/src/environment.rs b/src/environment.rs new file mode 100644 index 0000000..3ecb790 --- /dev/null +++ b/src/environment.rs @@ -0,0 +1,43 @@ +use std::env; +use std::path::PathBuf; + +pub const CONFIG_FILE_NAME: &str = "config.toml"; + +pub fn config_dir() -> PathBuf { + portable_dir().unwrap_or_else(platform_specific_config_dir) +} + +fn portable_dir() -> Option<PathBuf> { + let exe = env::current_exe().ok()?; + let dir = exe.parent()?; + + dir.join(CONFIG_FILE_NAME) + .is_file() + .then(|| dir.to_path_buf()) +} + +fn platform_specific_config_dir() -> PathBuf { + #[cfg(target_os = "macos")] + { + xdg_config_dir().unwrap_or_else(|| { + dirs_next::config_dir() + .expect("expected valid config dir") + .join("iced-builder") + }) + } + #[cfg(not(target_os = "macos"))] + { + dirs_next::config_dir() + .expect("expected valid config dir") + .join("iced-builder") + } +} + +#[cfg(target_os = "macos")] +fn xdg_config_dir() -> Option<PathBuf> { + let config_dir = xdg::BaseDirectories::with_prefix("iced-builder") + .ok() + .and_then(|xdg| xdg.find_config_file(CONFIG_FILE_NAME))?; + + config_dir.parent().map(|p| p.to_path_buf()) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..f4011bd --- /dev/null +++ b/src/error.rs @@ -0,0 +1,55 @@ +use std::io; +use std::sync::Arc; + +use thiserror::Error; + +#[derive(Debug, Clone, Error)] +#[error(transparent)] +pub enum Error { + IO(Arc<io::Error>), + #[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")] + NonExistentElement, + #[error( + "the file dialog has been closed without selecting a valid option" + )] + DialogClosed, + #[error("{0}")] + Other(String), +} + +impl From<io::Error> for Error { + fn from(value: io::Error) -> Self { + Self::IO(Arc::new(value)) + } +} + +impl From<serde_json::Error> for Error { + fn from(value: serde_json::Error) -> Self { + Self::SerdeJSON(Arc::new(value)) + } +} + +impl From<rust_format::Error> for Error { + fn from(value: rust_format::Error) -> Self { + Self::RustFmt(Arc::new(value)) + } +} + +impl From<&str> for Error { + fn from(value: &str) -> Self { + Self::Other(value.to_owned()) + } +} + +impl From<String> for Error { + fn from(value: String) -> Self { + Self::Other(value) + } +} diff --git a/src/icon.rs b/src/icon.rs new file mode 100644 index 0000000..f6760d5 --- /dev/null +++ b/src/icon.rs @@ -0,0 +1,23 @@ +// Generated automatically by iced_fontello at build time. +// Do not edit manually. Source: ../fonts/icons.toml +// 02c7558d187cdc056fdd0e6a638ef805fa10f5955f834575e51d75acd35bc70e +use iced::widget::{text, Text}; +use iced::Font; + +pub const FONT: &[u8] = include_bytes!("../fonts/icons.ttf"); + +pub fn copy<'a>() -> Text<'a> { + icon("\u{F1C9}") +} + +pub fn open<'a>() -> Text<'a> { + icon("\u{F115}") +} + +pub fn save<'a>() -> Text<'a> { + icon("\u{1F4BE}") +} + +fn icon<'a>(codepoint: &'a str) -> Text<'a> { + text(codepoint).font(Font::with_name("icons")) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..5b95b94 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,382 @@ +#![feature(test)] +mod config; +mod dialogs; +mod environment; +mod error; +mod icon; +mod panes; +mod theme; +mod types; +mod widget; + +use std::path::PathBuf; + +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::{container, pick_list, row, text_editor, Column}; +use iced::{clipboard, keyboard, Alignment, Element, Length, Task, Theme}; +use iced_anim::transition::Easing; +use iced_anim::{Animated, Animation}; +use panes::{code_view, designer_view, element_list}; +use tokio::runtime; +use types::{Action, DesignerPage, ElementName, Message, Project}; + +//pub(crate) type Result<T> = core::result::Result<T, Error>; + +fn main() -> Result<(), Box<dyn std::error::Error>> { + let config_load = { + let rt = runtime::Builder::new_current_thread() + .enable_all() + .build()?; + + rt.block_on(Config::load()) + }; + + iced::application(App::title, App::update, App::view) + .font(icon::FONT) + .theme(|state| state.theme.value().clone()) + .subscription(App::subscription) + .run_with(move || App::new(config_load))?; + + Ok(()) +} + +struct App { + is_dirty: bool, + is_loading: bool, + project_path: Option<PathBuf>, + project: Project, + config: Config, + theme: Animated<Theme>, + pane_state: pane_grid::State<Panes>, + focus: Option<Pane>, + designer_page: DesignerPage, + element_list: &'static [ElementName], + editor_content: text_editor::Content, +} + +#[derive(Clone, Copy, Debug)] +enum Panes { + Designer, + ElementList, +} + +impl App { + fn new(config_load: Result<Config, Error>) -> (Self, Task<Message>) { + let state = pane_grid::State::with_configuration( + pane_grid::Configuration::Split { + axis: pane_grid::Axis::Vertical, + ratio: 0.8, + a: Box::new(pane_grid::Configuration::Pane(Panes::Designer)), + b: Box::new(pane_grid::Configuration::Pane(Panes::ElementList)), + }, + ); + + let config = config_load.unwrap_or_default(); + let theme = config.selected_theme(); + + let mut task = Task::none(); + + if let Some(path) = config.last_project.clone() { + if path.exists() && path.is_file() { + task = Task::perform( + Project::from_path(path, config.clone()), + Message::FileOpened, + ); + } else { + warning_dialog(format!( + "The file {} does not exist, or isn't a file.", + path.to_string_lossy() + )); + } + } + + ( + Self { + is_dirty: false, + is_loading: false, + project_path: None, + project: Project::new(), + config, + theme: Animated::new(theme, Easing::EASE_IN), + pane_state: state, + focus: None, + designer_page: DesignerPage::DesignerView, + element_list: ElementName::ALL, + editor_content: text_editor::Content::new(), + }, + task, + ) + } + + fn title(&self) -> String { + let saved_state = if self.is_dirty { " *" } else { "" }; + + let project_name = match &self.project.title { + Some(n) => { + format!( + " - {}", + if n.len() > 60 { + format!("...{}", &n[n.len() - 40..]) + } else { + n.to_owned() + } + ) + } + None => String::new(), + }; + + format!("iced Builder{project_name}{saved_state}") + } + + fn update(&mut self, message: Message) -> Task<Message> { + match message { + Message::ToggleTheme(event) => { + self.theme.update(event); + } + Message::CopyCode => { + return clipboard::write(self.editor_content.text()) + } + Message::SwitchPage(page) => self.designer_page = page, + Message::EditorAction(action) => { + if let text_editor::Action::Scroll { lines: _ } = action { + self.editor_content.perform(action); + } + } + Message::RefreshEditorContent => { + match self.project.app_code(&self.config) { + Ok(code) => { + self.editor_content = + text_editor::Content::with_text(&code); + } + Err(error) => error_dialog(error.to_string()), + } + } + Message::DropNewElement(name, point, _) => { + return 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(); + if !ids.is_empty() { + let eltree_clone = self.project.element_tree.clone(); + let action = Action::new(&ids, &eltree_clone, None); + let result = name.handle_action( + self.project.element_tree.as_mut(), + action, + ); + match result { + Ok(Some(ref element)) => { + self.project.element_tree = Some(element.clone()); + } + Err(error) => error_dialog(error.to_string()), + _ => {} + } + + self.is_dirty = true; + return Task::done(Message::RefreshEditorContent); + } + } + Message::MoveElement(element, point, _) => { + return 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(); + if !ids.is_empty() { + let eltree_clone = self.project.element_tree.clone(); + let action = Action::new( + &ids, + &eltree_clone, + Some(element.get_id()), + ); + let result = element.handle_action( + self.project.element_tree.as_mut(), + action, + ); + if let Err(error) = result { + error_dialog(error.to_string()); + } + + self.is_dirty = true; + return Task::done(Message::RefreshEditorContent); + } + } + Message::PaneResized(pane_grid::ResizeEvent { split, ratio }) => { + self.pane_state.resize(split, ratio); + } + Message::PaneClicked(pane) => { + self.focus = Some(pane); + } + Message::PaneDragged(pane_grid::DragEvent::Dropped { + pane, + target, + }) => { + self.pane_state.drop(pane, target); + } + Message::PaneDragged(_) => {} + Message::NewFile => { + if !self.is_loading { + if !self.is_dirty { + self.project = Project::new(); + self.project_path = None; + self.editor_content = text_editor::Content::new(); + } else if unsaved_changes_dialog("You have unsaved changes. Do you wish to discard these and create a new project?") { + self.is_dirty = false; + self.project = Project::new(); + self.project_path = None; + self.editor_content = text_editor::Content::new(); + } + } + } + Message::OpenFile => { + if !self.is_loading { + if !self.is_dirty { + self.is_loading = true; + + return 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(Project::from_file(self.config.clone()), Message::FileOpened); + } + } + } + Message::FileOpened(result) => { + self.is_loading = false; + self.is_dirty = false; + + match result { + Ok((path, project)) => { + self.project = project; + self.project_path = Some(path); + self.editor_content = text_editor::Content::with_text( + &self + .project + .app_code(&self.config) + .unwrap_or_else(|err| err.to_string()), + ); + } + Err(error) => error_dialog(error.to_string()), + } + } + Message::SaveFile => { + if !self.is_loading { + self.is_loading = true; + + return Task::perform( + self.project + .clone() + .write_to_file(self.project_path.clone()), + Message::FileSaved, + ); + } + } + Message::SaveFileAs => { + if !self.is_loading { + self.is_loading = true; + + return Task::perform( + self.project.clone().write_to_file(None), + Message::FileSaved, + ); + } + } + Message::FileSaved(result) => { + self.is_loading = false; + + match result { + Ok(path) => { + self.project_path = Some(path); + self.is_dirty = false; + } + Err(error) => error_dialog(error.to_string()), + } + } + } + + Task::none() + } + + fn subscription(&self) -> iced::Subscription<Message> { + keyboard::on_key_press(|key, modifiers| { + if modifiers.command() { + match key.as_ref() { + keyboard::Key::Character("o") => Some(Message::OpenFile), + keyboard::Key::Character("s") => { + Some(if modifiers.shift() { + Message::SaveFileAs + } else { + Message::SaveFile + }) + } + keyboard::Key::Character("n") => Some(Message::NewFile), + _ => None, + } + } else { + None + } + }) + } + + fn view(&self) -> Element<'_, Message> { + let header = row![pick_list( + self.config.theme.all.clone(), + Some(self.theme.target().clone()), + |theme| { Message::ToggleTheme(theme.into()) } + )] + .width(200); + let 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 { + DesignerPage::DesignerView => designer_view::view( + &self.project.element_tree, + self.project.get_theme(&self.config), + is_focused, + ), + DesignerPage::CodeView => code_view::view( + &self.editor_content, + self.theme.target().clone(), + 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); + + let content = Column::new() + .push(header) + .push(pane_grid) + .spacing(5) + .align_x(Alignment::Center) + .width(Length::Fill); + + Animation::new(&self.theme, container(content).height(Length::Fill)) + .on_update(Message::ToggleTheme) + .into() + } +} diff --git a/src/panes.rs b/src/panes.rs new file mode 100644 index 0000000..387662a --- /dev/null +++ b/src/panes.rs @@ -0,0 +1,4 @@ +pub mod code_view; +pub mod designer_view; +pub mod element_list; +mod style; diff --git a/src/panes/code_view.rs b/src/panes/code_view.rs new file mode 100644 index 0000000..f545157 --- /dev/null +++ b/src/panes/code_view.rs @@ -0,0 +1,50 @@ +use iced::widget::{button, pane_grid, row, text, text_editor, Space}; +use iced::{Alignment, Font, Length, Theme}; +use super::style; +use crate::icon::copy; +use crate::types::{DesignerPage, Message}; +use crate::widget::tip; + +pub fn view( + editor_content: &text_editor::Content, + theme: Theme, + is_focused: bool, +) -> pane_grid::Content<'_, Message> { + let title = row![ + text("Generated Code"), + Space::with_width(Length::Fill), + tip( + button(copy()).on_press(Message::CopyCode), + "Copy code to clipboard", + tip::Position::FollowCursor + ), + Space::with_width(20), + button("Switch to Designer view") + .on_press(Message::SwitchPage(DesignerPage::DesignerView)) + ] + .align_y(Alignment::Center); + let title_bar = pane_grid::TitleBar::new(title) + .padding(10) + .style(style::title_bar); + pane_grid::Content::new( + text_editor(editor_content) + .on_action(Message::EditorAction) + .highlight( + "rs", + if theme.to_string().contains("Dark") { + highlighter::Theme::SolarizedDark + } else { + highlighter::Theme::InspiredGitHub + }, + .font(Font::MONOSPACE) + ) + .height(Length::Fill) + .padding(20), + ) + .title_bar(title_bar) + .style(if is_focused { + style::pane_focused + } else { + style::pane_active + }) +} diff --git a/src/panes/designer_view.rs b/src/panes/designer_view.rs new file mode 100644 index 0000000..76456db --- /dev/null +++ b/src/panes/designer_view.rs @@ -0,0 +1,37 @@ +use iced::widget::{button, container, pane_grid, row, text, themer, Space}; +use iced::{Alignment, Element, Length}; + +use super::style; +use crate::types::{DesignerPage, Message, RenderedElement}; + +pub fn view<'a>( + element_tree: &Option<RenderedElement>, + designer_theme: iced::Theme, + is_focused: bool, +) -> pane_grid::Content<'a, Message> { + let el_tree: Element<'a, Message> = match element_tree { + Some(tree) => tree.clone().into(), + None => text("Open a project or begin creating one").into(), + }; + let content = container(themer(designer_theme, el_tree)) + .id(iced::widget::container::Id::new("drop_zone")) + .height(Length::Fill) + .width(Length::Fill); + let title = row![ + text("Designer"), + Space::with_width(Length::Fill), + button("Switch to Code view") + .on_press(Message::SwitchPage(DesignerPage::CodeView)), + ] + .align_y(Alignment::Center); + let title_bar = pane_grid::TitleBar::new(title) + .padding(10) + .style(style::title_bar); + pane_grid::Content::new(content) + .title_bar(title_bar) + .style(if is_focused { + style::pane_focused + } else { + style::pane_active + }) +} diff --git a/src/panes/element_list.rs b/src/panes/element_list.rs new file mode 100644 index 0000000..8a1c6eb --- /dev/null +++ b/src/panes/element_list.rs @@ -0,0 +1,49 @@ +use iced::widget::{column, container, pane_grid, text, Column}; +use iced::{Alignment, Element, Length}; +use iced_drop::droppable; + +use super::style; +use crate::types::{ElementName, Message}; + +fn items_list_view(items: &[ElementName]) -> Element<'_, Message> { + let mut column = Column::new() + .spacing(20) + .align_x(Alignment::Center) + .width(Length::Fill); + + for item in items { + column = + column.push(droppable(text(item.clone().to_string())).on_drop( + move |point, rect| { + Message::DropNewElement(item.clone(), point, rect) + }, + )); + } + + container(column) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +pub fn view( + element_list: &[ElementName], + is_focused: bool, +) -> pane_grid::Content<'_, Message> { + let items_list = items_list_view(element_list); + let content = column![items_list] + .align_x(Alignment::Center) + .height(Length::Fill) + .width(Length::Fill); + let title = text("Element List"); + let title_bar = pane_grid::TitleBar::new(title) + .padding(10) + .style(style::title_bar); + pane_grid::Content::new(content) + .title_bar(title_bar) + .style(if is_focused { + style::pane_focused + } else { + style::pane_active + }) +} diff --git a/src/panes/style.rs b/src/panes/style.rs new file mode 100644 index 0000000..1eefb2d --- /dev/null +++ b/src/panes/style.rs @@ -0,0 +1,40 @@ +use iced::widget::container::Style; +use iced::{Border, Theme}; + +pub fn title_bar(theme: &Theme) -> Style { + let palette = theme.extended_palette(); + + Style { + text_color: Some(palette.background.strong.text), + background: Some(palette.background.strong.color.into()), + ..Default::default() + } +} + +pub fn pane_active(theme: &Theme) -> Style { + let palette = theme.extended_palette(); + + Style { + background: Some(palette.background.weak.color.into()), + border: Border { + width: 1.0, + color: palette.background.strong.color, + ..Border::default() + }, + ..Default::default() + } +} + +pub fn pane_focused(theme: &Theme) -> Style { + let palette = theme.extended_palette(); + + Style { + background: Some(palette.background.weak.color.into()), + border: Border { + width: 4.0, + color: palette.background.strong.color, + ..Border::default() + }, + ..Default::default() + } +} diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..7d18aa9 --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,381 @@ +use std::sync::Arc; + +use iced::theme::palette::Extended; +use iced::Color; + +use crate::config::Config; + +pub fn theme_index(theme_name: &str, slice: &[iced::Theme]) -> Option<usize> { + slice + .iter() + .position(|theme| theme.to_string() == theme_name) +} + +pub fn theme_from_str( + config: Option<&Config>, + theme_name: &str, +) -> iced::Theme { + match theme_name { + "Light" => iced::Theme::Light, + "Dark" => iced::Theme::Dark, + "Dracula" => iced::Theme::Dracula, + "Nord" => iced::Theme::Nord, + "Solarized Light" => iced::Theme::SolarizedLight, + "Solarized Dark" => iced::Theme::SolarizedDark, + "Gruvbox Light" => iced::Theme::GruvboxLight, + "Gruvbox Dark" => iced::Theme::GruvboxDark, + "Catppuccin Latte" => iced::Theme::CatppuccinLatte, + "Catppuccin Frappé" => iced::Theme::CatppuccinFrappe, + "Catppuccin Macchiato" => iced::Theme::CatppuccinMacchiato, + "Catppuccin Mocha" => iced::Theme::CatppuccinMocha, + "Tokyo Night" => iced::Theme::TokyoNight, + "Tokyo Night Storm" => iced::Theme::TokyoNightStorm, + "Tokyo Night Light" => iced::Theme::TokyoNightLight, + "Kanagawa Wave" => iced::Theme::KanagawaWave, + "Kanagawa Dragon" => iced::Theme::KanagawaDragon, + "Kanagawa Lotus" => iced::Theme::KanagawaLotus, + "Moonfly" => iced::Theme::Moonfly, + "Nightfly" => iced::Theme::Nightfly, + "Oxocarbon" => iced::Theme::Oxocarbon, + "Ferra" => iced::Theme::Ferra, + _ => { + if let Some(config) = config { + if theme_name == config.theme.selected.to_string() { + config.theme.selected.clone() + } else if let Some(index) = + theme_index(theme_name, &config.theme.all) + { + config.theme.all[index].clone() + } else { + iced::Theme::default() + } + } else { + iced::Theme::default() + } + } + } +} + +fn palette_to_string(palette: &iced::theme::Palette) -> String { + format!( + r#"Palette {{ + background: color!(0x{}), + text: color!(0x{}), + primary: color!(0x{}), + success: color!(0x{}), + danger: color!(0x{}), + }}"#, + color_to_hex(palette.background), + color_to_hex(palette.text), + color_to_hex(palette.primary), + color_to_hex(palette.success), + color_to_hex(palette.danger), + ) +} + +fn extended_to_string(extended: &Extended) -> String { + format!( + r#" +Extended{{background:Background{{base:Pair{{color:color!(0x{}),text:color!(0x{}),}},weak:Pair{{color:color!(0x{}),text:color!(0x{}),}},strong:Pair{{color:color!(0x{}),text:color!(0x{}),}},}},primary:Primary{{base:Pair{{color:color!(0x{}),text:color!(0x{}),}},weak:Pair{{color:color!(0x{}),text:color!(0x{}),}},strong:Pair{{color:color!(0x{}),text:color!(0x{}),}},}},secondary:Secondary{{base:Pair{{color:color!(0x{}),text:color!(0x{}),}},weak:Pair{{color:color!(0x{}),text:color!(0x{}),}},strong:Pair{{color:color!(0x{}),text:color!(0x{}),}},}},success:Success{{base:Pair{{color:color!(0x{}),text:color!(0x{}),}},weak:Pair{{color:color!(0x{}),text:color!(0x{}),}},strong:Pair{{color:color!(0x{}),text:color!(0x{}),}},}},danger:Danger{{base:Pair{{color:color!(0x{}),text:color!(0x{}),}},weak:Pair{{color:color!(0x{}),text:color!(0x{}),}},strong:Pair{{color:color!(0x{}),text:color!(0x{}),}},}},is_dark:true,}}"#, + color_to_hex(extended.background.base.color), + color_to_hex(extended.background.base.text), + color_to_hex(extended.background.weak.color), + color_to_hex(extended.background.weak.text), + color_to_hex(extended.background.strong.color), + color_to_hex(extended.background.strong.text), + color_to_hex(extended.primary.base.color), + color_to_hex(extended.primary.base.text), + color_to_hex(extended.primary.weak.color), + color_to_hex(extended.primary.weak.text), + color_to_hex(extended.primary.strong.color), + color_to_hex(extended.primary.strong.text), + color_to_hex(extended.secondary.base.color), + color_to_hex(extended.secondary.base.text), + color_to_hex(extended.secondary.weak.color), + color_to_hex(extended.secondary.weak.text), + color_to_hex(extended.secondary.strong.color), + color_to_hex(extended.secondary.strong.text), + color_to_hex(extended.success.base.color), + color_to_hex(extended.success.base.text), + color_to_hex(extended.success.weak.color), + color_to_hex(extended.success.weak.text), + color_to_hex(extended.success.strong.color), + color_to_hex(extended.success.strong.text), + color_to_hex(extended.danger.base.color), + color_to_hex(extended.danger.base.text), + color_to_hex(extended.danger.weak.color), + color_to_hex(extended.danger.weak.text), + color_to_hex(extended.danger.strong.color), + color_to_hex(extended.danger.strong.text), + ) +} + +pub fn theme_to_string(theme: &iced::Theme) -> String { + let palette = theme.palette(); + let extended = theme.extended_palette(); + + let generated_extended = Extended::generate(palette); + + if &generated_extended == extended { + format!( + r#"custom( + "{}".to_string(), + {} + )"#, + theme, + palette_to_string(&palette) + ) + } else { + format!( + r#"custom_with_fn( + "{}".to_string(), + {}, + |_| {} + )"#, + theme, + palette_to_string(&palette), + extended_to_string(extended) + ) + } +} + +fn color_to_hex(color: Color) -> String { + use std::fmt::Write; + + let mut hex = String::with_capacity(12); + + let [r, g, b, a] = color.into_rgba8(); + + let _ = write!(&mut hex, "{:02X}", r); + let _ = write!(&mut hex, "{:02X}", g); + let _ = write!(&mut hex, "{:02X}", b); + + if a < u8::MAX { + let _ = write!(&mut hex, ", {:.2}", a as f32 / 255.0); + } + + hex +} + +#[derive(Debug, Clone)] +pub struct Appearance { + pub selected: iced::Theme, + pub all: Arc<[iced::Theme]>, +} + +impl Default for Appearance { + fn default() -> Self { + Self { + selected: iced::Theme::default(), + all: iced::Theme::ALL.into(), + } + } +} + +#[derive(Debug, Default, serde::Deserialize)] +pub struct Theme { + palette: ThemePalette, + is_dark: Option<bool>, + #[serde(flatten)] + extended: Option<ExtendedThemePalette>, +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct ThemePalette { + #[serde(with = "color_serde")] + background: Color, + #[serde(with = "color_serde")] + text: Color, + #[serde(with = "color_serde")] + primary: Color, + #[serde(with = "color_serde")] + success: Color, + #[serde(with = "color_serde")] + danger: Color, +} + +impl Theme { + pub fn into_iced_theme(self, name: String) -> iced::Theme { + iced::Theme::custom_with_fn(name, self.palette.clone().into(), |_| { + self.into() + }) + } +} + +impl Default for ThemePalette { + fn default() -> Self { + let palette = iced::Theme::default().palette(); + Self { + background: palette.background, + text: palette.text, + primary: palette.primary, + success: palette.success, + danger: palette.danger, + } + } +} + +impl From<ThemePalette> for iced::theme::Palette { + fn from(palette: ThemePalette) -> Self { + iced::theme::Palette { + background: palette.background, + text: palette.text, + primary: palette.primary, + success: palette.success, + danger: palette.danger, + } + } +} + +impl From<Theme> for Extended { + fn from(theme: Theme) -> Self { + let mut extended = Extended::generate(theme.palette.into()); + + if let Some(is_dark) = theme.is_dark { + extended.is_dark = is_dark; + } + + if let Some(extended_palette) = theme.extended { + if let Some(background) = extended_palette.background { + if let Some(base) = background.base { + extended.background.base = base.into(); + } + if let Some(weak) = background.weak { + extended.background.weak = weak.into(); + } + if let Some(strong) = background.strong { + extended.background.strong = strong.into(); + } + } + + // Handle primary + if let Some(primary) = extended_palette.primary { + if let Some(base) = primary.base { + extended.primary.base = base.into(); + } + if let Some(weak) = primary.weak { + extended.primary.weak = weak.into(); + } + if let Some(strong) = primary.strong { + extended.primary.strong = strong.into(); + } + } + + // Handle secondary + if let Some(secondary) = extended_palette.secondary { + if let Some(base) = secondary.base { + extended.secondary.base = base.into(); + } + if let Some(weak) = secondary.weak { + extended.secondary.weak = weak.into(); + } + if let Some(strong) = secondary.strong { + extended.secondary.strong = strong.into(); + } + } + + // Handle success + if let Some(success) = extended_palette.success { + if let Some(base) = success.base { + extended.success.base = base.into(); + } + if let Some(weak) = success.weak { + extended.success.weak = weak.into(); + } + if let Some(strong) = success.strong { + extended.success.strong = strong.into(); + } + } + + // Handle danger + if let Some(danger) = extended_palette.danger { + if let Some(base) = danger.base { + extended.danger.base = base.into(); + } + if let Some(weak) = danger.weak { + extended.danger.weak = weak.into(); + } + if let Some(strong) = danger.strong { + extended.danger.strong = strong.into(); + } + } + } + + extended + } +} + +#[derive(Debug, Default, serde::Deserialize)] +struct ExtendedThemePalette { + background: Option<ThemeBackground>, + primary: Option<ThemePrimary>, + secondary: Option<ThemeSecondary>, + success: Option<ThemeSuccess>, + danger: Option<ThemeDanger>, +} + +#[derive(Debug, Default, serde::Deserialize)] +struct ThemeBackground { + base: Option<ThemePair>, + weak: Option<ThemePair>, + strong: Option<ThemePair>, +} + +#[derive(Debug, Default, serde::Deserialize)] +struct ThemePrimary { + base: Option<ThemePair>, + weak: Option<ThemePair>, + strong: Option<ThemePair>, +} + +#[derive(Debug, Default, serde::Deserialize)] +struct ThemeSecondary { + base: Option<ThemePair>, + weak: Option<ThemePair>, + strong: Option<ThemePair>, +} + +#[derive(Debug, Default, serde::Deserialize)] +struct ThemeSuccess { + base: Option<ThemePair>, + weak: Option<ThemePair>, + strong: Option<ThemePair>, +} + +#[derive(Debug, Default, serde::Deserialize)] +struct ThemeDanger { + base: Option<ThemePair>, + weak: Option<ThemePair>, + strong: Option<ThemePair>, +} + +#[derive(Debug, Default, serde::Deserialize)] +struct ThemePair { + #[serde(with = "color_serde")] + color: Color, + #[serde(with = "color_serde")] + text: Color, +} + +impl From<ThemePair> for iced::theme::palette::Pair { + fn from(pair: ThemePair) -> Self { + Self { + color: pair.color, + text: pair.text, + } + } +} + +mod color_serde { + use iced::Color; + use serde::{Deserialize, Deserializer}; + + pub fn deserialize<'de, D>(deserializer: D) -> Result<Color, D::Error> + where + D: Deserializer<'de>, + { + Ok(String::deserialize(deserializer) + .map(|hex| Color::parse(&hex))? + .unwrap_or(Color::TRANSPARENT)) + } +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..ac9d039 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,48 @@ +pub mod element_name; +pub mod project; +pub mod rendered_element; + +use std::path::PathBuf; + +pub use element_name::ElementName; +use iced::widget::{pane_grid, text_editor}; +use iced::Theme; +use iced_anim::Event; +pub use project::Project; +pub use rendered_element::*; + +use crate::Error; + +#[derive(Debug, Clone)] +pub enum Message { + ToggleTheme(Event<Theme>), + CopyCode, + SwitchPage(DesignerPage), + EditorAction(text_editor::Action), + RefreshEditorContent, + DropNewElement(ElementName, iced::Point, iced::Rectangle), + HandleNew( + ElementName, + Vec<(iced::advanced::widget::Id, iced::Rectangle)>, + ), + MoveElement(RenderedElement, iced::Point, iced::Rectangle), + HandleMove( + RenderedElement, + Vec<(iced::advanced::widget::Id, iced::Rectangle)>, + ), + PaneResized(pane_grid::ResizeEvent), + PaneClicked(pane_grid::Pane), + PaneDragged(pane_grid::DragEvent), + NewFile, + OpenFile, + FileOpened(Result<(PathBuf, Project), Error>), + SaveFile, + SaveFileAs, + FileSaved(Result<PathBuf, Error>), +} + +#[derive(Debug, Clone)] +pub enum DesignerPage { + DesignerView, + CodeView, +} diff --git a/src/types/element_name.rs b/src/types/element_name.rs new file mode 100644 index 0000000..2687673 --- /dev/null +++ b/src/types/element_name.rs @@ -0,0 +1,85 @@ +use serde::{Deserialize, Serialize}; + +use super::rendered_element::{ + button, column, container, image, row, svg, text, Action, RenderedElement, +}; +use crate::Error; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum ElementName { + Text(String), + Button(String), + Svg(String), + Image(String), + Container, + Row, + Column, +} + +impl ElementName { + pub const ALL: &'static [Self; 7] = &[ + Self::Text(String::new()), + Self::Button(String::new()), + Self::Svg(String::new()), + Self::Image(String::new()), + Self::Container, + Self::Row, + Self::Column, + ]; + + pub fn handle_action( + &self, + element_tree: Option<&mut RenderedElement>, + action: Action, + ) -> Result<Option<RenderedElement>, Error> { + let element = match self { + Self::Text(_) => text(""), + Self::Button(_) => button(""), + Self::Svg(_) => svg(""), + Self::Image(_) => image(""), + Self::Container => container(None), + Self::Row => row(None), + Self::Column => column(None), + }; + match action { + Action::Stop | Action::Drop => Ok(None), + Action::AddNew => Ok(Some(element)), + Action::PushFront(id) => { + element_tree + .ok_or("the action was of kind `PushFront`, but no element tree was provided.")? + .find_by_id(id) + .ok_or(Error::NonExistentElement)? + .push_front(&element); + Ok(None) + } + Action::InsertAfter(parent_id, child_id) => { + element_tree + .ok_or( + "the action was of kind `InsertAfter`, but no element tree was provided.", + )? + .find_by_id(parent_id) + .ok_or(Error::NonExistentElement)? + .insert_after(child_id, &element); + Ok(None) + } + } + } +} + +impl std::fmt::Display for ElementName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Self::Text(_) => "Text", + Self::Button(_) => "Button", + Self::Svg(_) => "SVG", + Self::Image(_) => "Image", + Self::Container => "Container", + Self::Row => "Row", + Self::Column => "Column", + } + ) + } +} diff --git a/src/types/project.rs b/src/types/project.rs new file mode 100644 index 0000000..27c576b --- /dev/null +++ b/src/types/project.rs @@ -0,0 +1,165 @@ +use std::path::{Path, PathBuf}; + +extern crate fxhash; +use fxhash::FxHashMap; +use iced::Theme; +use rust_format::{Edition, Formatter, RustFmt}; +use serde::{Deserialize, Serialize}; + +use super::rendered_element::RenderedElement; +use crate::config::Config; +use crate::theme::{theme_from_str, theme_index, theme_to_string}; +use crate::Error; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Project { + pub title: Option<String>, + pub theme: Option<String>, + pub element_tree: Option<RenderedElement>, + #[serde(skip)] + theme_cache: FxHashMap<String, String>, +} + +impl Default for Project { + fn default() -> Self { + Self::new() + } +} + +impl Project { + pub fn new() -> Self { + Self { + title: None, + theme: None, + element_tree: None, + theme_cache: FxHashMap::default(), + } + } + + pub fn get_theme(&self, config: &Config) -> Theme { + match &self.theme { + Some(theme) => theme_from_str(Some(config), theme), + None => Theme::default(), + } + } + + fn theme_code(&mut self, theme: &Theme) -> String { + let theme_name = theme.to_string(); + if theme_index(&theme_name, Theme::ALL).is_none() { + (*self + .theme_cache + .entry(theme_name) + .or_insert(theme_to_string(theme))) + .to_string() + } else { + theme_name.replace(" ", "") + } + } + + pub async fn from_path( + path: PathBuf, + config: Config, + ) -> Result<(PathBuf, Self), Error> { + let contents = tokio::fs::read_to_string(&path).await?; + let mut project: Self = serde_json::from_str(&contents)?; + + let _ = project.theme_code(&project.get_theme(&config)); + + Ok((path, project)) + } + + pub async fn from_file(config: Config) -> Result<(PathBuf, Self), Error> { + let picked_file = rfd::AsyncFileDialog::new() + .set_title("Open a JSON file...") + .add_filter("*.json, *.JSON", &["json", "JSON"]) + .pick_file() + .await + .ok_or(Error::DialogClosed)?; + + let path = picked_file.path().to_owned(); + + Self::from_path(path, config).await + } + + pub async fn write_to_file( + self, + path: Option<PathBuf>, + ) -> Result<PathBuf, Error> { + let path = if let Some(p) = path { + p + } else { + rfd::AsyncFileDialog::new() + .set_title("Save to JSON file...") + .add_filter("*.json, *.JSON", &["json", "JSON"]) + .save_file() + .await + .as_ref() + .map(rfd::FileHandle::path) + .map(Path::to_owned) + .ok_or(Error::DialogClosed)? + }; + + let contents = serde_json::to_string(&self)?; + tokio::fs::write(&path, contents).await?; + + Ok(path) + } + + pub fn app_code(&mut self, config: &Config) -> Result<String, Error> { + match self.element_tree { + Some(ref element_tree) => { + let (imports, view) = element_tree.codegen(); + let theme = self.get_theme(config); + let theme_code = self.theme_code(&theme); + let mut theme_imports = ""; + if theme_index(&theme.to_string(), Theme::ALL).is_none() { + if theme_code.contains("Extended") { + theme_imports = "use iced::{{color,theme::{{Palette,palette::{{Extended,Background,Primary,Secondary,Success,Danger,Pair}}}}}};\n"; + } else { + theme_imports = "use iced::{{color,theme::Palette}};\n"; + } + } + + let app_code = format!( + r#"// Automatically generated by iced Builder + use iced::{{widget::{{{imports}}},Element}}; + {theme_imports} + + fn main() -> iced::Result {{ + iced::application("{}", State::update, State::view).theme(State::theme).run() + }} + + #[derive(Default)] + struct State; + + #[derive(Debug, Clone)] + enum Message {{}} + + impl State {{ + fn update(&mut self, _message: Message) {{}} + + fn theme(&self) -> iced::Theme {{ + iced::Theme::{} + }} + + fn view(&self) -> Element<Message> {{ + {view}.into() + }} + }}"#, + match self.title { + Some(ref t) => t, + None => "New app", + }, + theme_code + ); + let config = rust_format::Config::new_str() + .edition(Edition::Rust2021) + .option("trailing_comma", "Never") + .option("imports_granularity", "Crate"); + let rustfmt = RustFmt::from_config(config); + Ok(rustfmt.format_str(app_code)?) + } + None => Err("No element tree present".into()), + } + } +} diff --git a/src/types/rendered_element.rs b/src/types/rendered_element.rs new file mode 100755 index 0000000..b001556 --- /dev/null +++ b/src/types/rendered_element.rs @@ -0,0 +1,468 @@ +use std::collections::BTreeMap; + +use iced::advanced::widget::Id; +use iced::{widget, Element, Length}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::ElementName; +use crate::types::Message; +use crate::Error; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RenderedElement { + #[serde(skip, default = "Uuid::new_v4")] + id: Uuid, + child_elements: Option<Vec<RenderedElement>>, + name: ElementName, + options: BTreeMap<String, Option<String>>, +} + +impl RenderedElement { + fn new(name: ElementName) -> Self { + Self { + id: Uuid::new_v4(), + child_elements: None, + name, + options: BTreeMap::new(), + } + } + + fn with(name: ElementName, child_elements: Vec<RenderedElement>) -> Self { + Self { + id: Uuid::new_v4(), + child_elements: Some(child_elements), + name, + options: BTreeMap::new(), + } + } + + pub fn get_id(&self) -> Id { + Id::new(self.id.to_string()) + } + + pub fn find_by_id(&mut self, id: &Id) -> Option<&mut Self> { + if &self.get_id() == id { + Some(self) + } else if let Some(child_elements) = self.child_elements.as_mut() { + for element in child_elements { + let element = element.find_by_id(id); + if element.is_some() { + return element; + } + } + None + } else { + None + } + } + + pub fn find_parent( + &mut self, + child_element: &RenderedElement, + ) -> Option<&mut Self> { + if child_element == self { + return Some(self); + } else if self.child_elements.is_some() { + if self + .child_elements + .clone() + .unwrap_or_default() + .contains(child_element) + { + return Some(self); + } + if let Some(child_elements) = self.child_elements.as_mut() { + for element in child_elements { + let element = element.find_parent(child_element); + if element.is_some() { + return element; + } + } + } + } + None + } + + pub fn is_parent(&self) -> bool { + self.child_elements.is_some() + } + + pub fn is_empty(&self) -> bool { + self.child_elements == Some(vec![]) + } + + pub fn remove(&mut self, element: &RenderedElement) { + let parent = self.find_parent(element).unwrap(); + if let Some(child_elements) = parent.child_elements.as_mut() { + if let Some(index) = + child_elements.iter().position(|x| x == element) + { + let _ = child_elements.remove(index); + } + } + } + + pub fn push_front(&mut self, element: &RenderedElement) { + if let Some(child_elements) = self.child_elements.as_mut() { + child_elements.insert(0, element.clone()); + } + } + + 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.insert(index + 1, element.clone()); + } else { + child_elements.push(element.clone()); + } + } + } + + pub fn handle_action( + &self, + element_tree: Option<&mut RenderedElement>, + action: Action, + ) -> Result<(), Error> { + let element_tree = element_tree.unwrap(); + + match action { + Action::Stop => Ok(()), + Action::Drop => { + element_tree.remove(self); + + Ok(()) + } + Action::AddNew => Err( + "the action was of kind `AddNew`, but invoking it on an existing element tree is not possible".into(), + ), + Action::PushFront(id) => { + element_tree.remove(self); + + let new_parent = element_tree.find_by_id(id).unwrap(); + new_parent.push_front(self); + + Ok(()) + } + Action::InsertAfter(parent_id, target_id) => { + element_tree.remove(self); + + let new_parent = element_tree.find_by_id(parent_id).unwrap(); + new_parent.insert_after(target_id, self); + + Ok(()) + } + } + } + + fn preset_options(mut self, options: &[&str]) -> Self { + for opt in options { + let _ = self.options.insert(opt.to_string(), None); + } + self + } + + pub fn option<'a>(mut self, option: &'a str, value: &'a str) -> Self { + let _ = self + .options + .entry(option.to_owned()) + .and_modify(|opt| *opt = Some(value.to_owned())); + self + } + + pub fn into_element<'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()); + } + } + iced_drop::droppable( + widget::container( + widget::column![ + widget::text(self.name.clone().to_string()), + children + ] + .width(Length::Fill) + .spacing(10), + ) + .padding(10) + .style(widget::container::bordered_box), + ) + .id(self.get_id()) + .drag_hide(true) + .on_drop(move |point, rect| { + Message::MoveElement(self.clone(), point, rect) + }) + .into() + } + + pub fn codegen(&self) -> (String, String) { + let mut imports = String::new(); + let mut view = String::new(); + let mut options = String::new(); + + for (k, v) in self.options.clone() { + if let Some(v) = v { + options = format!("{options}.{k}({v})"); + } + } + + let mut elements = String::new(); + + if let Some(els) = &self.child_elements { + for element in els { + let (c_imports, children) = element.codegen(); + imports = format!("{imports}{c_imports}"); + elements = format!("{elements}{children},"); + } + } + + match &self.name { + ElementName::Container => { + imports = format!("{imports}container,"); + view = format!("{view}\ncontainer({elements}){options}"); + } + ElementName::Row => { + imports = format!("{imports}row,"); + view = format!("{view}\nrow![{elements}]{options}"); + } + ElementName::Column => { + imports = format!("{imports}column,"); + view = format!("{view}\ncolumn![{elements}]{options}"); + } + ElementName::Text(string) => { + imports = format!("{imports}text,"); + view = format!( + "{view}\ntext(\"{}\"){options}", + if *string == String::new() { + "New Text" + } else { + string + } + ); + } + ElementName::Button(string) => { + imports = format!("{imports}button,"); + view = format!( + "{view}\nbutton(\"{}\"){options}", + if *string == String::new() { + "New Button" + } else { + string + } + ); + } + ElementName::Image(path) => { + imports = format!("{imports}image,"); + view = format!("{view}\nimage(\"{path}\"){options}"); + } + ElementName::Svg(path) => { + imports = format!("{imports}svg,"); + view = format!("{view}\nsvg(\"{path}\"){options}"); + } + } + + (imports, view) + } +} + +impl std::fmt::Display for RenderedElement { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut has_options = false; + f.pad("")?; + f.write_fmt(format_args!("{:?}\n", self.name))?; + f.pad("")?; + f.write_str("Options: (")?; + for (k, v) in &self.options { + if let Some(value) = v { + has_options = true; + f.write_fmt(format_args!( + "\n{:width$.precision$}{}: {}", + "", + k, + value, + width = f.width().unwrap_or(0) + f.precision().unwrap_or(0), + precision = f.precision().unwrap_or(0) + ))?; + } + } + if has_options { + f.write_str("\n")?; + f.pad("")?; + } + f.write_str(")")?; + if let Some(els) = &self.child_elements { + f.write_str(" {\n")?; + for el in els { + f.write_fmt(format_args!( + "\n{:width$.precision$}\n", + el, + width = f.width().unwrap_or(0) + f.precision().unwrap_or(0), + precision = f.precision().unwrap_or(0) + ))?; + } + f.pad("")?; + f.write_str("}")?; + } + Ok(()) + } +} + +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 content: Element<'a, Message> = match value.name.clone() { + ElementName::Text(s) => { + if s == String::new() { + widget::text("New Text").into() + } else { + widget::text(s).into() + } + } + ElementName::Button(s) => { + if s == String::new() { + widget::button(widget::text("New Button")).into() + } else { + widget::button(widget::text(s)).into() + } + } + ElementName::Svg(p) => widget::svg(p).into(), + ElementName::Image(p) => widget::image(p).into(), + ElementName::Container => { + widget::container(if child_elements.len() == 1 { + child_elements[0].clone().into() + } else { + Element::from("") + }) + .padding(20) + .into() + } + ElementName::Row => widget::Row::from_vec( + child_elements.into_iter().map(Into::into).collect(), + ) + .padding(20) + .into(), + ElementName::Column => widget::Column::from_vec( + child_elements.into_iter().map(Into::into).collect(), + ) + .padding(20) + .into(), + }; + iced_drop::droppable(content) + .id(value.get_id()) + .drag_hide(true) + .on_drop(move |point, rect| { + Message::MoveElement(value.clone(), point, rect) + }) + .into() + } +} + +#[derive(Debug, Clone)] +pub enum Action<'a> { + AddNew, + PushFront(&'a Id), + InsertAfter(&'a Id, &'a Id), + Drop, + Stop, +} + +impl<'a> Action<'a> { + pub fn new( + ids: &'a [Id], + element_tree: &'a Option<RenderedElement>, + source_id: Option<Id>, + ) -> Self { + let mut action = Self::Stop; + if ids.len() == 1 { + if element_tree.is_none() { + action = Self::AddNew; + } else { + action = Self::Drop; + } + } else { + let id: &Id = match source_id { + Some(id) if ids.contains(&id) => { + let element_id = + &ids[ids.iter().position(|x| *x == id).unwrap()]; + if ids.len() > 2 && &ids[ids.len() - 1] == element_id { + return Self::Stop; + } + element_id + } + _ => ids.last().unwrap(), + }; + let mut element_tree = element_tree.clone().unwrap(); + let element = element_tree.find_by_id(id).unwrap(); + + // Element is a parent and isn't a non-empty container + if (element.is_empty() || !(element.name == ElementName::Container)) + && element.is_parent() + { + action = Self::PushFront(id); + } else if ids.len() > 2 { + let parent = + element_tree.find_by_id(&ids[ids.len() - 2]).unwrap(); + + if parent.name == ElementName::Container + && parent.child_elements != Some(vec![]) + { + action = Self::Stop; + } else { + action = Self::InsertAfter( + &ids[ids.len() - 2], + &ids[ids.len() - 1], + ); + } + } + } + action + } +} + +pub fn text(text: &str) -> RenderedElement { + RenderedElement::new(ElementName::Text(text.to_owned())).preset_options(&[ + "size", + "line_height", + "width", + "height", + ]) +} + +pub fn button(text: &str) -> RenderedElement { + RenderedElement::new(ElementName::Button(text.to_owned())) +} + +pub fn svg(path: &str) -> RenderedElement { + RenderedElement::new(ElementName::Svg(path.to_owned())) +} + +pub fn image(path: &str) -> RenderedElement { + RenderedElement::new(ElementName::Image(path.to_owned())) +} + +pub fn container(content: Option<RenderedElement>) -> RenderedElement { + match content { + Some(el) => RenderedElement::with(ElementName::Container, vec![el]), + None => RenderedElement::with(ElementName::Container, vec![]), + } +} + +pub fn row(child_elements: Option<Vec<RenderedElement>>) -> RenderedElement { + RenderedElement::with(ElementName::Row, child_elements.unwrap_or_default()) +} + +pub fn column(child_elements: Option<Vec<RenderedElement>>) -> RenderedElement { + RenderedElement::with( + ElementName::Column, + child_elements.unwrap_or_default(), + ) +} diff --git a/src/widget.rs b/src/widget.rs new file mode 100644 index 0000000..ed2073a --- /dev/null +++ b/src/widget.rs @@ -0,0 +1,21 @@ +use iced::widget::{container, text, tooltip}; +use iced::Element; + +pub mod tip { + pub use super::tooltip::Position; +} + +pub fn tip<'a, Message: 'a>( + target: impl Into<Element<'a, Message>>, + tip: &'a str, + position: tip::Position, +) -> Element<'a, Message> { + tooltip( + target, + container(text(tip).size(14)) + .padding(5) + .style(container::rounded_box), + position, + ) + .into() +} |
