refactor: modularize architecture
refactors the application structure to move away from a monolithic `main.rs`. - **Domain Models**: Extracted `State`, `Message`, `FileAction`, and `ViewAction` into `state.rs` and `message.rs`. Enforced encapsulation on `State` using private fields and public accessors. - **Controller Layer**: Created `handler.rs` to handle business logic and I/O operations, transforming the main `update` loop into a pure event router. - **Component System**: Split the UI into composable, stateless functions in `components/` (tabs, editor, status_bar, action_bar), removing the 100+ line view function. - **File Logic**: Encapsulated file operations and safe path handling within `file.rs`, removing fragile `expect` calls from the UI layer. - **Utilities**: Centralized static configuration, keybindings, and I/O helpers into `constants.rs`, `key_bindings.rs`, and `io.rs`. This change decouples the view from the model and makes the core application logic testable
This commit is contained in:
@@ -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<FileAction>,
|
||||
selected_view_action: Option<ViewAction>,
|
||||
) -> 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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod tabs;
|
||||
pub mod editor;
|
||||
pub mod status_bar;
|
||||
pub mod action_bar;
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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");
|
||||
+60
-35
@@ -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<PathBuf>,
|
||||
markdown: Vec<markdown::Item>,
|
||||
}
|
||||
|
||||
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<PathBuf>) -> 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<PathBuf>) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
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;
|
||||
use rfd::FileDialog;
|
||||
|
||||
pub fn edit(state: &mut State, action: text_editor::Action) -> Task<Message> {
|
||||
state.apply_edit(action);
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub fn switch_tab(state: &mut State, index: usize) -> Task<Message> {
|
||||
state.switch_tab(index);
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub fn link_clicked(url: String) -> Task<Message> {
|
||||
println!("Opening link: {}", url);
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn save_file(path: Option<&PathBuf>, text: String) -> Option<PathBuf> {
|
||||
let mut save_path = path.cloned();
|
||||
|
||||
if path.is_none() {
|
||||
save_path = FileDialog::new().set_directory("/").save_file();
|
||||
}
|
||||
|
||||
match save_path {
|
||||
Some(p) => {
|
||||
let sp = p.clone();
|
||||
io::save_file_to_disk(sp, text);
|
||||
Some(p)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn save_file_as(text: String) -> Option<PathBuf> {
|
||||
let files = FileDialog::new().set_directory("/").save_file();
|
||||
|
||||
match files {
|
||||
Some(path) => {
|
||||
io::save_file_to_disk(path.clone(), text);
|
||||
Some(path)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn open_file() -> (Option<PathBuf>, String) {
|
||||
let file = FileDialog::new().set_directory("/").pick_file();
|
||||
|
||||
match file {
|
||||
Some(path) => {
|
||||
let content = io::load_file_from_disk(path.clone());
|
||||
(Some(path), content)
|
||||
}
|
||||
None => (None, String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn file_action(state: &mut State, action: FileAction) -> Task<Message> {
|
||||
match action {
|
||||
FileAction::New => {
|
||||
state.new_file();
|
||||
Task::none()
|
||||
}
|
||||
FileAction::Close(index) => {
|
||||
if state.close_file(index) {
|
||||
iced::exit()
|
||||
} else {
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
FileAction::Open => {
|
||||
let (path, content) = open_file();
|
||||
|
||||
if let Some(p) = path {
|
||||
state.open_file(p, content);
|
||||
}
|
||||
|
||||
Task::none()
|
||||
}
|
||||
FileAction::Save => {
|
||||
let (current_path, content) = state.active_file_data();
|
||||
|
||||
if let Some(path) = save_file(current_path, content) {
|
||||
state.set_active_file_path(path);
|
||||
}
|
||||
|
||||
Task::none()
|
||||
}
|
||||
FileAction::SaveAs => {
|
||||
let (_, content) = state.active_file_data();
|
||||
|
||||
if let Some(path) = save_file_as(content) {
|
||||
state.set_active_file_path(path);
|
||||
}
|
||||
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view_action(state: &mut State, action: ViewAction) -> Task<Message> {
|
||||
match action {
|
||||
ViewAction::Increase => state.increase_font(),
|
||||
ViewAction::Decrease => state.decrease_font(),
|
||||
ViewAction::Reset => state.reset_font(),
|
||||
ViewAction::TogglePreview => state.toggle_preview(),
|
||||
}
|
||||
|
||||
Task::none()
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use std::{
|
||||
fs::{read_to_string, write},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
pub fn save_file_to_disk(path: PathBuf, text: String) {
|
||||
let save_result = write(path, text);
|
||||
|
||||
match save_result {
|
||||
Ok(_) => {},
|
||||
Err(err) => eprintln!("Error: {}", err),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_file_from_disk(path: PathBuf) -> String {
|
||||
let read_result = read_to_string(path);
|
||||
read_result.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::{self, remove_file};
|
||||
|
||||
#[test]
|
||||
fn test_save_file_to_disk() {
|
||||
let test_path = PathBuf::from("test_file.txt");
|
||||
let test_content = String::from("Hello, Chat!");
|
||||
|
||||
save_file_to_disk(test_path.clone(), test_content.clone());
|
||||
|
||||
let saved_content = fs::read_to_string(&test_path).unwrap();
|
||||
|
||||
assert_eq!(saved_content, test_content);
|
||||
|
||||
let _ = remove_file(test_path);
|
||||
}
|
||||
|
||||
#[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());
|
||||
|
||||
let saved_content = load_file_from_disk(test_path.clone());
|
||||
|
||||
assert_eq!(saved_content, test_content);
|
||||
|
||||
let _ = remove_file(test_path);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
+49
-471
@@ -1,468 +1,73 @@
|
||||
mod components;
|
||||
mod constants;
|
||||
mod file;
|
||||
mod io;
|
||||
mod key_bindings;
|
||||
mod state;
|
||||
mod handler;
|
||||
mod message;
|
||||
|
||||
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,
|
||||
};
|
||||
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;
|
||||
use crate::message::Message;
|
||||
use crate::state::State;
|
||||
|
||||
struct File {
|
||||
content: text_editor::Content,
|
||||
path: Option<PathBuf>,
|
||||
markdown: Vec<markdown::Item>,
|
||||
}
|
||||
|
||||
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<File>,
|
||||
current_file: usize,
|
||||
editor_font_size: u32,
|
||||
selected_file_action: Option<FileAction>,
|
||||
selected_view_action: Option<ViewAction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FileAction {
|
||||
New,
|
||||
Save,
|
||||
SaveAs,
|
||||
Open,
|
||||
Close(Option<usize>),
|
||||
}
|
||||
|
||||
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"
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
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()
|
||||
})
|
||||
.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<PathBuf>, text: String) -> Option<PathBuf> {
|
||||
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<PathBuf> {
|
||||
let files = FileDialog::new().set_directory("/").save_file();
|
||||
|
||||
match files {
|
||||
Some(path) => {
|
||||
file::save_file_to_disk(path.clone(), text);
|
||||
Some(path)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn open_file() -> (Option<PathBuf>, 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()),
|
||||
}
|
||||
.window(window::Settings {
|
||||
icon: Some(
|
||||
icon::from_file_data(constants::ICON_BYTES, None).expect("Failed to load icon"),
|
||||
),
|
||||
..window::Settings::default()
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
fn update(state: &mut State, message: Message) -> Task<Message> {
|
||||
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::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::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);
|
||||
}
|
||||
}
|
||||
|
||||
Task::none()
|
||||
}
|
||||
|
||||
struct Keybinding {
|
||||
key: &'static str,
|
||||
modifiers: Modifiers,
|
||||
message: Message,
|
||||
fn view(state: &State) -> Element<'_, Message> {
|
||||
let current_file = state.active_file();
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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),
|
||||
},
|
||||
];
|
||||
fn boot() -> State {
|
||||
state::State::new()
|
||||
}
|
||||
|
||||
fn subscription(_state: &State) -> Subscription<Message> {
|
||||
event::listen_with(|e, _status, _win| -> Option<Message> {
|
||||
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 +78,7 @@ fn subscription(_state: &State) -> Subscription<Message> {
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
fn theme(_state: &State) -> Theme {
|
||||
Theme::Dark
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use iced::widget::text_editor;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FileAction {
|
||||
New,
|
||||
Save,
|
||||
SaveAs,
|
||||
Open,
|
||||
Close(Option<usize>),
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
+147
@@ -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<file::File>,
|
||||
current_file: usize,
|
||||
editor_font_size: u32,
|
||||
selected_file_action: Option<FileAction>,
|
||||
selected_view_action: Option<ViewAction>,
|
||||
}
|
||||
|
||||
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<usize>) -> 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<FileAction> {
|
||||
self.selected_file_action
|
||||
}
|
||||
|
||||
pub fn selected_view_action(&self) -> Option<ViewAction> {
|
||||
self.selected_view_action
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user