Files
zoeae/src/main.rs
T

507 lines
15 KiB
Rust
Raw Normal View History

mod file;
use std::path::PathBuf;
use iced::keyboard::{Key, Modifiers};
use iced::padding::bottom;
2026-01-21 07:12:20 -06:00
use iced::widget::{
button, column, container, markdown, pick_list, row, scrollable, text, text_editor,
};
2026-01-09 07:51:53 -06:00
use iced::window::icon;
use iced::{Background, Border, Element, Subscription, Task, border, event};
2026-01-10 08:02:21 -06:00
use iced::{Font, Length, Theme};
use iced::{keyboard, window};
use rfd::FileDialog;
2026-01-11 08:04:25 -06:00
const CUSTOM_FONT: Font = Font::with_name("CaskaydiaCove Nerd Font Mono");
2026-01-11 08:04:25 -06:00
const DEFAULT_EDITOR_FONT_SIZE: u32 = 16;
2026-01-10 08:02:21 -06:00
const MAX_EDITOR_FONT_SIZE: u32 = 80;
const MIN_EDITOR_FONT_SIZE: u32 = 12;
struct File {
content: text_editor::Content,
path: Option<PathBuf>,
2026-01-21 07:12:20 -06:00
markdown: Vec<markdown::Item>,
}
impl Default for File {
fn default() -> Self {
File {
content: text_editor::Content::new(),
path: None,
2026-01-21 07:12:20 -06:00
markdown: Vec::new(),
}
}
}
2026-01-21 07:12:20 -06:00
#[derive(Default)]
enum Mode {
#[default]
Edit,
Preview,
}
#[derive(Default)]
struct State {
2026-01-21 07:12:20 -06:00
mode: Mode,
files: Vec<File>,
current_file: usize,
2026-01-10 08:02:21 -06:00
editor_font_size: u32,
selected_file_action: Option<FileAction>,
2026-01-11 08:04:25 -06:00
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"),
2026-01-11 08:04:25 -06:00
FileAction::SaveAs => write!(f, "Save as... "),
FileAction::Open => write!(f, "Open"),
FileAction::Close(_) => write!(f, "Close"),
}
}
}
2026-01-11 08:04:25 -06:00
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ViewAction {
Increase,
Decrease,
Reset,
2026-01-21 07:12:20 -06:00
TogglePreview,
2026-01-11 08:04:25 -06:00
}
impl ViewAction {
const ALL: &'static [ViewAction] = &[
ViewAction::Increase,
ViewAction::Decrease,
ViewAction::Reset,
2026-01-21 07:12:20 -06:00
ViewAction::TogglePreview,
2026-01-11 08:04:25 -06:00
];
}
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"),
2026-01-11 08:04:25 -06:00
}
}
}
#[derive(Debug, Clone)]
enum Message {
Edit(text_editor::Action),
FileActionSelected(FileAction),
2026-01-11 08:04:25 -06:00
ViewActionSelected(ViewAction),
SwitchTab(usize),
2026-01-21 07:12:20 -06:00
LinkClicked(String),
}
2026-01-21 07:12:20 -06:00
// 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> {
2026-01-11 08:04:25 -06:00
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
}
})
.on_press(Message::SwitchTab(file_index));
tab_row = tab_row.push(tab_button);
2026-01-11 08:04:25 -06:00
}
let tabs = scrollable(container(tab_row).padding(bottom(10))).direction(
scrollable::Direction::Horizontal(scrollable::Scrollbar::new()),
);
2026-01-11 08:04:25 -06:00
let file_menu = pick_list(
FileAction::ALL,
state.selected_file_action,
Message::FileActionSelected,
)
.placeholder("File");
2026-01-11 08:04:25 -06:00
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(&current_file.content)
2026-01-10 08:02:21 -06:00
.size(state.editor_font_size)
.height(Length::Fill)
.on_action(Message::Edit);
2026-01-21 07:12:20 -06:00
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);
2026-01-21 07:12:20 -06:00
let markdown_preview =
markdown::view(&current_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 &current_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));
2026-01-21 07:12:20 -06:00
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,
}
}
2026-01-10 08:02:21 -06:00
fn save_file_as(text: String) -> Option<PathBuf> {
let files = FileDialog::new().set_directory("/").save_file();
match files {
2026-01-10 08:02:21 -06:00
Some(path) => {
file::save_file_to_disk(path.clone(), text);
Some(path)
}
2026-01-10 08:02:21 -06:00
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()),
}
}
2026-01-21 07:12:20 -06:00
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);
2026-01-21 07:12:20 -06:00
current_file.markdown = markdown::parse(&current_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;
2026-01-21 07:12:20 -06:00
return Task::none();
}
}
let opened_file = File {
path,
content: text_editor::Content::with_text(&content),
2026-01-21 07:12:20 -06:00
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,
};
2026-01-21 07:12:20 -06:00
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);
}
}
}
2026-01-11 08:04:25 -06:00
Message::ViewActionSelected(action) => {
state.selected_view_action = None;
2026-01-10 08:02:21 -06:00
2026-01-11 08:04:25 -06:00
match action {
ViewAction::Increase => {
2026-01-11 08:04:25 -06:00
if state.editor_font_size >= MAX_EDITOR_FONT_SIZE {
2026-01-21 07:12:20 -06:00
return Task::none();
2026-01-11 08:04:25 -06:00
}
state.editor_font_size += 2;
}
ViewAction::Decrease => {
2026-01-11 08:04:25 -06:00
if state.editor_font_size <= MIN_EDITOR_FONT_SIZE {
2026-01-21 07:12:20 -06:00
return Task::none();
2026-01-11 08:04:25 -06:00
}
state.editor_font_size -= 2;
}
2026-01-21 07:12:20 -06:00
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,
},
2026-01-11 08:04:25 -06:00
}
}
Message::SwitchTab(file_id) => {
state.current_file = file_id;
}
2026-01-21 07:12:20 -06:00
Message::LinkClicked(link) => {
print!("Link clicked: {}", link);
}
}
2026-01-21 07:12:20 -06:00
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
}
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 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 {
if vb.should_handle(&key, &modifiers) {
return Some(vb.message.clone());
}
}
None
}
_ => None,
}
})
}
fn boot() -> State {
let default_file = File::default();
let files = vec![default_file];
2026-01-10 08:02:21 -06:00
State {
files,
current_file: 0,
2026-01-11 08:04:25 -06:00
editor_font_size: DEFAULT_EDITOR_FONT_SIZE,
2026-01-10 08:02:21 -06:00
..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()
})
2026-01-09 07:51:53 -06:00
.window(window::Settings {
icon: Some(
icon::from_file_data(include_bytes!("./images/icon.ico"), None)
.expect("Failed to load icon"),
),
..window::Settings::default()
})
.run()
}