diff --git a/README.md b/README.md index 46d7324..cd74bbe 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11). - **Eigene Farben:** Jede Farbe in der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – gewählte Farbe erneut antippen, eine Vorgabe oder mit 🎨 einen freien Farbton wählen. Die Leiste merkt sich die Farben. Am Bildschirm erscheinen sie genauso wie im PDF- und Bildexport. - **Glättung in zwei Stufen:** Während des Schreibens glättet ein 1€-Filter das Zittern, die Linie erscheint sofort. Beim Absetzen wird der Strich nachbearbeitet: Ecken (Knicke über 65°) bleiben erhalten, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund. - **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen). -- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Stiftrückseite und Seitentaste des Stifts radieren ebenfalls. +- **Tasten am Stift** im ☰-Menü belegen: Radieren (Standard), Auswählen, Textmarker, Stift oder nichts – gilt, solange die Taste gedrückt ist. Jede Taste erscheint dort nach dem ersten Drücken. +- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Die Stiftrückseite radiert ebenfalls. - **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade. ### Formerkennung diff --git a/src/main.rs b/src/main.rs index 961f710..3eb0c20 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,11 +30,39 @@ use ink_notes::platform::{self, DisplayBackend}; use ink_notes::selection::{ render_selection_overlay, DragMode, SelectionHandleHit, SelectionState, }; -use ink_notes::settings::{SidebarSide, UiSettings}; +use ink_notes::settings::{ + SidebarSide, StylusAction, StylusButton, UiSettings, MAX_STYLUS_BUTTONS, +}; use ink_notes::storage::{self, NotebookManifest}; use ink_notes::ui::{self, ActiveTool, UiAction, UiState}; use ink_notes::updater::UpdaterController; +/// Wählt `tool` und stellt Farbe und Stärke des Live-Strichs passend ein. +fn activate_tool(tool: ActiveTool, ui_state: &mut UiState, canvas: &mut CanvasState) { + ui_state.active_tool = tool; + match tool { + ActiveTool::Pen | ActiveTool::Shape => { + canvas.set_stroke_color(ui_state.pen_color); + canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm); + } + ActiveTool::Highlighter => { + canvas.set_stroke_color(ui_state.highlighter_color); + canvas.set_stroke_width_mm(ui_state.highlighter_stroke_width_mm); + } + ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {} + } +} + +/// Kennung einer Stifttaste, die über Programmstarts hinweg gleich bleibt (die Tablet- +/// Schnittstelle liefert nur eine undurchsichtige, aber vergleichbare Kennung). +fn stylus_button_key(id: &octotablet::tool::ButtonID) -> u64 { + use std::hash::{Hash, Hasher}; + // DefaultHasher::new() verwendet feste Schlüssel: gleiche Taste, gleicher Wert + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + id.hash(&mut hasher); + hasher.finish() +} + /// Liest Notizbücher und Ordner für den Dokumenten-Manager neu ein. fn refresh_notebooks(base_dir: &Path, ui_state: &mut UiState) { ui_state.available_notebooks = storage::list_notebooks(base_dir); @@ -1107,6 +1135,7 @@ fn main() -> Result<(), Box> { ui_state.pressure_curve = ui_settings.pressure_curve; ui_state.continuous_scroll = ui_settings.continuous_scroll; ui_state.pen_presets = ui_settings.pen_presets; + ui_state.stylus_buttons = ui_settings.stylus_buttons.clone(); ui_state.select_pen(ui_settings.active_pen); ui_state.highlighter_colors = ui_settings.highlighter_colors; ui_state.select_highlighter_color(ui_settings.active_highlighter); @@ -1186,7 +1215,10 @@ fn main() -> Result<(), Box> { let mut is_stylus_selecting = false; let mut is_stylus_in_proximity = false; let mut stylus_tool_id: Option = None; - let mut stylus_barrel_pressed = false; + // Gedrückte Stifttaste (Kennung, Belegung) und das Werkzeug, das während eines Strichs mit + // einer Werkzeug-Taste (Auswählen, Textmarker, Stift) vertreten wird + let mut stylus_held: Option<(u64, StylusAction)> = None; + let mut tool_before_button: Option = None; let mut stylus_screen_pos = (0.0_f32, 0.0_f32); // Stift hat auf der UI aufgesetzt: alle Ereignisse bis zum Abheben gehen an egui let mut stylus_ui_capture = false; @@ -1357,23 +1389,45 @@ fn main() -> Result<(), Box> { selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state); window.request_redraw(); } + if let Some(tool) = tool_before_button.take() { + activate_tool(tool, &mut ui_state, &mut canvas); + } + stylus_held = None; + ui_state.pressed_stylus_button = None; } } - octotablet::events::ToolEvent::Button { pressed, .. } => { - stylus_barrel_pressed = pressed; - if pressed && is_stylus_drawing { - is_stylus_drawing = false; - is_drawing = false; - canvas.cancel_stroke(); - is_erasing = true; - window.request_redraw(); - } else if !pressed - && is_erasing - && !tool_is_hardware_eraser - && ui_state.active_tool != ActiveTool::Eraser - { - is_erasing = false; + octotablet::events::ToolEvent::Button { button_id, pressed } => { + // Tasten werden beim ersten Drücken gelernt (Standard: Radieren) + let key = stylus_button_key(&button_id); + let mut index = ui_state.stylus_buttons.iter().position(|b| b.id == key); + if index.is_none() && pressed && ui_state.stylus_buttons.len() < MAX_STYLUS_BUTTONS { + ui_state.stylus_buttons.push(StylusButton { id: key, action: StylusAction::Erase }); + index = Some(ui_state.stylus_buttons.len() - 1); + ui_settings.stylus_buttons = ui_state.stylus_buttons.clone(); + save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); } + let action = index.map_or(StylusAction::Erase, |i| ui_state.stylus_buttons[i].action); + if pressed { + stylus_held = Some((key, action)); + ui_state.pressed_stylus_button = index; + if action == StylusAction::Erase && is_stylus_drawing { + is_stylus_drawing = false; + is_drawing = false; + canvas.cancel_stroke(); + is_erasing = true; + } + } else if stylus_held.is_some_and(|(held, _)| held == key) { + stylus_held = None; + ui_state.pressed_stylus_button = None; + if action == StylusAction::Erase + && is_erasing + && !tool_is_hardware_eraser + && ui_state.active_tool != ActiveTool::Eraser + { + is_erasing = false; + } + } + window.request_redraw(); } octotablet::events::ToolEvent::Pose(pose) => { is_stylus_in_proximity = true; @@ -1471,8 +1525,22 @@ fn main() -> Result<(), Box> { } focus_page_at!((sx, sy)); + // Werkzeug-Taste gedrückt: für diesen Strich dieses Werkzeug + let button_action = stylus_held.map(|(_, action)| action); + let button_tool = match button_action { + Some(StylusAction::Select) => Some(ActiveTool::Select), + Some(StylusAction::Highlighter) => Some(ActiveTool::Highlighter), + Some(StylusAction::Pen) => Some(ActiveTool::Pen), + _ => None, + }; + if let Some(tool) = button_tool + .filter(|&t| !tool_is_hardware_eraser && t != ui_state.active_tool) + { + tool_before_button = Some(ui_state.active_tool); + activate_tool(tool, &mut ui_state, &mut canvas); + } let should_erase = tool_is_hardware_eraser - || stylus_barrel_pressed + || button_action == Some(StylusAction::Erase) || ui_state.active_tool == ActiveTool::Eraser; if !should_erase && ui_state.active_tool == ActiveTool::Text { @@ -1550,6 +1618,7 @@ fn main() -> Result<(), Box> { window.request_redraw(); } } + let restore_tool = tool_before_button.take(); if is_stylus_drawing { is_stylus_drawing = false; @@ -1584,6 +1653,11 @@ fn main() -> Result<(), Box> { snapped_shape, ); } + // Nach einem Strich mit Werkzeug-Taste wieder das gewählte Werkzeug + if let Some(tool) = restore_tool { + activate_tool(tool, &mut ui_state, &mut canvas); + window.request_redraw(); + } } _ => {} } @@ -2731,22 +2805,7 @@ fn main() -> Result<(), Box> { for action in actions { match action { UiAction::SetTool(tool) => { - ui_state.active_tool = tool; - match tool { - ActiveTool::Pen => { - canvas.set_stroke_color(ui_state.pen_color); - canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm); - } - ActiveTool::Highlighter => { - canvas.set_stroke_color(ui_state.highlighter_color); - canvas.set_stroke_width_mm(ui_state.highlighter_stroke_width_mm); - } - ActiveTool::Shape => { - canvas.set_stroke_color(ui_state.pen_color); - canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm); - } - ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {} - } + activate_tool(tool, &mut ui_state, &mut canvas); update_window_title(&window, ¬ebook, &manifest, tool, autosave.status()); window.request_redraw(); } @@ -3433,6 +3492,10 @@ fn main() -> Result<(), Box> { save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); window.request_redraw(); } + UiAction::SaveStylusButtons => { + ui_settings.stylus_buttons = ui_state.stylus_buttons.clone(); + save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); + } UiAction::SavePressureCurve(curve) => { ui_settings.pressure_curve = curve; save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); diff --git a/src/settings.rs b/src/settings.rs index fe82493..4d7cb9b 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -150,6 +150,53 @@ fn sanitized_color(color: [f32; 4], fallback: [f32; 4]) -> [f32; 4] { out } +/// Was eine Taste am Stift bewirkt, solange sie gedrückt ist. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum StylusAction { + /// Radieren (Standard, wie bisher) + #[default] + Erase, + /// Auswählen und verschieben + Select, + /// Mit dem Textmarker schreiben + Highlighter, + /// Mit dem gewählten Stift schreiben (z. B. während der Textmarker aktiv ist) + Pen, + /// Taste nicht verwenden + Nothing, +} + +impl StylusAction { + pub const ALL: [StylusAction; 5] = [ + StylusAction::Erase, + StylusAction::Select, + StylusAction::Highlighter, + StylusAction::Pen, + StylusAction::Nothing, + ]; + + pub fn label(self) -> &'static str { + match self { + StylusAction::Erase => "Radieren", + StylusAction::Select => "Auswählen", + StylusAction::Highlighter => "Textmarker", + StylusAction::Pen => "Stift", + StylusAction::Nothing => "Nichts", + } + } +} + +/// Eine Taste am Stift: Kennung (aus der Tablet-Schnittstelle, beim ersten Drücken gelernt) +/// und ihre Belegung. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct StylusButton { + pub id: u64, + pub action: StylusAction, +} + +/// Höchstens so viele Stifttasten werden gemerkt. +pub const MAX_STYLUS_BUTTONS: usize = 4; + /// Benutzereinstellungen der Oberfläche. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -173,6 +220,8 @@ pub struct UiSettings { pub active_highlighter: usize, pub text_colors: [[f32; 4]; 5], pub active_text_color: usize, + /// Tasten am Stift in der Reihenfolge, in der sie zuerst gedrückt wurden + pub stylus_buttons: Vec, } impl Default for UiSettings { @@ -190,6 +239,7 @@ impl Default for UiSettings { active_highlighter: 0, text_colors: DEFAULT_TEXT_COLORS, active_text_color: 0, + stylus_buttons: Vec::new(), } } } @@ -237,6 +287,7 @@ impl UiSettings { settings.active_text_color = settings .active_text_color .min(settings.text_colors.len() - 1); + settings.stylus_buttons.truncate(MAX_STYLUS_BUTTONS); settings } @@ -277,6 +328,16 @@ mod tests { active_highlighter: 3, text_colors: [[0.3, 0.1, 0.6, 1.0]; 5], active_text_color: 4, + stylus_buttons: vec![ + StylusButton { + id: 17, + action: StylusAction::Select, + }, + StylusButton { + id: 42, + action: StylusAction::Nothing, + }, + ], }; settings.save_to(&path).unwrap(); assert_eq!(UiSettings::load_from(&path), settings); diff --git a/src/ui.rs b/src/ui.rs index fce4114..c56c662 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -288,6 +288,8 @@ pub enum UiAction { theme: PaperTheme, adapt_ink: bool, }, + /// Belegung der Stifttasten speichern (gilt sofort über `UiState::stylus_buttons`) + SaveStylusButtons, /// Druckkurve speichern (gilt während des Ziehens bereits über `UiState::pressure_curve`) SavePressureCurve(PressureCurve), /// Seiten durchgehend untereinander statt einzeln anzeigen (wird gespeichert) @@ -376,6 +378,9 @@ pub struct UiState { pub show_color_picker: bool, /// Kontextmenü nach langem Drücken (Einfügen an dieser Stelle) pub context_menu: Option, + /// Tasten am Stift und ihre Belegung; die gerade gedrückte (Index) wird hervorgehoben + pub stylus_buttons: Vec, + pub pressed_stylus_button: Option, /// Rückfrage „Seite löschen?“ im ☰-Menü ist offen pub confirm_delete_page: bool, /// Seitenzahl der zuletzt gelöschten Seite, solange sie wiederhergestellt werden kann @@ -471,6 +476,8 @@ impl Default for UiState { is_text_color_menu_open: false, show_color_picker: false, context_menu: None, + stylus_buttons: Vec::new(), + pressed_stylus_button: None, confirm_delete_page: false, restorable_page: None, shutdown_error: None, @@ -1635,6 +1642,70 @@ fn pattern_color_button( /// Schieberegler für die Druckkurve mit Vorschau der Kennlinie. Änderungen gelten sofort /// (`state.pressure_curve`); zurückgegeben wird die Kurve, sobald sie gespeichert werden soll /// (Regler losgelassen oder per Tastatur geändert). +/// Belegung der Tasten am Stift. Tasten erscheinen, sobald sie einmal gedrückt wurden; die +/// gerade gedrückte ist markiert. `true`, wenn sich etwas geändert hat. +fn stylus_buttons_editor(ui: &mut egui::Ui, state: &mut UiState) -> bool { + use crate::settings::StylusAction; + let mut changed = false; + ui.add_space(4.0); + ui.label(egui::RichText::new("Tasten am Stift").color(egui::Color32::from_gray(200))); + if state.stylus_buttons.is_empty() { + ui.label( + egui::RichText::new( + "Drücke eine Taste am Stift (über dem Bildschirm) – sie erscheint dann hier. \ + Bis dahin radieren alle Tasten.", + ) + .size(11.0) + .color(egui::Color32::from_gray(150)), + ); + return false; + } + let mut remove = None; + for i in 0..state.stylus_buttons.len() { + ui.horizontal(|ui| { + let pressed = state.pressed_stylus_button == Some(i); + let label = egui::RichText::new(format!("Taste {}", i + 1)); + ui.label(if pressed { + label.strong().color(egui::Color32::from_rgb(46, 204, 113)) + } else { + label + }) + .on_hover_text("Leuchtet grün, solange die Taste gedrückt ist"); + let current = state.stylus_buttons[i].action; + egui::ComboBox::from_id_source(("stylus_button", i)) + .selected_text(current.label()) + .width(130.0) + .show_ui(ui, |ui| { + for action in StylusAction::ALL { + if ui + .selectable_label(action == current, action.label()) + .clicked() + && action != current + { + state.stylus_buttons[i].action = action; + changed = true; + } + } + }); + if ui + .small_button("🗙") + .on_hover_text( + "Vergessen (erscheint beim nächsten Drücken wieder, dann mit Radieren)", + ) + .clicked() + { + remove = Some(i); + } + }); + } + if let Some(i) = remove { + state.stylus_buttons.remove(i); + state.pressed_stylus_button = None; + changed = true; + } + changed +} + fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option { let mut save = false; let mut track = |response: egui::Response| { @@ -3560,6 +3631,9 @@ pub fn render_ui( if let Some(curve) = pressure_curve_editor(ui, state) { actions.push(UiAction::SavePressureCurve(curve)); } + if stylus_buttons_editor(ui, state) { + actions.push(UiAction::SaveStylusButtons); + } section(ui, "WERKZEUGLEISTE"); ui.horizontal(|ui| { @@ -5295,6 +5369,43 @@ mod tests { actions } + #[test] + fn test_stylus_buttons_in_menu() { + use crate::settings::{StylusAction, StylusButton}; + let notebook = Notebook::new(); + let mut state = UiState::new(); + state.is_more_menu_open = true; + // Ohne gelernte Tasten: nur der Hinweis, keine Aktion + let ctx = egui::Context::default(); + let updater = crate::updater::UpdaterController::new(); + let shapes = ctx + .run(egui::RawInput::default(), |ctx| { + let _ = render_ui(ctx, &mut state, ¬ebook, AutosaveStatus::Saved, &updater); + }) + .shapes + .len(); + assert!(shapes > 0); + + // Mit zwei Tasten: beide Zeilen, die gedrückte hervorgehoben, ohne Fehler + state.stylus_buttons = vec![ + StylusButton { + id: 1, + action: StylusAction::Erase, + }, + StylusButton { + id: 2, + action: StylusAction::Select, + }, + ]; + state.pressed_stylus_button = Some(1); + let output = ctx.run(egui::RawInput::default(), |ctx| { + let actions = render_ui(ctx, &mut state, ¬ebook, AutosaveStatus::Saved, &updater); + assert!(!actions.contains(&UiAction::SaveStylusButtons)); + }); + assert!(!output.shapes.is_empty()); + assert_eq!(state.stylus_buttons.len(), 2); + } + #[test] fn test_long_press_detection() { let t0 = Instant::now();