summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorPolesznyák Márk László <116908301+pml68@users.noreply.github.com>2025-01-11 23:13:07 +0100
committerGitHub <noreply@github.com>2025-01-11 23:13:07 +0100
commit103699beeb8bdce38bc5803cbe038e74cbc20e40 (patch)
treeb79e13b3decc778cc7c66af7187c647ae0a21a52 /src
parentMerge pull request #4 from pml68/feat/playground (diff)
parentrefactor: remove iced_drop & workspace (diff)
downloadiced-builder-103699beeb8bdce38bc5803cbe038e74cbc20e40.tar.gz
Merge pull request #5 from pml68/feat/config
Config done
Diffstat (limited to 'src')
-rw-r--r--src/config.rs121
-rw-r--r--src/dialogs.rs30
-rw-r--r--src/environment.rs43
-rw-r--r--src/error.rs55
-rw-r--r--src/icon.rs23
-rw-r--r--src/main.rs382
-rw-r--r--src/panes.rs4
-rw-r--r--src/panes/code_view.rs50
-rw-r--r--src/panes/designer_view.rs37
-rw-r--r--src/panes/element_list.rs49
-rw-r--r--src/panes/style.rs40
-rw-r--r--src/theme.rs381
-rw-r--r--src/types.rs48
-rw-r--r--src/types/element_name.rs85
-rw-r--r--src/types/project.rs165
-rwxr-xr-xsrc/types/rendered_element.rs468
-rw-r--r--src/widget.rs21
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()
+}