diff --git a/Cargo.lock b/Cargo.lock index 4525dc5..67082e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -543,7 +543,7 @@ dependencies = [ [[package]] name = "challenge_frontend" -version = "1.0.1" +version = "2.0.0" dependencies = [ "eframe", "egui", diff --git a/Cargo.toml b/Cargo.toml index 1ad5b82..12a6459 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "challenge_frontend" -version = "1.0.1" +version = "2.0.0" edition = "2021" rust-version = "1.71" authors = ["Philip Barlow"] diff --git a/src/code_editor/editor.rs b/src/code_editor/editor.rs new file mode 100644 index 0000000..426bdc8 --- /dev/null +++ b/src/code_editor/editor.rs @@ -0,0 +1,239 @@ +use egui::{text_edit::CCursorRange, *}; + +#[derive(serde::Deserialize, serde::Serialize)] +pub struct CodeEditor { + code: String, + highlight_editor: bool, + show_rendered: bool, +} + +impl PartialEq for CodeEditor { + fn eq(&self, other: &Self) -> bool { + (&self.code, self.highlight_editor, self.show_rendered) + == (&other.code, other.highlight_editor, other.show_rendered) + } +} + +impl Default for CodeEditor { + fn default() -> Self { + Self { + code: DEFAULT_CODE.trim().to_owned(), + highlight_editor: true, + show_rendered: true, + } + } +} + +impl CodeEditor { + pub fn panels(&mut self, ctx: &egui::Context) { + egui::TopBottomPanel::bottom("easy_mark_bottom").show(ctx, |ui| { + let layout = egui::Layout::top_down(egui::Align::Center).with_main_justify(true); + }); + + egui::CentralPanel::default().show(ctx, |ui| { + self.ui(ui); + }); + } + + pub fn ui(&mut self, ui: &mut egui::Ui) { + egui::Grid::new("controls").show(ui, |ui| { + let _ = ui.button("Hotkeys").on_hover_ui(nested_hotkeys_ui); + ui.checkbox(&mut self.show_rendered, "Show rendered"); + ui.checkbox(&mut self.highlight_editor, "Highlight editor"); + egui::reset_button(ui, self); + ui.end_row(); + }); + ui.separator(); + + if self.show_rendered { + ui.columns(2, |columns| { + ScrollArea::vertical() + .id_source("source") + .show(&mut columns[0], |ui| self.editor_ui(ui)); + ScrollArea::vertical() + .id_source("rendered") + .show(&mut columns[1], |ui| {}); + }); + } else { + ScrollArea::vertical() + .id_source("source") + .show(ui, |ui| self.editor_ui(ui)); + } + } + + fn editor_ui(&mut self, ui: &mut egui::Ui) {} +} + +pub const SHORTCUT_BOLD: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::B); +pub const SHORTCUT_CODE: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::N); +pub const SHORTCUT_ITALICS: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::I); +pub const SHORTCUT_SUBSCRIPT: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::L); +pub const SHORTCUT_SUPERSCRIPT: KeyboardShortcut = + KeyboardShortcut::new(Modifiers::COMMAND, Key::Y); +pub const SHORTCUT_STRIKETHROUGH: KeyboardShortcut = + KeyboardShortcut::new(Modifiers::CTRL.plus(Modifiers::SHIFT), Key::Q); +pub const SHORTCUT_UNDERLINE: KeyboardShortcut = + KeyboardShortcut::new(Modifiers::CTRL.plus(Modifiers::SHIFT), Key::W); +pub const SHORTCUT_INDENT: KeyboardShortcut = + KeyboardShortcut::new(Modifiers::CTRL.plus(Modifiers::SHIFT), Key::E); + +fn nested_hotkeys_ui(ui: &mut egui::Ui) { + egui::Grid::new("shortcuts").striped(true).show(ui, |ui| { + let mut label = |shortcut, what| { + ui.label(what); + ui.weak(ui.ctx().format_shortcut(&shortcut)); + ui.end_row(); + }; + + label(SHORTCUT_BOLD, "*bold*"); + label(SHORTCUT_CODE, "`code`"); + label(SHORTCUT_ITALICS, "/italics/"); + label(SHORTCUT_SUBSCRIPT, "$subscript$"); + label(SHORTCUT_SUPERSCRIPT, "^superscript^"); + label(SHORTCUT_STRIKETHROUGH, "~strikethrough~"); + label(SHORTCUT_UNDERLINE, "_underline_"); + label(SHORTCUT_INDENT, "two spaces"); // Placeholder for tab indent + }); +} + +fn shortcuts(ui: &Ui, code: &mut dyn TextBuffer, ccursor_range: &mut CCursorRange) -> bool { + let mut any_change = false; + + if ui.input_mut(|i| i.consume_shortcut(&SHORTCUT_INDENT)) { + // This is a placeholder till we can indent the active line + any_change = true; + let [primary, _secondary] = ccursor_range.sorted(); + + let advance = code.insert_text(" ", primary.index); + ccursor_range.primary.index += advance; + ccursor_range.secondary.index += advance; + } + + for (shortcut, surrounding) in [ + (SHORTCUT_BOLD, "*"), + (SHORTCUT_CODE, "`"), + (SHORTCUT_ITALICS, "/"), + (SHORTCUT_SUBSCRIPT, "$"), + (SHORTCUT_SUPERSCRIPT, "^"), + (SHORTCUT_STRIKETHROUGH, "~"), + (SHORTCUT_UNDERLINE, "_"), + ] { + if ui.input_mut(|i| i.consume_shortcut(&shortcut)) { + any_change = true; + toggle_surrounding(code, ccursor_range, surrounding); + }; + } + + any_change +} + +/// E.g. toggle *strong* with `toggle_surrounding(&mut text, &mut cursor, "*")` +fn toggle_surrounding( + code: &mut dyn TextBuffer, + ccursor_range: &mut CCursorRange, + surrounding: &str, +) { + let [primary, secondary] = ccursor_range.sorted(); + + let surrounding_ccount = surrounding.chars().count(); + + let prefix_crange = primary.index.saturating_sub(surrounding_ccount)..primary.index; + let suffix_crange = secondary.index..secondary.index.saturating_add(surrounding_ccount); + let already_surrounded = code.char_range(prefix_crange.clone()) == surrounding + && code.char_range(suffix_crange.clone()) == surrounding; + + if already_surrounded { + code.delete_char_range(suffix_crange); + code.delete_char_range(prefix_crange); + ccursor_range.primary.index -= surrounding_ccount; + ccursor_range.secondary.index -= surrounding_ccount; + } else { + code.insert_text(surrounding, secondary.index); + let advance = code.insert_text(surrounding, primary.index); + + ccursor_range.primary.index += advance; + ccursor_range.secondary.index += advance; + } +} + +// ---------------------------------------------------------------------------- + +const DEFAULT_CODE: &str = r#" +# EasyMark +EasyMark is a markup language, designed for extreme simplicity. + +``` +WARNING: EasyMark is still an evolving specification, +and is also missing some features. +``` + +---------------- + +# At a glance +- inline text: + - normal, `code`, *strong*, ~strikethrough~, _underline_, /italics/, ^raised^, $small$ + - `\` escapes the next character + - [hyperlink](https://github.com/emilk/egui) + - Embedded URL: +- `# ` header +- `---` separator (horizontal line) +- `> ` quote +- `- ` bullet list +- `1. ` numbered list +- \`\`\` code fence +- a^2^ + b^2^ = c^2^ +- $Remember to read the small print$ + +# Design +> /"Why do what everyone else is doing, when everyone else is already doing it?" +> \- Emil + +Goals: +1. easy to parse +2. easy to learn +3. similar to markdown + +[The reference parser](https://github.com/emilk/egui/blob/master/crates/egui_demo_lib/src/easy_mark/easy_mark_parser.rs) is \~250 lines of code, using only the Rust standard library. The parser uses no look-ahead or recursion. + +There is never more than one way to accomplish the same thing, and each special character is only used for one thing. For instance `*` is used for *strong* and `-` is used for bullet lists. There is no alternative way to specify the *strong* style or getting a bullet list. + +Similarity to markdown is kept when possible, but with much less ambiguity and some improvements (like _underlining_). + +# Details +All style changes are single characters, so it is `*strong*`, NOT `**strong**`. Style is reset by a matching character, or at the end of the line. + +Style change characters and escapes (`\`) work everywhere except for in inline code, code blocks and in URLs. + +You can mix styles. For instance: /italics _underline_/ and *strong `code`*. + +You can use styles on URLs: ~my webpage is at ~. + +Newlines are preserved. If you want to continue text on the same line, just do so. Alternatively, escape the newline by ending the line with a backslash (`\`). \ +Escaping the newline effectively ignores it. + +The style characters are chosen to be similar to what they are representing: + `_` = _underline_ + `~` = ~strikethrough~ (`-` is used for bullet points) + `/` = /italics/ + `*` = *strong* + `$` = $small$ + `^` = ^raised^ + +# TODO +- Sub-headers (`## h2`, `### h3` etc) +- Hotkey Editor +- International keyboard algorithm for non-letter keys +- ALT+SHIFT+Num1 is not a functioning hotkey +- Tab Indent Increment/Decrement CTRL+], CTRL+[ + +- Images + - we want to be able to optionally specify size (width and\/or height) + - centering of images is very desirable + - captioning (image with a text underneath it) + - `![caption=My image][width=200][center](url)` ? +- Nicer URL:s + - `` and `[url](url)` do the same thing yet look completely different. + - let's keep similarity with images +- Tables +- Inspiration: +"#; diff --git a/src/code_editor/mod.rs b/src/code_editor/mod.rs new file mode 100644 index 0000000..80219a9 --- /dev/null +++ b/src/code_editor/mod.rs @@ -0,0 +1,3 @@ +mod editor; + +pub use editor::CodeEditor; diff --git a/src/lib.rs b/src/lib.rs index d8fcae7..b2f9828 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,9 @@ #![warn(clippy::all, rust_2018_idioms)] mod wrap_app; -pub use wrap_app::CodeChallengeApp; +pub use wrap_app::WrapApp; pub mod apps; +pub mod code_editor; pub mod components; pub mod helpers; diff --git a/src/main.rs b/src/main.rs index cf7801d..1ea9e45 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +18,7 @@ fn main() -> eframe::Result<()> { eframe::run_native( "my app", native_options, - Box::new(|cc| Box::new(challenge_frontend::CodeChallengeApp::new(cc))), + Box::new(|cc| Box::new(challenge_frontend::WrapApp::new(cc))), ) } @@ -36,7 +36,7 @@ fn main() { .start( "the_canvas_id", // hardcode it web_options, - Box::new(|cc| Box::new(challenge_frontend::CodeChallengeApp::new(cc))), + Box::new(|cc| Box::new(challenge_frontend::WrapApp::new(cc))), ) .await .expect("failed to start eframe"); diff --git a/src/wrap_app.rs b/src/wrap_app.rs index fed1dfb..51a7291 100644 --- a/src/wrap_app.rs +++ b/src/wrap_app.rs @@ -1,22 +1,28 @@ -use crate::apps; -/// We derive Deserialize/Serialize so we can persist app state on shutdown. +use crate::{ + apps::{self}, + code_editor, +}; +#[cfg(target_arch = "wasm32")] +use core::any::Any; + #[derive(serde::Deserialize, serde::Serialize, Default)] -#[serde(default)] // if we add new fields, give them default values when deserializing old state -pub struct CodeChallengeApp { - #[serde(skip)] // This how you opt-out of serialization of a field - logged_in: bool, - #[serde(skip)] // This how you opt-out of serialization of a field - windows: apps::app_windows::AppWindows, +struct CodeEditorApp { + editor: code_editor::CodeEditor, } -impl CodeChallengeApp { - pub fn new(cc: &eframe::CreationContext<'_>) -> Self { - if let Some(storage) = cc.storage { - return eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default(); - } - Default::default() + +impl eframe::App for CodeEditorApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.editor.panels(ctx); } } +#[derive(serde::Deserialize, serde::Serialize, Default)] +#[serde(default)] +pub struct CodeChallengeApp { + #[serde(skip)] + windows: apps::app_windows::AppWindows, +} + impl eframe::App for CodeChallengeApp { /// Called by the frame work to save state before shutdown. fn save(&mut self, storage: &mut dyn eframe::Storage) { @@ -25,22 +31,6 @@ impl eframe::App for CodeChallengeApp { /// Called each time the UI needs repainting, which may be many times per second. fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { - egui::menu::bar(ui, |ui| { - #[cfg(not(target_arch = "wasm32"))] // no File->Quit on web pages! - { - ui.menu_button("File", |ui| { - if ui.button("Quit").clicked() { - _frame.close(); - } - }); - ui.add_space(16.0); - } - - egui::widgets::global_dark_light_mode_buttons(ui); - }); - }); - self.windows.ui(ctx); egui::CentralPanel::default().show(ctx, |ui| { @@ -52,6 +42,169 @@ impl eframe::App for CodeChallengeApp { } } +#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, Default)] +enum Anchor { + #[default] + Landing, + CodeEditor, +} + +impl Anchor { + #[cfg(target_arch = "wasm32")] + fn all() -> Vec { + vec![Anchor::Landing, Anchor::CodeEditor] + } +} + +impl std::fmt::Display for Anchor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} + +impl From for egui::WidgetText { + fn from(value: Anchor) -> Self { + Self::RichText(egui::RichText::new(value.to_string())) + } +} + +#[derive(serde::Deserialize, serde::Serialize, Default)] +pub struct State { + landing: CodeChallengeApp, + code_editor: CodeEditorApp, + selected_anchor: Anchor, +} + +#[derive(serde::Deserialize, serde::Serialize, Default)] +#[serde(default)] +pub struct WrapApp { + state: State, +} + +impl WrapApp { + pub fn new(cc: &eframe::CreationContext<'_>) -> Self { + #[allow(unused_mut)] + let mut slf = Self { + state: State::default(), + + #[cfg(any(feature = "glow", feature = "wgpu"))] + custom3d: crate::apps::Custom3d::new(cc), + }; + + if let Some(storage) = cc.storage { + if let Some(state) = eframe::get_value(storage, eframe::APP_KEY) { + slf.state = state; + } + } + + slf + } + + fn apps_iter_mut(&mut self) -> impl Iterator { + let vec = vec![ + ( + "✨ Landing", + Anchor::Landing, + &mut self.state.landing as &mut dyn eframe::App, + ), + ( + "💻 Code Editor", + Anchor::CodeEditor, + &mut self.state.code_editor as &mut dyn eframe::App, + ), + ]; + vec.into_iter() + } +} + +impl eframe::App for WrapApp { + #[cfg(feature = "persistence")] + fn save(&mut self, storage: &mut dyn eframe::Storage) { + eframe::set_value(storage, eframe::APP_KEY, &self.state); + } + + fn clear_color(&self, visuals: &egui::Visuals) -> [f32; 4] { + visuals.panel_fill.to_normalized_gamma_f32() + } + + fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + #[cfg(target_arch = "wasm32")] + if let Some(anchor) = frame.info().web_info.location.hash.strip_prefix('#') { + let anchor = Anchor::all().into_iter().find(|x| x.to_string() == anchor); + if let Some(v) = anchor { + self.state.selected_anchor = v; + } + } + + #[cfg(not(target_arch = "wasm32"))] + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::F11)) { + frame.set_fullscreen(!frame.info().window_info.fullscreen); + } + + egui::TopBottomPanel::top("wrap_app_top_bar").show(ctx, |ui| { + ui.horizontal_wrapped(|ui| { + ui.visuals_mut().button_frame = false; + self.bar_contents(ui, frame); + }); + }); + + self.show_selected_app(ctx, frame); + + // On web, the browser controls `pixels_per_point`. + if !frame.is_web() { + egui::gui_zoom::zoom_with_keyboard_shortcuts(ctx, frame.info().native_pixels_per_point); + } + } + + #[cfg(feature = "glow")] + fn on_exit(&mut self, gl: Option<&glow::Context>) { + if let Some(custom3d) = &mut self.custom3d { + custom3d.on_exit(gl); + } + } + + #[cfg(target_arch = "wasm32")] + fn as_any_mut(&mut self) -> Option<&mut dyn Any> { + Some(&mut *self) + } +} + +impl WrapApp { + fn bar_contents(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + egui::widgets::global_dark_light_mode_switch(ui); + + ui.separator(); + + let mut selected_anchor = self.state.selected_anchor; + for (name, anchor, _app) in self.apps_iter_mut() { + if ui + .selectable_label(selected_anchor == anchor, name) + .clicked() + { + selected_anchor = anchor; + if frame.is_web() { + ui.ctx() + .open_url(egui::OpenUrl::same_tab(format!("#{anchor}"))); + } + } + } + self.state.selected_anchor = selected_anchor; + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + egui::warn_if_debug_build(ui); + }); + } + + fn show_selected_app(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + let selected_anchor = self.state.selected_anchor; + for (_name, anchor, app) in self.apps_iter_mut() { + if anchor == selected_anchor || ctx.memory(|mem| mem.everything_is_visible()) { + app.update(ctx, frame); + } + } + } +} + fn powered_by_egui_and_eframe(ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 0.0;