diff --git a/Cargo.lock b/Cargo.lock index 751dd12..cdb2db1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1503,6 +1503,7 @@ dependencies = [ "iced_core", "log", "rustc-hash 2.1.1", + "tokio", "wasm-bindgen-futures", "wasmtimer", ] @@ -3656,6 +3657,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "pin-project-lite", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -4849,6 +4859,7 @@ version = "0.1.0" dependencies = [ "iced", "rfd", + "tokio", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index 8daeef9..14fe584 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -iced = { version = "0.14.0", features = ["image", "markdown", "highlighter"] } +iced = { version = "0.14.0", features = ["image", "markdown", "highlighter", "tokio"] } rfd = "0.17.1" +tokio = { version ="1.49.0", features = ["fs"] } uuid = { version = "1.19.0", features = ["v4"] } diff --git a/src/components/action_bar.rs b/src/components/action_bar.rs new file mode 100644 index 0000000..4dd0ac4 --- /dev/null +++ b/src/components/action_bar.rs @@ -0,0 +1,29 @@ +use iced::{Element, Length, widget::{container, pick_list, row}}; + +use crate::message::{FileAction, Message, ViewAction}; + +pub fn view( + selected_file_action: Option, + selected_view_action: Option, +) -> Element<'static, Message> { + + let file_menu = pick_list( + FileAction::ALL, + selected_file_action, + Message::FileActionSelected, + ) + .placeholder("File"); + + let view_menu = pick_list( + ViewAction::ALL, + selected_view_action, + Message::ViewActionSelected, + ) + .placeholder("View"); + + container( + row![file_menu, view_menu].spacing(10) + ) + .width(Length::Fill) + .into() +} diff --git a/src/components/editor.rs b/src/components/editor.rs new file mode 100644 index 0000000..d2a52fd --- /dev/null +++ b/src/components/editor.rs @@ -0,0 +1,32 @@ +use iced::{ + Element, Length, Theme, + widget::{container, markdown, row, text_editor}, +}; + +use crate::{constants, file::File, message::Message, state::Mode}; + +pub fn view<'a>(file: &'a File, mode: Mode, font_size: u32) -> Element<'a, Message> { + match mode { + Mode::Edit => { + let editor = text_editor(file.content()) + .size(font_size) + .height(Length::Fill) + .on_action(Message::Edit); + + container(row![editor]).height(Length::Fill).into() + } + Mode::Preview => { + let mut style: markdown::Style = Theme::Dark.into(); + style.font = constants::CUSTOM_FONT; + + let settings = markdown::Settings::with_text_size(font_size, style); + + let markdown_preview = + markdown::view(file.markdown(), settings).map(Message::LinkClicked); + + container(row![markdown_preview]) + .height(Length::Fill) + .into() + } + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs new file mode 100644 index 0000000..6e9ede3 --- /dev/null +++ b/src/components/mod.rs @@ -0,0 +1,4 @@ +pub mod tabs; +pub mod editor; +pub mod status_bar; +pub mod action_bar; diff --git a/src/components/status_bar.rs b/src/components/status_bar.rs new file mode 100644 index 0000000..a7cbeed --- /dev/null +++ b/src/components/status_bar.rs @@ -0,0 +1,16 @@ +use iced::widget::{container, row, text}; +use iced::{Element, Length}; +use crate::file::File; + +pub fn view(file: &File) -> Element<'_, crate::Message> { + let path_text = text(file.path_summary()); + let cursor_text = text(file.position_summary()); + + container( + row![cursor_text, path_text] + .spacing(20) + ) + .width(Length::Fill) + .padding(5) + .into() +} diff --git a/src/components/tabs.rs b/src/components/tabs.rs new file mode 100644 index 0000000..842cf54 --- /dev/null +++ b/src/components/tabs.rs @@ -0,0 +1,41 @@ +use crate::file::File; +use crate::message::{FileAction, Message}; +use iced::{Background, Border, Theme, border}; +use iced::{ + Element, + widget::{button, container, row, scrollable, text}, +}; + +pub fn view<'a>(files: &'a [File], active_index: usize) -> Element<'a, Message> { + let tabs = files.iter().enumerate().map(|(index, file)| { + let is_focused = index == active_index; + + let label = text(file.display_name()); + let close_btn = + button(text("x")).on_press(Message::FileActionSelected(FileAction::Close(Some(index)))); + + button(row![label, close_btn].spacing(5)) + .on_press(Message::SwitchTab(index)) + .padding(5) + .style(move |theme: &Theme, status| { + let base = button::primary(theme, status); + let button_background = if is_focused { + base.background + } else { + Some(Background::Color(theme.palette().background)) + }; + + button::Style { + background: button_background, + border: Border { + radius: border::radius(0).top_left(10).top_right(10), + ..base.border + }, + ..base + } + }) + .into() + }); + + scrollable(container(row(tabs).spacing(2))).into() +} diff --git a/src/constants.rs b/src/constants.rs new file mode 100644 index 0000000..4e4acb9 --- /dev/null +++ b/src/constants.rs @@ -0,0 +1,8 @@ +use iced::Font; + +pub const CUSTOM_FONT_BYTES: &[u8] = include_bytes!("./fonts/CaskaydiaCoveNFM-Regular.ttf"); +pub const CUSTOM_FONT: Font = Font::with_name("CaskaydiaCove Nerd Font Mono"); +pub const DEFAULT_EDITOR_FONT_SIZE: u32 = 16; +pub const MAX_EDITOR_FONT_SIZE: u32 = 80; +pub const MIN_EDITOR_FONT_SIZE: u32 = 12; +pub const ICON_BYTES: &[u8] = include_bytes!("./images/icon.ico"); diff --git a/src/file.rs b/src/file.rs index 105303d..fcf1ea4 100644 --- a/src/file.rs +++ b/src/file.rs @@ -1,55 +1,80 @@ -use std::{ - fs::{read_to_string, write}, - path::PathBuf, -}; +use std::path::PathBuf; -pub fn save_file_to_disk(path: PathBuf, text: String) { - let save_result = write(path, text); +use iced::widget::{markdown, text_editor}; - match save_result { - Ok(_) => {}, - Err(err) => eprintln!("Error: {}", err), +pub struct File { + content: text_editor::Content, + path: Option, + markdown: Vec, +} + +impl Default for File { + fn default() -> Self { + File { + content: text_editor::Content::new(), + path: None, + markdown: Vec::new(), + } } } -pub fn load_file_from_disk(path: PathBuf) -> String { - let read_result = read_to_string(path); +impl File { + pub fn from(content: &str, path: Option) -> Self { + let text_editor_content = text_editor::Content::with_text(content); + let markdown = markdown::parse(content).collect(); - match read_result { - Ok(contents) => contents, - Err(_) => String::new(), + File { + content: text_editor_content, + path, + markdown, + } } -} -#[cfg(test)] -mod tests { - use super::*; - use std::fs::{self, remove_file}; + pub fn content(&self) -> &text_editor::Content { + &self.content + } - #[test] - fn test_save_file_to_disk() { - let test_path = PathBuf::from("test_file.txt"); - let test_content = String::from("Hello, Chat!"); + pub fn content_mut(&mut self) -> &mut text_editor::Content { + &mut self.content + } - save_file_to_disk(test_path.clone(), test_content.clone()); + pub fn set_content(&mut self, content: &str) { + self.content = text_editor::Content::with_text(content); + } - let saved_content = fs::read_to_string(&test_path).unwrap(); + pub fn markdown(&self) -> Vec<&markdown::Item> { + self.markdown.iter().collect() + } - assert_eq!(saved_content, test_content); + pub fn update_markdown(&mut self) { + self.markdown = markdown::parse(&self.content.text()).collect(); + } - let _ = remove_file(test_path); + pub fn path(&self) -> Option<&PathBuf> { + self.path.as_ref() } - #[test] - fn test_load_file_from_disk() { - let test_path = PathBuf::from("test_file.txt"); - let test_content = String::from("Hello, Chat!"); - let _ = fs::write(test_path.clone(), test_content.clone()); + pub fn set_path(&mut self, path: Option) { + self.path = path; + } - let saved_content = load_file_from_disk(test_path.clone()); + pub fn display_name(&self) -> &str { + self.path + .as_deref() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .unwrap_or("New file") + } - assert_eq!(saved_content, test_content); + pub fn position_summary(&self) -> String { + let pos = self.content.cursor().position; + format!("Ln {}, Col {}", pos.line, pos.column) + } - let _ = remove_file(test_path); + pub fn path_summary(&self) -> String { + self.path + .as_deref() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default() } } diff --git a/src/handler.rs b/src/handler.rs new file mode 100644 index 0000000..110006d --- /dev/null +++ b/src/handler.rs @@ -0,0 +1,81 @@ +use std::path::PathBuf; + +use crate::io; +use crate::message::{FileAction, ViewAction}; +use crate::{Message, state::State}; +use iced::Task; +use iced::widget::text_editor; + +pub fn edit(state: &mut State, action: text_editor::Action) -> Task { + state.apply_edit(action); + Task::none() +} + +pub fn switch_tab(state: &mut State, index: usize) -> Task { + state.switch_tab(index); + Task::none() +} + +pub fn link_clicked(url: String) -> Task { + println!("Opening link: {}", url); + Task::none() +} + +pub fn file_action(state: &mut State, action: FileAction) -> Task { + match action { + FileAction::New => { + state.new_file(); + Task::none() + } + FileAction::Close(index) => { + if state.close_file(index) { + iced::exit() + } else { + Task::none() + } + } + FileAction::Open => Task::perform(io::open_file(), Message::FileOpened), + FileAction::Save => { + let (current_path, content) = state.active_file_data(); + let path = current_path.cloned(); + Task::perform(io::save_file(path, content), Message::FileSaved) + } + FileAction::SaveAs => { + let (_, content) = state.active_file_data(); + Task::perform(io::save_file(None, content), Message::FileSaved) + } + } +} + +pub fn opened_file(state: &mut State, result: Result<(PathBuf, String), String>) -> Task { + match result { + Ok((path, content)) => { + state.open_file(path, content); + } + Err(_error) => {} + }; + + Task::none() +} + +pub fn saved_file(state: &mut State, result: Result) -> Task { + match result { + Ok(path) => { + state.set_active_file_path(path); + } + Err(_error) => {} + }; + + Task::none() +} + +pub fn view_action(state: &mut State, action: ViewAction) -> Task { + match action { + ViewAction::Increase => state.increase_font(), + ViewAction::Decrease => state.decrease_font(), + ViewAction::Reset => state.reset_font(), + ViewAction::TogglePreview => state.toggle_preview(), + } + + Task::none() +} diff --git a/src/io.rs b/src/io.rs new file mode 100644 index 0000000..c36cf8d --- /dev/null +++ b/src/io.rs @@ -0,0 +1,45 @@ +use std::path::PathBuf; + +use rfd::AsyncFileDialog; +use tokio::fs; + +pub async fn open_file() -> Result<(PathBuf, String), String> { + let handle_result = AsyncFileDialog::new() + .set_directory("/") + .pick_file() + .await + .ok_or(String::from("Dialog cancelled")); + + match handle_result { + Ok(handle) => { + let path = handle.path().to_owned(); + let content_result = fs::read_to_string(&path).await.map_err(|e| e.to_string()); + + match content_result { + Ok(content) => Ok((path, content)), + Err(e) => Err(e), + } + } + Err(e) => Err(e), + } +} + +pub async fn save_file(path: Option, text: String) -> Result { + let save_path = match path { + Some(p) => p, + None => AsyncFileDialog::new() + .set_directory("/") + .save_file() + .await + .ok_or(String::from("Dialog cancelled"))? + .path() + .to_owned(), + }; + + let save_result = fs::write(&save_path, text).await.map_err(|e| e.to_string()); + + match save_result { + Ok(_) => Ok(save_path), + Err(err) => Err(err), + } +} diff --git a/src/key_bindings.rs b/src/key_bindings.rs new file mode 100644 index 0000000..f1eb0ab --- /dev/null +++ b/src/key_bindings.rs @@ -0,0 +1,68 @@ +use iced::keyboard::{self, Key, Modifiers}; + +use crate::message::{FileAction, Message, ViewAction}; + +pub struct Keybinding { + key: &'static str, + modifiers: Modifiers, + message: Message, +} + +impl Keybinding { + pub fn should_handle(&self, key_pressed: &Key, modifiers: &Modifiers) -> bool { + let key = keyboard::Key::Character(self.key.into()); + modifiers.contains(self.modifiers) && key_pressed == &key + } + + pub fn message(&self) -> Message { + self.message.clone() + } +} + +pub const ALL: &[Keybinding] = &[ + Keybinding { + key: "o", + modifiers: Modifiers::CTRL, + message: Message::FileActionSelected(FileAction::Open), + }, + Keybinding { + key: "n", + modifiers: Modifiers::CTRL, + message: Message::FileActionSelected(FileAction::New), + }, + Keybinding { + key: "s", + modifiers: Modifiers::CTRL.union(Modifiers::SHIFT), + message: Message::FileActionSelected(FileAction::SaveAs), + }, + Keybinding { + key: "s", + modifiers: Modifiers::CTRL, + message: Message::FileActionSelected(FileAction::Save), + }, + Keybinding { + key: "w", + modifiers: Modifiers::CTRL, + message: Message::FileActionSelected(FileAction::Close(None)), + }, + Keybinding { + key: "p", + modifiers: Modifiers::CTRL, + message: Message::ViewActionSelected(ViewAction::TogglePreview), + }, + Keybinding { + key: "=", + modifiers: Modifiers::CTRL, + message: Message::ViewActionSelected(ViewAction::Increase), + }, + Keybinding { + key: "-", + modifiers: Modifiers::CTRL, + message: Message::ViewActionSelected(ViewAction::Decrease), + }, + Keybinding { + key: "0", + modifiers: Modifiers::CTRL, + message: Message::ViewActionSelected(ViewAction::Reset), + }, +]; diff --git a/src/main.rs b/src/main.rs index 18a6c3c..8e0b29b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,468 +1,75 @@ +mod components; +mod constants; mod file; - -use std::path::PathBuf; - -use iced::keyboard::{Key, Modifiers}; -use iced::padding::bottom; -use iced::widget::{ - button, column, container, markdown, pick_list, row, scrollable, text, text_editor, -}; +mod handler; +mod io; +mod key_bindings; +mod message; +mod state; + +use iced::Theme; +use iced::widget::column; use iced::window::icon; -use iced::{Background, Border, Element, Subscription, Task, border, event}; -use iced::{Font, Length, Theme}; +use iced::{Element, Subscription, Task, event}; use iced::{keyboard, window}; -use rfd::FileDialog; - -const CUSTOM_FONT: Font = Font::with_name("CaskaydiaCove Nerd Font Mono"); -const DEFAULT_EDITOR_FONT_SIZE: u32 = 16; -const MAX_EDITOR_FONT_SIZE: u32 = 80; -const MIN_EDITOR_FONT_SIZE: u32 = 12; - -struct File { - content: text_editor::Content, - path: Option, - markdown: Vec, -} - -impl Default for File { - fn default() -> Self { - File { - content: text_editor::Content::new(), - path: None, - markdown: Vec::new(), - } - } -} - -#[derive(Default)] -enum Mode { - #[default] - Edit, - Preview, -} - -#[derive(Default)] -struct State { - mode: Mode, - files: Vec, - current_file: usize, - editor_font_size: u32, - selected_file_action: Option, - selected_view_action: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum FileAction { - New, - Save, - SaveAs, - Open, - Close(Option), -} - -impl FileAction { - const ALL: &'static [FileAction] = &[ - FileAction::New, - FileAction::Save, - FileAction::SaveAs, - FileAction::Open, - FileAction::Close(None), - ]; -} - -impl std::fmt::Display for FileAction { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - FileAction::New => write!(f, "New file"), - FileAction::Save => write!(f, "Save"), - FileAction::SaveAs => write!(f, "Save as... "), - FileAction::Open => write!(f, "Open"), - FileAction::Close(_) => write!(f, "Close"), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ViewAction { - Increase, - Decrease, - Reset, - TogglePreview, -} - -impl ViewAction { - const ALL: &'static [ViewAction] = &[ - ViewAction::Increase, - ViewAction::Decrease, - ViewAction::Reset, - ViewAction::TogglePreview, - ]; -} - -impl std::fmt::Display for ViewAction { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ViewAction::Decrease => write!(f, "Decrease font"), - ViewAction::Increase => write!(f, "Increase font"), - ViewAction::Reset => write!(f, "Reset font"), - ViewAction::TogglePreview => write!(f, "Toggle preview"), - } - } -} - -#[derive(Debug, Clone)] -enum Message { - Edit(text_editor::Action), - FileActionSelected(FileAction), - ViewActionSelected(ViewAction), - SwitchTab(usize), - LinkClicked(String), -} - -// TODO: Can we at least make -// theme configurable -// and then maybe even define -// complete custom and/or use -// existing as starter -fn theme(_state: &State) -> Theme { - Theme::Dark -} - -fn view(state: &State) -> Element<'_, Message> { - let mut tab_row = row![]; - for (file_index, file) in state.files.iter().enumerate() { - let file_name = if let Some(p) = &file.path { - p.file_name() - .expect("unable to get file name") - .to_str() - .expect("unable to get file name") - } else { - "New file" - }; +use crate::message::Message; +use crate::state::State; - let tab_button_text = text(file_name).wrapping(text::Wrapping::None); - let delete_button = button(text("x")).on_press(Message::FileActionSelected( - FileAction::Close(Some(file_index)), - )); - - let tab_button = button(row![tab_button_text, delete_button]) - .style(move |theme: &Theme, status| { - let base = button::primary(theme, status); - let is_focused = state.current_file == file_index; - let button_background = if is_focused { - base.background - } else { - Some(Background::Color(theme.palette().background)) - }; - - button::Style { - background: button_background, - border: Border { - radius: border::radius(0).top_left(10).top_right(10), - ..base.border - }, - ..base - } - }) - .on_press(Message::SwitchTab(file_index)); - - tab_row = tab_row.push(tab_button); - } - - let tabs = scrollable(container(tab_row).padding(bottom(10))).direction( - scrollable::Direction::Horizontal(scrollable::Scrollbar::new()), - ); - - let file_menu = pick_list( - FileAction::ALL, - state.selected_file_action, - Message::FileActionSelected, - ) - .placeholder("File"); - - let view_menu = pick_list( - ViewAction::ALL, - state.selected_view_action, - Message::ViewActionSelected, - ) - .placeholder("View"); - - let action_bar = container(row![file_menu, view_menu].spacing(5)); - - let current_file = &state.files[state.current_file]; - - let editor = text_editor(¤t_file.content) - .size(state.editor_font_size) - .height(Length::Fill) - .on_action(Message::Edit); - - let mut markdown_styles: markdown::Style = Theme::Dark.into(); - markdown_styles.font = CUSTOM_FONT; - - let markdown_settings = - markdown::Settings::with_text_size(state.editor_font_size, markdown_styles); - - let markdown_preview = - markdown::view(¤t_file.markdown, markdown_settings).map(Message::LinkClicked); - - let preview_container = container(row![markdown_preview]).height(Length::Fill); - let editor_container = container(row![editor]).height(Length::Fill); - - let cursor_position = current_file.content.cursor().position; - - let cursor_display_text = format!( - "Ln {}, Col {}", - cursor_position.line, cursor_position.column - ); - let cursor_text = text(cursor_display_text); - - let file_path_display_text = match ¤t_file.path { - Some(path) => path.to_string_lossy().to_string(), - None => String::new(), - }; - - let file_path_text = text(file_path_display_text); - - let status_bar = container(row![file_path_text, cursor_text].spacing(10)); - - let main = match state.mode { - Mode::Edit => editor_container, - Mode::Preview => preview_container, - }; - - container(column![tabs, action_bar, main, status_bar]) - .padding(10) - .into() -} - -fn save_file(path: Option, text: String) -> Option { - let mut save_path = path.clone(); - - if path.is_none() { - save_path = FileDialog::new().set_directory("/").save_file(); - } - - match save_path { - Some(p) => { - let sp = p.clone(); - file::save_file_to_disk(sp, text); - Some(p) - } - None => None, - } -} - -fn save_file_as(text: String) -> Option { - let files = FileDialog::new().set_directory("/").save_file(); - - match files { - Some(path) => { - file::save_file_to_disk(path.clone(), text); - Some(path) - } - None => None, - } +pub fn main() -> iced::Result { + iced::application(boot, update, view) + .subscription(subscription) + .font(constants::CUSTOM_FONT_BYTES) + .theme(theme) + .settings(iced::Settings { + default_font: constants::CUSTOM_FONT, + ..Default::default() + }) + .window(window::Settings { + icon: Some( + icon::from_file_data(constants::ICON_BYTES, None).expect("Failed to load icon"), + ), + ..window::Settings::default() + }) + .run() } -fn open_file() -> (Option, String) { - // TODO: Allow opening multiple files - let file = FileDialog::new().set_directory("/").pick_file(); - - match file { - Some(path) => { - let content = file::load_file_from_disk(path.clone()); - (Some(path), content) - } - None => (None, String::new()), - } +fn boot() -> State { + state::State::new() } fn update(state: &mut State, message: Message) -> Task { match message { - Message::Edit(action) => { - let current_file = &mut state.files[state.current_file]; - current_file.content.perform(action); - current_file.markdown = markdown::parse(¤t_file.content.text()).collect(); - } - Message::FileActionSelected(action) => { - state.selected_file_action = None; - - match action { - FileAction::SaveAs => { - println!("We are here!"); - let current_file = &mut state.files[state.current_file]; - let path = save_file_as(current_file.content.text()); - - if path.is_some() { - current_file.path = path; - } - } - FileAction::Open => { - let (path, content) = open_file(); - - if let Some(opened_path) = &path { - for (file_index, file) in state.files.iter_mut().enumerate() { - if let Some(existing_path) = &file.path - && opened_path == existing_path - { - file.content = text_editor::Content::with_text(&content); - state.current_file = file_index; - return Task::none(); - } - } - - let opened_file = File { - path, - content: text_editor::Content::with_text(&content), - markdown: markdown::parse(&content).collect(), - }; - - state.files.push(opened_file); - state.current_file = state.files.len() - 1; - } - } - FileAction::Save => { - let current_file = &mut state.files[state.current_file]; - let path = save_file(current_file.path.clone(), current_file.content.text()); - current_file.path = path; - } - FileAction::New => { - let default_file = File::default(); - - state.files.push(default_file); - state.current_file = state.files.len() - 1; - } - FileAction::Close(idx) => { - let idx_to_close = match idx { - Some(i) => i, - None => state.current_file, - }; - - if state.files.len() == 1 { - return iced::exit(); - } - - if state.files.len() - 1 == idx_to_close { - state.current_file = state.files.len() - 2; - state.files.remove(idx_to_close); - return Task::none(); - } - - state.current_file -= 1; - state.files.remove(idx_to_close); - } - } - } - Message::ViewActionSelected(action) => { - state.selected_view_action = None; - - match action { - ViewAction::Increase => { - if state.editor_font_size >= MAX_EDITOR_FONT_SIZE { - return Task::none(); - } - - state.editor_font_size += 2; - } - ViewAction::Decrease => { - if state.editor_font_size <= MIN_EDITOR_FONT_SIZE { - return Task::none(); - } - - state.editor_font_size -= 2; - } - ViewAction::Reset => { - state.editor_font_size = DEFAULT_EDITOR_FONT_SIZE; - } - // TODO: Carry previous mode when toggling - // so that if we already in preview we can - // just put the user back where they were - ViewAction::TogglePreview => match state.mode { - Mode::Edit => state.mode = Mode::Preview, - Mode::Preview => state.mode = Mode::Edit, - }, - } - } - Message::SwitchTab(file_id) => { - state.current_file = file_id; - } - Message::LinkClicked(link) => { - print!("Link clicked: {}", link); - } + Message::Edit(action) => handler::edit(state, action), + Message::SwitchTab(index) => handler::switch_tab(state, index), + Message::LinkClicked(url) => handler::link_clicked(url), + Message::FileActionSelected(action) => handler::file_action(state, action), + Message::ViewActionSelected(action) => handler::view_action(state, action), + Message::FileOpened(result) => handler::opened_file(state, result), + Message::FileSaved(result) => handler::saved_file(state, result), } - - Task::none() } -struct Keybinding { - key: &'static str, - modifiers: Modifiers, - message: Message, -} - -impl Keybinding { - fn should_handle(&self, key_pressed: &Key, modifiers: &Modifiers) -> bool { - let key = keyboard::Key::Character(self.key.into()); - modifiers.contains(self.modifiers) && key_pressed == &key - } +fn view(state: &State) -> Element<'_, Message> { + let current_file = state.active_file(); - const ALL: &'static [Keybinding] = &[ - Keybinding { - key: "o", - modifiers: Modifiers::CTRL, - message: Message::FileActionSelected(FileAction::Open), - }, - Keybinding { - key: "n", - modifiers: Modifiers::CTRL, - message: Message::FileActionSelected(FileAction::New), - }, - Keybinding { - key: "s", - modifiers: Modifiers::CTRL.union(Modifiers::SHIFT), - message: Message::FileActionSelected(FileAction::SaveAs), - }, - Keybinding { - key: "s", - modifiers: Modifiers::CTRL, - message: Message::FileActionSelected(FileAction::Save), - }, - Keybinding { - key: "w", - modifiers: Modifiers::CTRL, - message: Message::FileActionSelected(FileAction::Close(None)), - }, - Keybinding { - key: "p", - modifiers: Modifiers::CTRL, - message: Message::ViewActionSelected(ViewAction::TogglePreview), - }, - Keybinding { - key: "=", - modifiers: Modifiers::CTRL, - message: Message::ViewActionSelected(ViewAction::Increase), - }, - Keybinding { - key: "-", - modifiers: Modifiers::CTRL, - message: Message::ViewActionSelected(ViewAction::Decrease), - }, - Keybinding { - key: "0", - modifiers: Modifiers::CTRL, - message: Message::ViewActionSelected(ViewAction::Reset), - }, - ]; + column![ + components::tabs::view(state.files(), state.current_file_index()), + components::action_bar::view(state.selected_file_action(), state.selected_view_action()), + components::editor::view(current_file, state.mode(), state.font_size()), + components::status_bar::view(current_file), + ] + .padding(10) + .into() } fn subscription(_state: &State) -> Subscription { event::listen_with(|e, _status, _win| -> Option { match e { iced::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => { - for vb in Keybinding::ALL { + for vb in key_bindings::ALL { if vb.should_handle(&key, &modifiers) { - return Some(vb.message.clone()); + return Some(vb.message()); } } @@ -473,34 +80,6 @@ fn subscription(_state: &State) -> Subscription { }) } -fn boot() -> State { - let default_file = File::default(); - - let files = vec![default_file]; - - State { - files, - current_file: 0, - editor_font_size: DEFAULT_EDITOR_FONT_SIZE, - ..Default::default() - } -} - -pub fn main() -> iced::Result { - iced::application(boot, update, view) - .subscription(subscription) - .font(include_bytes!("./fonts/CaskaydiaCoveNFM-Regular.ttf")) - .theme(theme) - .settings(iced::Settings { - default_font: CUSTOM_FONT, - ..Default::default() - }) - .window(window::Settings { - icon: Some( - icon::from_file_data(include_bytes!("./images/icon.ico"), None) - .expect("Failed to load icon"), - ), - ..window::Settings::default() - }) - .run() +fn theme(_state: &State) -> Theme { + Theme::Dark } diff --git a/src/message.rs b/src/message.rs new file mode 100644 index 0000000..e05f1dc --- /dev/null +++ b/src/message.rs @@ -0,0 +1,73 @@ +use std::{fmt::Display, path::PathBuf}; + +use iced::widget::text_editor; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileAction { + New, + Save, + SaveAs, + Open, + Close(Option), +} + +impl FileAction { + pub const ALL: &'static [FileAction] = &[ + FileAction::New, + FileAction::Save, + FileAction::SaveAs, + FileAction::Open, + FileAction::Close(None), + ]; +} + +impl Display for FileAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FileAction::New => write!(f, "New file"), + FileAction::Save => write!(f, "Save"), + FileAction::SaveAs => write!(f, "Save as... "), + FileAction::Open => write!(f, "Open"), + FileAction::Close(_) => write!(f, "Close"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ViewAction { + Increase, + Decrease, + Reset, + TogglePreview, +} + +impl ViewAction { + pub const ALL: &'static [ViewAction] = &[ + ViewAction::Increase, + ViewAction::Decrease, + ViewAction::Reset, + ViewAction::TogglePreview, + ]; +} + +impl Display for ViewAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ViewAction::Decrease => write!(f, "Decrease font"), + ViewAction::Increase => write!(f, "Increase font"), + ViewAction::Reset => write!(f, "Reset font"), + ViewAction::TogglePreview => write!(f, "Toggle preview"), + } + } +} + +#[derive(Debug, Clone)] +pub enum Message { + Edit(text_editor::Action), + FileActionSelected(FileAction), + ViewActionSelected(ViewAction), + SwitchTab(usize), + LinkClicked(String), + FileOpened(Result<(PathBuf, String), String>), + FileSaved(Result), +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..7ca7197 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,147 @@ +use std::path::PathBuf; + +use iced::widget::text_editor; + +use crate::{constants, file}; +use crate::message::{FileAction, ViewAction}; + +#[derive(Default, Copy, Clone)] +pub enum Mode { + #[default] + Edit, + Preview, +} + +#[derive(Default)] +pub struct State { + mode: Mode, + files: Vec, + current_file: usize, + editor_font_size: u32, + selected_file_action: Option, + selected_view_action: Option, +} + +impl State { + pub fn new() -> Self { + let default_file = file::File::default(); + + Self { + files: vec![default_file], + current_file: 0, + editor_font_size: constants::DEFAULT_EDITOR_FONT_SIZE, + ..Default::default() + } + } + + pub fn apply_edit(&mut self, action: text_editor::Action) { + self.files[self.current_file].content_mut().perform(action); + } + + pub fn new_file(&mut self) { + self.files.push(file::File::default()); + self.current_file = self.files.len() - 1; + } + + pub fn close_file(&mut self, index: Option) -> bool { + let index = index.unwrap_or(self.current_file); + + if self.files.len() <= 1 { + return true; + } + + self.files.remove(index); + + if self.current_file >= self.files.len() { + self.current_file = self.files.len().saturating_sub(1); + } else if index < self.current_file { + self.current_file -= 1; + } + + false + } + + pub fn open_file(&mut self, path: PathBuf, content: String) { + if let Some(index) = self.files.iter().position(|f| f.path() == Some(&path)) { + self.files[index].set_content(&content); + self.current_file = index; + return; + } + + let opened_file = file::File::from(&content, Some(path)); + + self.files.push(opened_file); + self.current_file = self.files.len() - 1; + } + + pub fn switch_tab(&mut self, index: usize) { + if index < self.files.len() { + self.current_file = index; + } + } + + pub fn active_file_data(&self) -> (Option<&PathBuf>, String) { + let file = &self.files[self.current_file]; + (file.path(), file.content().text()) + } + + pub fn set_active_file_path(&mut self, path: PathBuf) { + if let Some(file) = self.files.get_mut(self.current_file) { + file.set_path(Some(path)); + } + } + + pub fn increase_font(&mut self) { + if self.editor_font_size < constants::MAX_EDITOR_FONT_SIZE { + self.editor_font_size += 2; + } + } + + pub fn decrease_font(&mut self) { + if self.editor_font_size > constants::MIN_EDITOR_FONT_SIZE { + self.editor_font_size -= 2; + } + } + + pub fn reset_font(&mut self) { + self.editor_font_size = constants::DEFAULT_EDITOR_FONT_SIZE; + } + + pub fn toggle_preview(&mut self) { + match self.mode { + Mode::Edit => { + self.files[self.current_file].update_markdown(); + self.mode = Mode::Preview; + } + Mode::Preview => self.mode = Mode::Edit, + } + } + + pub fn files(&self) -> &[file::File] { + &self.files + } + + pub fn current_file_index(&self) -> usize { + self.current_file + } + + pub fn active_file(&self) -> &file::File { + &self.files[self.current_file] + } + + pub fn mode(&self) -> Mode { + self.mode + } + + pub fn font_size(&self) -> u32 { + self.editor_font_size + } + + pub fn selected_file_action(&self) -> Option { + self.selected_file_action + } + + pub fn selected_view_action(&self) -> Option { + self.selected_view_action + } +}