1 Commits

Author SHA1 Message Date
fdb92fbd0e Shrink handwriting when not focused [wip] 2025-10-26 09:22:49 +01:00
7 changed files with 77 additions and 208 deletions

View File

@@ -1,5 +1,6 @@
use std::{ use std::{
fs, fs,
io::Read,
path::PathBuf, path::PathBuf,
sync::{Arc, mpsc}, sync::{Arc, mpsc},
thread::JoinHandle, thread::JoinHandle,
@@ -7,12 +8,11 @@ use std::{
}; };
use crate::{ use crate::{
command_palette::{self, CommandPalette},
file_editor::{FileEditor, SaveStatus}, file_editor::{FileEditor, SaveStatus},
folder::Folder, folder::Folder,
preferences::Preferences, preferences::Preferences,
text_styles::{H1, H1_MONO, H2, H2_MONO, H3, H3_MONO, H4, H4_MONO, H5, H5_MONO, H6, H6_MONO}, text_styles::{H1, H1_MONO, H2, H2_MONO, H3, H3_MONO, H4, H4_MONO, H5, H5_MONO, H6, H6_MONO},
util::{GuiSender, log_error}, util::{GuiSender, file_mtime, log_error},
}; };
use egui::{ use egui::{
Align, Button, Context, FontData, FontDefinitions, FontFamily, FontId, Frame, Image, Key, Align, Button, Context, FontData, FontDefinitions, FontFamily, FontId, Frame, Image, Key,
@@ -40,9 +40,6 @@ pub struct App {
open_tab_index: Option<usize>, open_tab_index: Option<usize>,
next_tab_id: TabId, next_tab_id: TabId,
#[serde(skip)]
command_palette: Option<CommandPalette>,
} }
pub struct Jobs { pub struct Jobs {
@@ -132,7 +129,6 @@ impl Default for App {
next_tab_id: 2, next_tab_id: 2,
show_folders: false, show_folders: false,
folders: vec![], folders: vec![],
command_palette: None,
} }
} }
} }
@@ -296,10 +292,6 @@ impl eframe::App for App {
if input.consume_key(Modifiers::CTRL, Key::S) { if input.consume_key(Modifiers::CTRL, Key::S) {
self.save_active_tab(ctx); self.save_active_tab(ctx);
} }
if input.consume_key(Modifiers::CTRL, Key::K) {
self.command_palette = Some(Default::default());
}
}); });
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
@@ -318,10 +310,19 @@ impl eframe::App for App {
self.jobs.start(ui.ctx(), move || { self.jobs.start(ui.ctx(), move || {
let file_path = rfd::FileDialog::new().pick_file()?; let file_path = rfd::FileDialog::new().pick_file()?;
log_error(eyre!("Failed to open file {file_path:?}"), || { let mut file = fs::File::open(&file_path)
FileEditor::open_file(file_path) .inspect_err(|e| log::error!("Failed to open {file_path:?}: {e}"))
}) .ok()?;
.map(Action::OpenFile)
let mtime = log_error(eyre!("file_path:?"), || file_mtime(&file))?;
let mut text = String::new();
file.read_to_string(&mut text)
.inspect_err(|e| log::error!("Failed to read {file_path:?}: {e}"))
.ok()?;
let editor = FileEditor::from_file(file_path, &text, mtime);
Some(Action::OpenFile(editor))
}); });
} }
@@ -444,10 +445,23 @@ impl eframe::App for App {
if let Some(file_path) = response.open_file { if let Some(file_path) = response.open_file {
let file_path = file_path.to_owned(); let file_path = file_path.to_owned();
self.jobs.start(ui.ctx(), move || { self.jobs.start(ui.ctx(), move || {
log_error(eyre!("Failed to open file {file_path:?}"), || { let mut file = fs::File::open(&file_path)
FileEditor::open_file(file_path) .inspect_err(|e| {
}) log::error!("Failed to open {file_path:?}: {e}")
.map(Action::OpenFile) })
.ok()?;
let mtime = log_error(eyre!("file_path:?"), || file_mtime(&file))?;
let mut text = String::new();
file.read_to_string(&mut text)
.inspect_err(|e| {
log::error!("Failed to read {file_path:?}: {e}")
})
.ok()?;
let editor = FileEditor::from_file(file_path, &text, mtime);
Some(Action::OpenFile(editor))
}); });
} }
@@ -475,16 +489,6 @@ impl eframe::App for App {
egui::warn_if_debug_build(ui); egui::warn_if_debug_build(ui);
}); });
}); });
if let Some(command_palette) = &mut self.command_palette {
match command_palette.show(ctx, &mut self.jobs, &mut self.folders) {
command_palette::Response::None => {}
command_palette::Response::Close => {
self.command_palette = None;
ctx.request_repaint();
}
};
}
} }
} }

View File

@@ -1,146 +0,0 @@
use std::{
cmp::min,
path::{Path, PathBuf},
};
use egui::{Key, Modifiers, RichText, Ui, Window};
use eyre::eyre;
use crate::{
app::{Action, Jobs},
file_editor::FileEditor,
folder::Folder,
util::log_error,
};
pub struct CommandPalette {
first_frame: bool,
input: String,
results: Vec<OpenFileCommand>,
list_i: usize,
}
impl Default for CommandPalette {
fn default() -> Self {
Self {
first_frame: true,
input: Default::default(),
results: Default::default(),
list_i: 0,
}
}
}
struct OpenFileCommand {
pretty: String,
path: PathBuf,
}
pub enum Response {
None,
Close,
}
impl CommandPalette {
pub fn show(
&mut self,
ctx: &egui::Context,
jobs: &mut Jobs,
folders: &mut [Folder],
) -> Response {
Window::new("Command Palette")
.collapsible(false)
.show(ctx, |ui| {
let text_edit = ui.text_edit_singleline(&mut self.input);
let mut response = Response::None;
if self.first_frame {
self.first_frame = false;
text_edit.request_focus();
}
if text_edit.has_focus() || text_edit.lost_focus() {
ui.input_mut(|input| {
if input.consume_key(Modifiers::NONE, Key::ArrowUp) {
self.list_i = self.list_i.saturating_sub(1);
};
if input.consume_key(Modifiers::NONE, Key::ArrowDown) {
self.list_i =
min(self.list_i + 1, self.results.len().saturating_sub(1));
};
if input.consume_key(Modifiers::NONE, Key::Enter)
&& let Some(result) = self.results.get(self.list_i)
{
let path = result.path.clone();
log::info!("open {}", result.pretty);
jobs.start(ui.ctx(), move || {
log_error(eyre!("Failed to open file {path:?}"), move || {
FileEditor::open_file(path)
})
.map(Action::OpenFile)
});
};
});
}
if !text_edit.has_focus() {
log::info!("sad face");
response = Response::Close;
}
if text_edit.changed() {
self.results.clear();
if !self.input.is_empty() {
self.input = self.input.to_lowercase();
search_files(ui, &self.input, "".as_ref(), folders, &mut self.results);
}
self.list_i = min(self.list_i, self.results.len().saturating_sub(1));
}
for (i, result) in self.results.iter().enumerate() {
let highlight = i == self.list_i;
ui.horizontal(|ui| {
let mut label = RichText::new("> open");
if highlight {
label = label.strong().underline();
}
ui.label(label);
ui.monospace(&result.pretty);
});
}
response
})
.expect("Window is always open")
.inner
.expect("Windows is never collapsed")
}
}
/// Search the tree of [Folder]s for files matching `query`.
fn search_files(
ui: &mut Ui,
query: &str,
path: &Path,
folders: &mut [Folder],
out: &mut Vec<OpenFileCommand>,
) {
for folder in folders {
let Some(folder) = folder.load(ui) else {
continue;
};
let path = path.join(&folder.name);
for file in &folder.child_files {
if file.name.to_lowercase().contains(query) {
let pretty = path.join(&file.name).to_string_lossy().to_string();
let path = file.path.clone();
out.push(OpenFileCommand { pretty, path });
}
}
search_files(ui, query, &path, &mut folder.child_folders, out);
}
}

View File

@@ -2,7 +2,7 @@ use std::{
cmp::Ordering, cmp::Ordering,
fmt::{self, Display}, fmt::{self, Display},
fs::{self, File}, fs::{self, File},
io::{Read as _, Write}, io::Write,
ops::{Div as _, Sub as _}, ops::{Div as _, Sub as _},
path::{Path, PathBuf}, path::{Path, PathBuf},
str::FromStr, str::FromStr,
@@ -13,7 +13,7 @@ use chrono::{DateTime, Local};
use egui::{ use egui::{
Align, Button, Context, DragAndDrop, Frame, Layout, ScrollArea, Ui, UiBuilder, Vec2, vec2, Align, Button, Context, DragAndDrop, Frame, Layout, ScrollArea, Ui, UiBuilder, Vec2, vec2,
}; };
use eyre::{Context as _, eyre}; use eyre::eyre;
use notify::{EventKind, Watcher}; use notify::{EventKind, Watcher};
use crate::{ use crate::{
@@ -100,7 +100,7 @@ impl FileEditor {
} }
} }
pub fn from_path(file_path: PathBuf, contents: &str, mtime: DateTime<Local>) -> Self { pub fn from_file(file_path: PathBuf, contents: &str, mtime: DateTime<Local>) -> Self {
let file_title = file_path let file_title = file_path
.file_name() .file_name()
.map(|name| name.to_string_lossy().to_string()) .map(|name| name.to_string_lossy().to_string())
@@ -115,19 +115,6 @@ impl FileEditor {
} }
} }
pub fn open_file(file_path: PathBuf) -> eyre::Result<Self> {
let mut file =
fs::File::open(&file_path).wrap_err_with(|| eyre!("Failed to open {file_path:?}"))?;
let mtime = file_mtime(&file)?;
let mut text = String::new();
file.read_to_string(&mut text)
.wrap_err_with(|| eyre!("Failed to read {file_path:?}"))?;
Ok(FileEditor::from_path(file_path, &text, mtime))
}
pub fn title(&self) -> &str { pub fn title(&self) -> &str {
&self.title &self.title
} }

View File

@@ -158,7 +158,7 @@ impl LoadedFolder {
} }
impl Folder { impl Folder {
pub fn load(&mut self, ui: &mut Ui) -> Option<&mut LoadedFolder> { fn load(&mut self, ui: &mut Ui) -> Option<&mut LoadedFolder> {
if let Folder::NotLoaded { name, path } = self { if let Folder::NotLoaded { name, path } = self {
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();

View File

@@ -27,7 +27,7 @@ use zerocopy::{FromBytes, IntoBytes};
use crate::{custom_code_block::try_from_custom_code_block, rasterizer}; use crate::{custom_code_block::try_from_custom_code_block, rasterizer};
use crate::{custom_code_block::write_custom_code_block, util::random_id}; use crate::{custom_code_block::write_custom_code_block, util::random_id};
use self::tool::{Tool, ToolEvent}; use self::tool::{ToolEvent, Tool};
const HANDWRITING_MIN_HEIGHT: f32 = 100.0; const HANDWRITING_MIN_HEIGHT: f32 = 100.0;
const HANDWRITING_BOTTOM_PADDING: f32 = 80.0; const HANDWRITING_BOTTOM_PADDING: f32 = 80.0;
@@ -89,6 +89,9 @@ struct Ephemeral {
/// Tool position last frame, in canvas space. /// Tool position last frame, in canvas space.
last_tool_position: Option<Pos2>, last_tool_position: Option<Pos2>,
/// Whether the handwriting element is being interacted with.
is_focused: bool,
/// The stroke that is currently being drawed. /// The stroke that is currently being drawed.
current_stroke: Vec<Pos2>, current_stroke: Vec<Pos2>,
@@ -139,9 +142,10 @@ impl Default for Ephemeral {
Self { Self {
id: random_id(), id: random_id(),
canvas_rasterizer: Default::default(), canvas_rasterizer: Default::default(),
tool: Tool::Pencil, tool: Tool::Eraser,
tool_position: None, tool_position: None,
last_tool_position: None, last_tool_position: None,
is_focused: false,
current_stroke: Default::default(), current_stroke: Default::default(),
tessellator: None, tessellator: None,
mesh: Default::default(), mesh: Default::default(),
@@ -238,9 +242,16 @@ impl Handwriting {
// Was the user in the process of drawing a stroke last frame? // Was the user in the process of drawing a stroke last frame?
let was_drawing = !self.e.current_stroke.is_empty(); let was_drawing = !self.e.current_stroke.is_empty();
// Is the user in the process of drawing a stroke now? // Is the user in the process of drawing a stroke now?;
let is_drawing = response.interact_pointer_pos().is_some(); let is_drawing = response.interact_pointer_pos().is_some();
if is_drawing {
self.e.is_focused = true;
} else if ui.ctx().input(|i| i.pointer.any_down()) {
// if the user starts interactive with something else, unfocus the handwriting area.
self.e.is_focused = false;
}
if !is_drawing { if !is_drawing {
if was_drawing { if was_drawing {
// commit current line // commit current line
@@ -248,12 +259,18 @@ impl Handwriting {
response.mark_changed(); response.mark_changed();
} }
let padding = if self.e.is_focused {
HANDWRITING_BOTTOM_PADDING
} else {
0.0
};
// recalculate how tall the widget should be // recalculate how tall the widget should be
let lines_max_y = self let lines_max_y = self
.strokes .strokes
.iter() .iter()
.flatten() .flatten()
.map(|p| p.y + HANDWRITING_BOTTOM_PADDING) .map(|p| p.y + padding)
.fold(HANDWRITING_MIN_HEIGHT, |max, y| max.max(y)); .fold(HANDWRITING_MIN_HEIGHT, |max, y| max.max(y));
// Change the height of the handwriting item. // Change the height of the handwriting item.
@@ -299,7 +316,9 @@ impl Handwriting {
self.e.tool_position = tool_event.position(); self.e.tool_position = tool_event.position();
match self.e.tool { match self.e.tool {
Tool::Pencil => { Tool::Pencil => {
hw_response.changed |= tool::pencil::on_tool_event(self, tool_event); hw_response.changed |=
tool::pencil::on_tool_event(self, tool_event);
} }
Tool::Eraser => { Tool::Eraser => {
if tool::eraser::on_tool_event(self, tool_event) { if tool::eraser::on_tool_event(self, tool_event) {
@@ -365,9 +384,7 @@ impl Handwriting {
// Draw the texture // Draw the texture
self.e.canvas_rasterizer.show(ui.ctx(), &painter, mesh_rect); self.e.canvas_rasterizer.show(ui.ctx(), &painter, mesh_rect);
if let Some(tool_position) = self.e.tool_position if let Some(tool_position) = self.e.tool_position && let Tool::Eraser = self.e.tool {
&& let Tool::Eraser = self.e.tool
{
let pos = to_screen * tool_position; let pos = to_screen * tool_position;
let shape = Shape::circle_stroke(pos, tool::eraser::RADIUS, style.stroke); let shape = Shape::circle_stroke(pos, tool::eraser::RADIUS, style.stroke);
painter.add(shape); painter.add(shape);
@@ -780,8 +797,6 @@ mod test {
use egui::{Event, Modifiers, PointerButton, Pos2, Rect, emath::RectTransform}; use egui::{Event, Modifiers, PointerButton, Pos2, Rect, emath::RectTransform};
use crate::handwriting::tool::pencil;
use super::{Handwriting, process_event}; use super::{Handwriting, process_event};
#[test] #[test]
@@ -980,7 +995,7 @@ mod test {
let from_screen = from_screen(); let from_screen = from_screen();
for event in TEST_EVENTS { for event in TEST_EVENTS {
process_event(&mut last_pos, from_screen, event, |tool_event| { process_event(&mut last_pos, from_screen, event, |tool_event| {
pencil::on_tool_event(&mut handwriting, tool_event); handwriting.on_tool_event(tool_event);
}); });
} }
let serialized = handwriting.to_string(); let serialized = handwriting.to_string();

View File

@@ -1,5 +1,4 @@
pub mod app; pub mod app;
pub mod command_palette;
pub mod constants; pub mod constants;
pub mod custom_code_block; pub mod custom_code_block;
pub mod file_editor; pub mod file_editor;

View File

@@ -1,4 +1,4 @@
use std::iter; use std::iter::{self, once};
use crate::markdown::Style; use crate::markdown::Style;
@@ -142,8 +142,18 @@ fn collect_until<'a, const N: usize>(
first_token: Option<&Token<'a>>, first_token: Option<&Token<'a>>,
tokens: &mut &[Token<'a>], tokens: &mut &[Token<'a>],
pattern: impl FnMut(&[Token<'a>; N]) -> bool, pattern: impl FnMut(&[Token<'a>; N]) -> bool,
) -> Span<'a> { ) -> Span<'a>
let split_at = match tokens.array_windows::<N>().position(pattern) { where
// &[T; N]: TryFrom<&[T]>
for<'b> &'b [Token<'a>; N]: TryFrom<&'b [Token<'a>]>,
{
let mut windows = tokens.windows(N).map(|slice| {
<&[Token<'a>; N]>::try_from(slice)
.ok()
.expect("`windows` promises to return slices of length N")
});
let split_at = match windows.position(pattern) {
Some(i) => i + N, Some(i) => i + N,
None => tokens.len(), // consume everything None => tokens.len(), // consume everything
}; };
@@ -151,8 +161,8 @@ fn collect_until<'a, const N: usize>(
let (consume, keep) = tokens.split_at(split_at); let (consume, keep) = tokens.split_at(split_at);
*tokens = keep; *tokens = keep;
first_token once(first_token)
.into_iter() .flatten()
.chain(consume) .chain(consume)
.fold(Span::empty(), |span: Span<'_>, token| { .fold(Span::empty(), |span: Span<'_>, token| {
span.try_merge(&token.span).unwrap() span.try_merge(&token.span).unwrap()