feat: Knopf für das Seitenverhältnis-Schloss der Auswahl (Issue #29)

Im Tablet-Betrieb gibt es keine Umschalttaste. Beim Auswahlwerkzeug
steht unter den Stiftfarben jetzt ein Schloss: 🔒 skaliert gleichmäßig,
🔓 frei. Vorgabe ist gesperrt, sobald ein Bild ausgewählt ist, sonst
frei. Das Umschalten gilt für die aktuelle Auswahl; eine neue Auswahl
beginnt wieder mit der Vorgabe. Shift sperrt am Rechner zusätzlich.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 21:13:15 +02:00
co-authored by Claude Opus 5.5
parent 98b78d29e6
commit 1a98c9b8bb
4 changed files with 135 additions and 3 deletions
+2
View File
@@ -286,6 +286,8 @@ pub const EN: &[(&str, &str)] = &[
("Seite {number}", "Page {number}"), ("Seite {number}", "Page {number}"),
("Seiten ({count})", "Pages ({count})"), ("Seiten ({count})", "Pages ({count})"),
("Seiten durchgehend scrollen", "Scroll pages continuously"), ("Seiten durchgehend scrollen", "Scroll pages continuously"),
("Seitenverhältnis frei – tippen zum Sperren", "Aspect ratio free – tap to lock"),
("Seitenverhältnis gesperrt – tippen zum Freigeben", "Aspect ratio locked – tap to unlock"),
("Serialisierungsfehler: {e}", "Serialization error: {e}"), ("Serialisierungsfehler: {e}", "Serialization error: {e}"),
("Server-Einstellungen gespeichert", "Server settings saved"), ("Server-Einstellungen gespeichert", "Server settings saved"),
("Server:", "Server:"), ("Server:", "Server:"),
+10 -2
View File
@@ -651,8 +651,8 @@ fn selection_press(
_, _,
) => { ) => {
let initial_strokes = page.get_strokes(&selection_state.selected_ids); let initial_strokes = page.get_strokes(&selection_state.selected_ids);
// Bilder nicht verzerren (Issue #29); Shift hält das Verhältnis auch bei Strichen // Schloss der Auswahl (Bilder: an, Issue #29); Shift sperrt zusätzlich
let keep_aspect = shift || initial_strokes.iter().any(|s| s.is_image()); let keep_aspect = shift || selection_state.keeps_aspect(page);
DragMode::Scale { DragMode::Scale {
fixed_corner_page, fixed_corner_page,
start_pos_page, start_pos_page,
@@ -3101,6 +3101,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 3. Toolbar & Widgets rendern // 3. Toolbar & Widgets rendern
updater.update(); updater.update();
ui_state.page_view = Some(page_view(&canvas, window.scale_factor() as f32)); ui_state.page_view = Some(page_view(&canvas, window.scale_factor() as f32));
ui_state.selection_aspect_lock = (!selection_state.is_empty())
.then(|| selection_state.keeps_aspect(notebook.current_page()));
ui_state.restorable_page = last_deleted_page ui_state.restorable_page = last_deleted_page
.as_ref() .as_ref()
.filter(|(id, _, _)| *id == manifest.id) .filter(|(id, _, _)| *id == manifest.id)
@@ -3201,6 +3203,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
UiAction::ToggleAspectLock => {
if !selection_state.is_empty() {
selection_state.toggle_aspect_lock(notebook.current_page());
window.request_redraw();
}
}
UiAction::RotateSelected => { UiAction::RotateSelected => {
if !selection_state.is_empty() if !selection_state.is_empty()
&& selection_state.rotate_selected_90_deg(notebook.current_page_mut()) { && selection_state.rotate_selected_90_deg(notebook.current_page_mut()) {
+65 -1
View File
@@ -36,7 +36,7 @@ pub enum DragMode {
start_pos_page: Vec2, start_pos_page: Vec2,
initial_box_size: Vec2, initial_box_size: Vec2,
initial_strokes: Vec<Stroke>, initial_strokes: Vec<Stroke>,
/// Seitenverhältnis beibehalten (Bilder in der Auswahl oder Shift gedrückt) /// Seitenverhältnis beibehalten (Schloss der Auswahl oder Shift gedrückt)
keep_aspect: bool, keep_aspect: bool,
}, },
} }
@@ -89,6 +89,9 @@ pub struct SelectionState {
pub selected_ids: Vec<u64>, pub selected_ids: Vec<u64>,
pub drag_mode: DragMode, pub drag_mode: DragMode,
pub clipboard: Vec<Stroke>, pub clipboard: Vec<Stroke>,
/// Von Hand umgeschaltetes Seitenverhältnis-Schloss und die Auswahl (IDs), für die es
/// gilt. Ändert sich die Auswahl, gilt wieder die Vorgabe (siehe [`Self::keeps_aspect`]).
aspect_lock: Option<(Vec<u64>, bool)>,
} }
impl SelectionState { impl SelectionState {
@@ -97,9 +100,28 @@ impl SelectionState {
selected_ids: Vec::new(), selected_ids: Vec::new(),
drag_mode: DragMode::None, drag_mode: DragMode::None,
clipboard: Vec::new(), clipboard: Vec::new(),
aspect_lock: None,
} }
} }
/// Ob Skalieren an den Eckgriffen das Seitenverhältnis der Auswahl beibehält. Vorgabe:
/// ja, sobald ein Bild ausgewählt ist (Issue #29); per Knopf für diese Auswahl umschaltbar.
pub fn keeps_aspect(&self, page: &Page) -> bool {
match &self.aspect_lock {
Some((ids, locked)) if *ids == self.selected_ids => *locked,
_ => page
.get_strokes(&self.selected_ids)
.iter()
.any(|s| s.is_image()),
}
}
/// Schaltet das Seitenverhältnis-Schloss der aktuellen Auswahl um.
pub fn toggle_aspect_lock(&mut self, page: &Page) {
let locked = !self.keeps_aspect(page);
self.aspect_lock = Some((self.selected_ids.clone(), locked));
}
/// Prüft, ob aktuell Objekte ausgewählt sind. /// Prüft, ob aktuell Objekte ausgewählt sind.
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.selected_ids.is_empty() self.selected_ids.is_empty()
@@ -577,6 +599,48 @@ mod tests {
); );
} }
#[test]
fn test_aspect_lock_defaults_to_images_and_toggles_per_selection() {
let mut page = Page::new(1, PageBackground::Blank);
let corners = [
Vec2::new(0.0, 0.0),
Vec2::new(100.0, 0.0),
Vec2::new(100.0, 50.0),
Vec2::new(0.0, 50.0),
];
page.add_stroke(Stroke::new_image(1, "a.png".into(), corners));
page.add_stroke(make_test_stroke(2, 200.0, 200.0, [0.1, 0.1, 0.1, 1.0]));
let mut sel = SelectionState::new();
// Vorgabe: Bilder gesperrt, Striche frei
sel.select_single(1);
assert!(sel.keeps_aspect(&page));
sel.select_single(2);
assert!(!sel.keeps_aspect(&page));
// Umschalten gilt für diese Auswahl
sel.toggle_aspect_lock(&page);
assert!(sel.keeps_aspect(&page));
sel.select_single(1);
sel.toggle_aspect_lock(&page);
assert!(!sel.keeps_aspect(&page));
sel.toggle_aspect_lock(&page);
assert!(sel.keeps_aspect(&page));
sel.toggle_aspect_lock(&page);
// Neue Auswahl: wieder die Vorgabe
sel.select_multiple(vec![1, 2]);
assert!(sel.keeps_aspect(&page));
sel.select_single(1);
assert!(
!sel.keeps_aspect(&page),
"gleiche Auswahl behält das Umschalten"
);
sel.clear();
sel.select_single(1);
assert!(!sel.keeps_aspect(&page));
}
#[test] #[test]
fn test_recolor_ignores_images() { fn test_recolor_ignores_images() {
let mut page = Page::new(1, PageBackground::Blank); let mut page = Page::new(1, PageBackground::Blank);
+58
View File
@@ -255,6 +255,8 @@ pub enum UiAction {
/// Zwischenablage an dieser Stelle der aktuellen Seite einfügen (Kontextmenü) /// Zwischenablage an dieser Stelle der aktuellen Seite einfügen (Kontextmenü)
PasteAt(glam::Vec2), PasteAt(glam::Vec2),
RotateSelected, RotateSelected,
/// Seitenverhältnis beim Skalieren der Auswahl sperren bzw. freigeben
ToggleAspectLock,
// Dokumenten- & Notizbuch-Verwaltung // Dokumenten- & Notizbuch-Verwaltung
OpenNotebook(String), OpenNotebook(String),
/// Neues Notizbuch im Ordner `folder` anlegen und öffnen /// Neues Notizbuch im Ordner `folder` anlegen und öffnen
@@ -410,6 +412,8 @@ pub struct UiState {
pub confirm_delete_page: bool, pub confirm_delete_page: bool,
/// Seitenzahl der zuletzt gelöschten Seite, solange sie wiederhergestellt werden kann /// Seitenzahl der zuletzt gelöschten Seite, solange sie wiederhergestellt werden kann
pub restorable_page: Option<usize>, pub restorable_page: Option<usize>,
/// Seitenverhältnis-Schloss der Auswahl (`None`: nichts ausgewählt)
pub selection_aspect_lock: Option<bool>,
/// Fehler beim Flushen vor dem Beenden /// Fehler beim Flushen vor dem Beenden
pub shutdown_error: Option<crate::autosave::AutosaveError>, pub shutdown_error: Option<crate::autosave::AutosaveError>,
/// Fensterseite der Werkzeugleiste /// Fensterseite der Werkzeugleiste
@@ -508,6 +512,7 @@ impl Default for UiState {
pressed_stylus_button: None, pressed_stylus_button: None,
confirm_delete_page: false, confirm_delete_page: false,
restorable_page: None, restorable_page: None,
selection_aspect_lock: None,
shutdown_error: None, shutdown_error: None,
sidebar_side: SidebarSide::Left, sidebar_side: SidebarSide::Left,
language: None, language: None,
@@ -3045,6 +3050,30 @@ pub fn render_ui(
used += btn + spacing; used += btn + spacing;
} }
} }
// Auswahl: unter den 4 Stiftfarben ist Platz für das Schloss des
// Seitenverhältnisses beim Skalieren (Bilder: gesperrt, Issue #29)
if state.active_tool == ActiveTool::Select {
let locked = state.selection_aspect_lock;
let (icon, tooltip) = if locked == Some(true) {
("🔒", t!("Seitenverhältnis gesperrt – tippen zum Freigeben"))
} else {
("🔓", t!("Seitenverhältnis frei – tippen zum Sperren"))
};
if sidebar_button(
ui,
swatch,
SidebarIcon::Glyph(icon),
locked == Some(true),
locked.is_some(),
None,
tooltip,
)
.clicked()
{
actions.push(UiAction::ToggleAspectLock);
}
used += swatch + spacing;
}
let reserved = let reserved =
UiState::MAX_PALETTE_LEN as f32 * (swatch + spacing); UiState::MAX_PALETTE_LEN as f32 * (swatch + spacing);
if reserved > used { if reserved > used {
@@ -5189,6 +5218,35 @@ mod tests {
} }
} }
#[test]
fn test_aspect_lock_button_only_with_selection() {
// Den Knopf in der Leiste des Auswahlwerkzeugs suchen: ohne Auswahl gesperrt
let notebook = Notebook::new();
let toggles = |lock: Option<bool>| {
let mut probe = UiState::new();
probe.active_tool = ActiveTool::Select;
render_frames(&mut probe, &notebook, 10);
let bar = probe.toolbar_rect.unwrap();
let mut found = 0;
let mut y = bar.min.y + 4.0;
while y < bar.max.y {
let mut state = UiState::new();
state.active_tool = ActiveTool::Select;
state.selection_aspect_lock = lock;
let ctx = render_frames(&mut state, &notebook, 10);
let actions = click_at(&ctx, &mut state, &notebook, egui::pos2(bar.center().x, y));
if actions.contains(&UiAction::ToggleAspectLock) {
found += 1;
}
y += 4.0;
}
found
};
assert_eq!(toggles(None), 0);
assert!(toggles(Some(true)) > 0);
assert!(toggles(Some(false)) > 0);
}
#[test] #[test]
fn test_sidebar_fits_window_height_for_every_tool() { fn test_sidebar_fits_window_height_for_every_tool() {
let notebook = Notebook::new(); let notebook = Notebook::new();