feat(ui): einheitliche, deckende Dialoge und fingertaugliche Popover

- Dialoge (Notizbücher, Updates, Warnfenster) mit gemeinsamem deckendem
  Rahmen, ohne doppelte Titelleiste, zentriert neben der Werkzeugleiste;
  Schließen-Knopf 36 px, größere Knöpfe, Eingabefelder und Schieberegler
- Seitenauswahl als Popover neben dem Seitenknopf mit Kachelraster
  (56 × 48 px) und „Neue Seite anhängen“
- Immer nur ein Dialog oder Popover gleichzeitig offen (close_overlays);
  Seitenauswahl schließt bei Tippen daneben
- Stift-/Marker-Popover: Farbtupfer 30 px, Stärke-Vorschau 40 px hoch,
  breiterer Schieberegler, deckender Rahmen
- Auswahl-Überlagerung und Griff-Trefferprüfung rechnen mit der
  Display-Skalierung (lag bei Skalierung ≠ 1 neben den Strichen);
  Trefferradius der Griffe 18 Punkte für Finger

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 23:51:48 +02:00
co-authored by Claude Opus 5.5
parent 40eb9a666f
commit a77e6cde90
3 changed files with 628 additions and 639 deletions
+2 -2
View File
@@ -1180,7 +1180,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 2. Objektauswahl & Transformation im Select-Modus
if ui_state.active_tool == ActiveTool::Select {
let screen_vec = Vec2::new(sx, sy);
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec);
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec, egui_ctx.pixels_per_point());
match hit {
SelectionHandleHit::RotateHandle { center_page } => {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
@@ -1580,7 +1580,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
} else if ui_state.active_tool == ActiveTool::Select {
let screen_vec = Vec2::new(tx, ty);
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec);
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec, egui_ctx.pixels_per_point());
match hit {
SelectionHandleHit::RotateHandle { center_page } => {
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
+26 -10
View File
@@ -115,12 +115,15 @@ impl SelectionState {
Page::selection_aabb(&strokes)
}
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten durch.
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten
/// (physische Pixel) durch. Abstände und Trefferradien sind in logischen Punkten festgelegt
/// und werden mit `pixels_per_point` skaliert, damit sie zur gezeichneten Überlagerung passen.
pub fn hit_test_handles(
&self,
page: &Page,
canvas: &CanvasState,
screen_pos: Vec2,
pixels_per_point: f32,
) -> SelectionHandleHit {
let Some(aabb) = self.get_selection_aabb(page) else {
return SelectionHandleHit::None;
@@ -134,9 +137,10 @@ impl SelectionState {
let screen_center = (screen_min + screen_max) * 0.5;
let page_center = (aabb.min + aabb.max) * 0.5;
// 1. Drehgriff testen (26 px oberhalb des oberen Randes)
let rot_screen = Vec2::new(screen_center.x, screen_min.y - 26.0);
if (screen_pos - rot_screen).length() <= 14.0 {
// 1. Drehgriff testen (oberhalb des oberen Randes, wie gezeichnet)
let ppp = pixels_per_point;
let rot_screen = Vec2::new(screen_center.x, screen_min.y - ROTATE_HANDLE_OFFSET * ppp);
if (screen_pos - rot_screen).length() <= HANDLE_HIT_RADIUS * ppp {
return SelectionHandleHit::RotateHandle {
center_page: page_center,
};
@@ -164,7 +168,7 @@ impl SelectionState {
];
for i in 0..4 {
if (screen_pos - corners_screen[i]).length() <= 12.0 {
if (screen_pos - corners_screen[i]).length() <= HANDLE_HIT_RADIUS * ppp {
return SelectionHandleHit::CornerHandle {
corner_idx: i,
fixed_corner_page: fixed_corners_page[i],
@@ -273,6 +277,16 @@ impl SelectionState {
}
/// Rendert das Auswahl-Overlay (Bounding Box, Ecken, Dreh-Griff, Marquee Box) über egui.
/// Abstand des Drehgriffs über dem Auswahlrahmen (logische Punkte).
const ROTATE_HANDLE_OFFSET: f32 = 24.0;
/// Trefferradius der Griffe (logische Punkte), groß genug für Finger.
const HANDLE_HIT_RADIUS: f32 = 18.0;
/// Wandelt Fensterpixel (physisch) in egui-Koordinaten (logische Punkte) um.
fn to_ui(screen: [f32; 2], pixels_per_point: f32) -> egui::Pos2 {
egui::pos2(screen[0] / pixels_per_point, screen[1] / pixels_per_point)
}
pub fn render_selection_overlay(
ctx: &egui::Context,
selection: &SelectionState,
@@ -283,6 +297,8 @@ pub fn render_selection_overlay(
egui::Order::Foreground,
egui::Id::new("selection_overlay_layer"),
));
// Fensterpixel sind physisch, egui zeichnet in logischen Punkten
let ppp = ctx.pixels_per_point();
// 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box)
if let DragMode::BoxSelect {
@@ -290,8 +306,8 @@ pub fn render_selection_overlay(
current_page,
} = selection.drag_mode
{
let p1 = canvas.page_to_screen(start_page);
let p2 = canvas.page_to_screen(current_page);
let p1 = to_ui(canvas.page_to_screen(start_page), ppp);
let p2 = to_ui(canvas.page_to_screen(current_page), ppp);
let min_x = p1[0].min(p2[0]);
let min_y = p1[1].min(p2[1]);
let max_x = p1[0].max(p2[0]);
@@ -321,8 +337,8 @@ pub fn render_selection_overlay(
return;
};
let p_min = canvas.page_to_screen(aabb.min);
let p_max = canvas.page_to_screen(aabb.max);
let p_min = to_ui(canvas.page_to_screen(aabb.min), ppp);
let p_max = to_ui(canvas.page_to_screen(aabb.max), ppp);
let screen_min = egui::pos2(p_min[0].min(p_max[0]), p_min[1].min(p_max[1]));
let screen_max = egui::pos2(p_min[0].max(p_max[0]), p_min[1].max(p_max[1]));
@@ -338,7 +354,7 @@ pub fn render_selection_overlay(
// Dreh-Stängel (Stalk) und Drehgriff
let top_center = egui::pos2(rect.center().x, rect.min.y);
let rot_handle = egui::pos2(rect.center().x, rect.min.y - 24.0_f32);
let rot_handle = egui::pos2(rect.center().x, rect.min.y - ROTATE_HANDLE_OFFSET);
painter.line_segment(
[top_center, rot_handle],
+356 -383
View File
@@ -228,6 +228,17 @@ impl UiState {
/// Abstand der Werkzeugleiste zum Fensterrand.
pub const SIDEBAR_MARGIN: f32 = 10.0;
/// Schließt alle Popover und Dialoge. Wird vor dem Öffnen eines neuen aufgerufen, damit
/// sich nie mehrere überlagern.
pub fn close_overlays(&mut self) {
self.is_pen_menu_open = false;
self.is_highlighter_menu_open = false;
self.is_more_menu_open = false;
self.show_page_jump_popup = false;
self.is_document_menu_open = false;
self.is_update_dialog_open = false;
}
/// Länge der längsten Farbpalette; so viel Platz hält die Leiste immer frei.
pub const MAX_PALETTE_LEN: usize = 5;
@@ -363,47 +374,6 @@ impl UiState {
}
}
/// Stilvoller einfacher Werkzeug-Button (für den Radierer).
fn tool_pill_button(
ui: &mut egui::Ui,
is_active: bool,
text: &str,
tooltip: &str,
) -> egui::Response {
let (bg, text_color, stroke) = if is_active {
(
egui::Color32::from_rgba_premultiplied(45, 80, 145, 230),
egui::Color32::WHITE,
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_unmultiplied(110, 165, 255, 190),
),
)
} else {
(
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 8),
egui::Color32::from_gray(215),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 15),
),
)
};
let button = egui::Button::new(
egui::RichText::new(text)
.color(text_color)
.size(13.0)
.strong(),
)
.fill(bg)
.stroke(stroke)
.rounding(egui::Rounding::same(8.0))
.min_size(egui::vec2(0.0, 28.0));
ui.add(button).on_hover_text(tooltip)
}
/// Dicken-Preset-Button mit visueller Linien-Vorschau in Originalfarbe und Proportion.
fn stroke_preview_button(
ui: &mut egui::Ui,
@@ -412,7 +382,7 @@ fn stroke_preview_button(
label_text: &str,
color: [f32; 4],
) -> egui::Response {
let size = egui::vec2(66.0, 34.0);
let size = egui::vec2(66.0, 40.0);
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
if ui.is_rect_visible(rect) {
@@ -482,46 +452,6 @@ fn stroke_preview_button(
response.on_hover_text(format!("Stärke auf {} setzen", label_text))
}
/// Abgerundeter Aktions-Button (Undo, Redo, Navigation, PDF).
fn action_pill_button(
ui: &mut egui::Ui,
enabled: bool,
text: &str,
tooltip: &str,
) -> egui::Response {
let text_color = if enabled {
egui::Color32::from_gray(230)
} else {
egui::Color32::from_gray(105)
};
let (bg, stroke) = if enabled {
(
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 10),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 22),
),
)
} else {
(
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 3),
egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 8),
),
)
};
let button = egui::Button::new(egui::RichText::new(text).color(text_color).size(12.5))
.fill(bg)
.stroke(stroke)
.rounding(egui::Rounding::same(7.0))
.min_size(egui::vec2(0.0, 26.0));
ui.add_enabled(enabled, button).on_hover_text(tooltip)
}
/// Inhalt eines Symbol-Knopfs der Werkzeugleiste.
#[derive(Clone, Copy)]
enum SidebarIcon<'a> {
@@ -767,6 +697,116 @@ fn color_circle_swatch(
response.on_hover_text(tooltip)
}
/// Spalten der Seitenauswahl.
const PAGE_PICKER_COLUMNS: usize = 4;
/// Größe einer Kachel der Seitenauswahl.
const PAGE_TILE: egui::Vec2 = egui::vec2(56.0, 48.0);
/// Rahmen der Popover neben der Werkzeugleiste.
fn popover_frame() -> egui::Frame {
egui::Frame::none()
.fill(egui::Color32::from_rgb(30, 30, 34))
.rounding(egui::Rounding::same(12.0))
.stroke(egui::Stroke::new(
1.0_f32,
egui::Color32::from_white_alpha(40),
))
.inner_margin(egui::Margin::symmetric(14.0, 12.0))
.shadow(egui::epaint::Shadow {
offset: egui::vec2(0.0, 6.0),
blur: 16.0,
spread: 0.0,
color: egui::Color32::from_black_alpha(120),
})
}
/// Deckender, einheitlicher Rahmen für Dialogfenster (`danger` = rote Warnfenster).
fn dialog_frame(danger: bool) -> egui::Frame {
let (fill, stroke) = if danger {
(
egui::Color32::from_rgb(44, 24, 26),
egui::Stroke::new(1.5_f32, egui::Color32::from_rgb(235, 87, 87)),
)
} else {
(
egui::Color32::from_rgb(30, 30, 34),
egui::Stroke::new(1.0_f32, egui::Color32::from_white_alpha(40)),
)
};
egui::Frame::none()
.fill(fill)
.rounding(egui::Rounding::same(14.0))
.stroke(stroke)
.inner_margin(egui::Margin::symmetric(20.0, 16.0))
.shadow(egui::epaint::Shadow {
offset: egui::vec2(0.0, 8.0),
blur: 24.0,
spread: 0.0,
color: egui::Color32::from_black_alpha(160),
})
}
/// Dialogfenster ohne egui-Titelleiste (der Titel steht in [`dialog_header`]), zentriert über
/// dem freien Bereich neben der Werkzeugleiste. Warnfenster sitzen in der Bildschirmmitte.
fn dialog_window(title: &'static str, state: &UiState, danger: bool) -> egui::Window<'static> {
let shift = match state.sidebar_side {
SidebarSide::Left => state.sidebar_reserved_width() / 2.0,
SidebarSide::Right => -state.sidebar_reserved_width() / 2.0,
};
let (align, y) = if danger {
(egui::Align2::CENTER_CENTER, 0.0)
} else {
(egui::Align2::CENTER_TOP, 60.0)
};
egui::Window::new(title)
.title_bar(false)
.collapsible(false)
.resizable(false)
.anchor(align, egui::vec2(shift, y))
.frame(dialog_frame(danger))
}
/// Kopfzeile eines Dialogs mit Titel und optionalem Schließen-Knopf.
/// Gibt `true` zurück, wenn „Schließen“ getippt wurde.
fn dialog_header(ui: &mut egui::Ui, title: &str, closable: bool) -> bool {
let mut close = false;
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(title)
.strong()
.size(16.0)
.color(egui::Color32::WHITE),
);
if closable {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
close = close_button(ui).clicked();
});
}
});
ui.add_space(4.0);
ui.separator();
ui.add_space(6.0);
close
}
/// Schließen-Knopf mit fingertauglicher Trefferfläche.
fn close_button(ui: &mut egui::Ui) -> egui::Response {
ui.add_sized(
[36.0, 36.0],
egui::Button::new(egui::RichText::new("🗙").size(16.0)).frame(false),
)
.on_hover_text("Schließen (Esc)")
}
/// Größere Knöpfe, Eingabefelder und Schieberegler für Finger und Stift.
fn touch_friendly_spacing(ui: &mut egui::Ui) {
let spacing = ui.spacing_mut();
spacing.button_padding = egui::vec2(12.0, 8.0);
spacing.interact_size = egui::vec2(40.0, 36.0);
spacing.item_spacing = egui::vec2(8.0, 8.0);
spacing.slider_width = 200.0;
}
/// Prüft, ob ein Bildschirmpunkt (physische Pixel) auf einem UI-Element liegt.
///
/// egui arbeitet in logischen Punkten; Touch- und Stift-Positionen kommen in physischen
@@ -802,6 +842,7 @@ pub fn render_ui(
let mut pen_btn_rect = None;
let mut high_btn_rect = None;
let mut more_btn_rect = None;
let mut page_btn_rect = None;
// 1. Senkrechte Werkzeugleiste, seitlich oben angedockt. Bewusst nicht vertikal
// zentriert: sonst würden alle Knöpfe springen, sobald sich die Höhe ändert.
@@ -872,11 +913,10 @@ pub fn render_ui(
)
.clicked()
{
state.is_document_menu_open = !state.is_document_menu_open;
state.is_pen_menu_open = false;
state.is_highlighter_menu_open = false;
state.is_more_menu_open = false;
if state.is_document_menu_open {
let open = !state.is_document_menu_open;
state.close_overlays();
state.is_document_menu_open = open;
if open {
actions.push(UiAction::RefreshNotebooks);
}
}
@@ -910,7 +950,9 @@ pub fn render_ui(
state.pen_stroke_width_mm,
));
} else {
state.is_pen_menu_open = !state.is_pen_menu_open;
let open = !state.is_pen_menu_open;
state.close_overlays();
state.is_pen_menu_open = open;
}
}
@@ -940,8 +982,9 @@ pub fn render_ui(
state.highlighter_stroke_width_mm,
));
} else {
state.is_highlighter_menu_open =
!state.is_highlighter_menu_open;
let open = !state.is_highlighter_menu_open;
state.close_overlays();
state.is_highlighter_menu_open = open;
}
}
@@ -1098,7 +1141,7 @@ pub fn render_ui(
notebook.current_page_index + 1,
notebook.pages.len()
);
if sidebar_button(
let page_resp = sidebar_button(
ui,
btn,
SidebarIcon::Text(&page_text),
@@ -1106,10 +1149,12 @@ pub fn render_ui(
true,
None,
"Seite wählen",
)
.clicked()
{
state.show_page_jump_popup = !state.show_page_jump_popup;
);
page_btn_rect = Some(page_resp.rect);
if page_resp.clicked() {
let open = !state.show_page_jump_popup;
state.close_overlays();
state.show_page_jump_popup = open;
}
let is_last = notebook.current_page_index + 1 >= notebook.pages.len();
if sidebar_button(
@@ -1166,9 +1211,9 @@ pub fn render_ui(
);
}
if more_resp.clicked() {
state.is_more_menu_open = !state.is_more_menu_open;
state.is_pen_menu_open = false;
state.is_highlighter_menu_open = false;
let open = !state.is_more_menu_open;
state.close_overlays();
state.is_more_menu_open = open;
}
});
});
@@ -1193,22 +1238,9 @@ pub fn render_ui(
.constrain(true)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
let frame = egui::Frame::none()
.fill(egui::Color32::from_rgba_premultiplied(24, 24, 28, 235)) // Milchglas-Look
.rounding(egui::Rounding::same(12.0))
.stroke(egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30),
))
.inner_margin(egui::Margin::symmetric(14.0, 12.0))
.shadow(egui::epaint::Shadow {
offset: egui::vec2(0.0, 6.0),
blur: 16.0,
spread: 0.0,
color: egui::Color32::from_black_alpha(120),
})
.show(ui, |ui| {
ui.set_width(295.0);
let frame = popover_frame().show(ui, |ui| {
ui.set_width(320.0);
touch_friendly_spacing(ui);
// Titelzeile mit Schließen-Button
ui.horizontal(|ui| {
@@ -1221,15 +1253,7 @@ pub fn render_ui(
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if ui
.add(
egui::Button::new(
egui::RichText::new("🗙").size(12.0),
)
.frame(false),
)
.clicked()
{
if close_button(ui).clicked() {
state.is_pen_menu_open = false;
}
},
@@ -1252,8 +1276,7 @@ pub fn render_ui(
let is_sel = (state.pen_color[0] - color[0]).abs() < 0.05
&& (state.pen_color[1] - color[1]).abs() < 0.05
&& (state.pen_color[2] - color[2]).abs() < 0.05;
if color_circle_swatch(ui, color, is_sel, label, 22.0).clicked()
{
if color_circle_swatch(ui, color, is_sel, label, 30.0).clicked() {
state.pen_color = color;
actions.push(UiAction::SetColor(color));
}
@@ -1292,13 +1315,7 @@ pub fn render_ui(
ui.spacing_mut().item_spacing = egui::vec2(6.0, 0.0);
for (preset, label) in PEN_WIDTH_PRESETS {
let is_sel = (state.pen_stroke_width_mm - preset).abs() < 0.05;
if stroke_preview_button(
ui,
is_sel,
preset,
label,
state.pen_color,
)
if stroke_preview_button(ui, is_sel, preset, label, state.pen_color)
.clicked()
{
state.pen_stroke_width_mm = preset;
@@ -1344,22 +1361,9 @@ pub fn render_ui(
.constrain(true)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
let frame = egui::Frame::none()
.fill(egui::Color32::from_rgba_premultiplied(24, 24, 28, 235))
.rounding(egui::Rounding::same(12.0))
.stroke(egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30),
))
.inner_margin(egui::Margin::symmetric(14.0, 12.0))
.shadow(egui::epaint::Shadow {
offset: egui::vec2(0.0, 6.0),
blur: 16.0,
spread: 0.0,
color: egui::Color32::from_black_alpha(120),
})
.show(ui, |ui| {
ui.set_width(280.0);
let frame = popover_frame().show(ui, |ui| {
ui.set_width(320.0);
touch_friendly_spacing(ui);
ui.horizontal(|ui| {
ui.label(
@@ -1371,15 +1375,7 @@ pub fn render_ui(
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if ui
.add(
egui::Button::new(
egui::RichText::new("🗙").size(12.0),
)
.frame(false),
)
.clicked()
{
if close_button(ui).clicked() {
state.is_highlighter_menu_open = false;
}
},
@@ -1399,12 +1395,10 @@ pub fn render_ui(
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(8.0, 0.0);
for (color, label) in HIGHLIGHTER_COLOR_PRESETS {
let is_sel = (state.highlighter_color[0] - color[0]).abs()
< 0.05
let is_sel = (state.highlighter_color[0] - color[0]).abs() < 0.05
&& (state.highlighter_color[1] - color[1]).abs() < 0.05
&& (state.highlighter_color[2] - color[2]).abs() < 0.05;
if color_circle_swatch(ui, color, is_sel, label, 22.0).clicked()
{
if color_circle_swatch(ui, color, is_sel, label, 30.0).clicked() {
state.highlighter_color = color;
actions.push(UiAction::SetColor(color));
}
@@ -1508,23 +1502,10 @@ pub fn render_ui(
.constrain(true)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
egui::Frame::none()
.fill(egui::Color32::from_rgba_premultiplied(24, 24, 28, 235))
.rounding(egui::Rounding::same(12.0))
.stroke(egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30),
))
.inner_margin(egui::Margin::symmetric(14.0, 12.0))
.shadow(egui::epaint::Shadow {
offset: egui::vec2(0.0, 6.0),
blur: 16.0,
spread: 0.0,
color: egui::Color32::from_black_alpha(120),
})
popover_frame()
.show(ui, |ui| {
ui.set_width(250.0);
ui.spacing_mut().button_padding = egui::vec2(10.0, 7.0);
ui.set_width(260.0);
touch_friendly_spacing(ui);
let section = |ui: &mut egui::Ui, title: &str| {
ui.add_space(8.0);
ui.label(
@@ -1546,10 +1527,7 @@ pub fn render_ui(
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if ui
.add(egui::Button::new("🗙").frame(false))
.on_hover_text("Schließen")
.clicked()
if close_button(ui).clicked()
{
state.is_more_menu_open = false;
}
@@ -1688,9 +1666,75 @@ pub fn render_ui(
.add_sized([ui.available_width(), 30.0], egui::Button::new(update_label))
.clicked()
{
state.close_overlays();
state.is_update_dialog_open = true;
state.is_more_menu_open = false;
state.is_document_menu_open = false;
}
})
.response
.rect
});
popover_rect = Some(pop_response.inner);
}
}
// 2c. SEITENAUSWAHL als Kacheln neben dem Seitenknopf
if state.show_page_jump_popup {
if let Some(btn_rect) = page_btn_rect {
let (pos, pivot) = popover_placement(state.sidebar_side, area_response.inner, btn_rect);
let pop_response = egui::Area::new(egui::Id::new("page_picker_popover"))
.fixed_pos(pos)
.pivot(pivot)
.constrain(true)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
popover_frame()
.show(ui, |ui| {
// Breite = Kachelraster; sonst nimmt die Kopfzeile die ganze Fensterbreite ein
ui.set_width(PAGE_PICKER_COLUMNS as f32 * (PAGE_TILE.x + 8.0) - 8.0);
touch_friendly_spacing(ui);
let title = format!("Seiten ({})", notebook.pages.len());
if dialog_header(ui, &title, true) {
state.show_page_jump_popup = false;
}
egui::ScrollArea::vertical()
.max_height(320.0)
.show(ui, |ui| {
egui::Grid::new("page_picker_grid")
.spacing([8.0, 8.0])
.show(ui, |ui| {
for idx in 0..notebook.pages.len() {
let is_current = idx == notebook.current_page_index;
let label =
egui::RichText::new(format!("{}", idx + 1))
.size(16.0);
if ui
.add_sized(
[56.0, 48.0],
egui::SelectableLabel::new(
is_current, label,
),
)
.clicked()
{
actions.push(UiAction::GoToPage(idx));
state.show_page_jump_popup = false;
}
if (idx + 1) % PAGE_PICKER_COLUMNS == 0 {
ui.end_row();
}
}
});
});
ui.add_space(4.0);
if ui
.add_sized(
[ui.available_width(), 36.0],
egui::Button::new("➕ Neue Seite anhängen"),
)
.clicked()
{
actions.push(UiAction::AddPage);
state.show_page_jump_popup = false;
}
})
.response
@@ -1703,7 +1747,10 @@ pub fn render_ui(
state.popover_rect = popover_rect;
// Automatisches Schließen der Popover bei Klick außerhalb von Leiste und Popover
if (state.is_pen_menu_open || state.is_highlighter_menu_open || state.is_more_menu_open)
if (state.is_pen_menu_open
|| state.is_highlighter_menu_open
|| state.is_more_menu_open
|| state.show_page_jump_popup)
&& ctx.input(|i| i.pointer.any_pressed())
{
if let Some(pos) = ctx.input(|i| i.pointer.press_origin()) {
@@ -1713,106 +1760,20 @@ pub fn render_ui(
state.is_pen_menu_open = false;
state.is_highlighter_menu_open = false;
state.is_more_menu_open = false;
}
}
}
// 3. Schnellauswahl-Fenster für Seiten (im gleichen Milchglas-Look)
if state.show_page_jump_popup {
egui::Window::new("Seiten-Schnellauswahl")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 70.0))
.frame(
egui::Frame::none()
.fill(egui::Color32::from_rgba_premultiplied(24, 24, 28, 235))
.rounding(egui::Rounding::same(12.0))
.stroke(egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30),
))
.inner_margin(egui::Margin::symmetric(14.0, 12.0))
.shadow(egui::epaint::Shadow {
offset: egui::vec2(0.0, 6.0),
blur: 16.0,
spread: 0.0,
color: egui::Color32::from_black_alpha(120),
}),
)
.show(ctx, |ui| {
ui.label(
egui::RichText::new(format!("Notizbuch: {} Seite(n)", notebook.pages.len()))
.strong()
.color(egui::Color32::WHITE),
);
ui.separator();
egui::ScrollArea::vertical()
.max_height(220.0)
.show(ui, |ui| {
for idx in 0..notebook.pages.len() {
let is_current = idx == notebook.current_page_index;
let text = format!("Seite {}", idx + 1);
if tool_pill_button(ui, is_current, &text, "").clicked() {
actions.push(UiAction::GoToPage(idx));
state.show_page_jump_popup = false;
}
}
});
ui.separator();
if action_pill_button(ui, true, "Schließen", "Fenster schließen").clicked() {
state.show_page_jump_popup = false;
}
});
}
// 3b. Dokumenten-Manager (Notizbücher wechseln, anlegen, umbenennen, löschen)
if state.is_document_menu_open {
let doc_win = egui::Window::new("Dokumenten-Manager")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 70.0))
.frame(
egui::Frame::none()
.fill(egui::Color32::from_rgba_premultiplied(24, 24, 28, 242))
.rounding(egui::Rounding::same(14.0))
.stroke(egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30),
))
.inner_margin(egui::Margin::symmetric(18.0, 16.0))
.shadow(egui::epaint::Shadow {
offset: egui::vec2(0.0, 8.0),
blur: 20.0,
spread: 0.0,
color: egui::Color32::from_black_alpha(140),
}),
)
let doc_win = dialog_window("Dokumenten-Manager", state, false)
.show(ctx, |ui| {
ui.set_width(380.0);
// Kopfzeile
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("📚 Notizbücher verwalten")
.strong()
.size(15.0)
.color(egui::Color32::WHITE),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(egui::Button::new(egui::RichText::new("🗙").size(12.0)).frame(false))
.clicked()
{
ui.set_width(420.0);
touch_friendly_spacing(ui);
if dialog_header(ui, "📚 Notizbücher", true) {
state.is_document_menu_open = false;
}
});
});
ui.add_space(8.0);
ui.separator();
ui.add_space(8.0);
// Neues Dokument anlegen
ui.label(
@@ -1825,8 +1786,9 @@ pub fn render_ui(
ui.horizontal(|ui| {
let text_edit = ui.add(
egui::TextEdit::singleline(&mut state.new_notebook_title_input)
.hint_text("Titel des Notizbuchs...")
.desired_width(260.0),
.hint_text("Titel des neuen Notizbuchs …")
.margin(egui::vec2(8.0, 8.0))
.desired_width(270.0),
);
let create_btn = ui.button(egui::RichText::new("➕ Erstellen").strong());
let submit = create_btn.clicked()
@@ -1863,7 +1825,8 @@ pub fn render_ui(
if state.renaming_notebook_id.as_deref() == Some(&summary.id) {
let edit = ui.add(
egui::TextEdit::singleline(&mut state.renaming_title_input)
.desired_width(230.0),
.margin(egui::vec2(8.0, 8.0))
.desired_width(260.0),
);
if ui.button("✔").clicked()
|| (edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
@@ -1929,14 +1892,14 @@ pub fn render_ui(
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if summary.status == crate::storage::NotebookStatus::Damaged {
if ui
.button(egui::RichText::new("🔄 Backup").size(11.0).color(egui::Color32::from_rgb(100, 200, 255)))
.button(egui::RichText::new("🔄 Backup").size(13.0).color(egui::Color32::from_rgb(100, 200, 255)))
.on_hover_text("Aus manifest.json.bak wiederherstellen")
.clicked()
{
actions.push(UiAction::RestoreNotebookBackup(summary.id.clone()));
}
if ui
.button(egui::RichText::new("📁 Ordner").size(11.0))
.button(egui::RichText::new("📁 Ordner").size(13.0))
.on_hover_text("Pfad in Statusleiste anzeigen")
.clicked()
{
@@ -1945,14 +1908,14 @@ pub fn render_ui(
} else {
if state.available_notebooks.len() > 1
&& ui
.button(egui::RichText::new("🗑").size(11.0).color(egui::Color32::from_rgb(255, 120, 120)))
.button(egui::RichText::new("🗑").size(16.0).color(egui::Color32::from_rgb(255, 120, 120)))
.on_hover_text("Notizbuch in Papierkorb verschieben")
.clicked()
{
actions.push(UiAction::DeleteNotebook(summary.id.clone()));
}
if ui.button(egui::RichText::new("✏").size(11.0))
if ui.button(egui::RichText::new("✏").size(16.0))
.on_hover_text("Titel umbenennen")
.clicked()
{
@@ -1986,22 +1949,13 @@ pub fn render_ui(
.color(egui::Color32::from_rgb(255, 180, 100)),
);
if ui
.button(egui::RichText::new("↩ Rückgängig").size(11.0).color(egui::Color32::from_rgb(100, 200, 255)))
.button(egui::RichText::new("↩ Rückgängig").size(13.0).color(egui::Color32::from_rgb(100, 200, 255)))
.clicked()
{
actions.push(UiAction::UndoDeleteNotebook);
}
});
}
ui.add_space(8.0);
ui.separator();
ui.add_space(4.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if action_pill_button(ui, true, "Schließen", "Dokumenten-Manager schließen").clicked() {
state.is_document_menu_open = false;
}
});
});
state.document_modal_rect = doc_win.map(|w| w.response.rect);
} else {
@@ -2010,50 +1964,13 @@ pub fn render_ui(
// 3c. Release- & Update-Center (Gitea Releases)
if state.is_update_dialog_open {
let win = egui::Window::new("Release- & Update-Center")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 70.0))
.frame(
egui::Frame::none()
.fill(egui::Color32::from_rgba_premultiplied(24, 24, 28, 245))
.rounding(egui::Rounding::same(14.0))
.stroke(egui::Stroke::new(
1.0_f32,
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30),
))
.inner_margin(egui::Margin::symmetric(20.0, 16.0))
.shadow(egui::epaint::Shadow {
offset: egui::vec2(0.0, 8.0),
blur: 24.0,
spread: 0.0,
color: egui::Color32::from_black_alpha(150),
}),
)
let win = dialog_window("Release- & Update-Center", state, false)
.show(ctx, |ui| {
ui.set_width(450.0);
// Kopfzeile mit Schließen-Button
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("🚀 Gitea Release- & Update-Center")
.strong()
.size(16.0)
.color(egui::Color32::WHITE),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(egui::Button::new(egui::RichText::new("🗙").size(13.0)).frame(false))
.clicked()
{
ui.set_width(460.0);
touch_friendly_spacing(ui);
if dialog_header(ui, "🚀 Updates & Versionen", true) {
state.is_update_dialog_open = false;
}
});
});
ui.add_space(8.0);
ui.separator();
ui.add_space(8.0);
// Info über lokale Installation
let current_ver = env!("CARGO_PKG_VERSION");
@@ -2275,13 +2192,6 @@ pub fn render_ui(
});
}
});
ui.add_space(6.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if action_pill_button(ui, true, "Schließen", "Fenster schließen").clicked() {
state.is_update_dialog_open = false;
}
});
});
state.update_modal_rect = win.map(|w| w.response.rect);
} else {
@@ -2339,28 +2249,10 @@ pub fn render_ui(
// 5. Nicht-wegklickbare Warnung bei Ladefehlern / Quarantäne (Block 1)
if let Some(warning) = notebook.load_warnings.get(&notebook.current_page().id) {
egui::Window::new("⚠ Datenintegritäts-Warnung")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
.frame(
egui::Frame::none()
.fill(egui::Color32::from_rgba_premultiplied(35, 20, 20, 245))
.rounding(egui::Rounding::same(12.0))
.stroke(egui::Stroke::new(
1.5_f32,
egui::Color32::from_rgb(235, 87, 87),
))
.inner_margin(egui::Margin::symmetric(24.0, 20.0))
.shadow(egui::epaint::Shadow {
offset: egui::vec2(0.0, 8.0),
blur: 24.0,
spread: 0.0,
color: egui::Color32::from_black_alpha(180),
}),
)
dialog_window("⚠ Datenintegritäts-Warnung", state, true)
.show(ctx, |ui| {
ui.set_width(420.0);
touch_friendly_spacing(ui);
ui.horizontal(|ui| {
ui.colored_label(egui::Color32::from_rgb(255, 80, 80), "⚠");
ui.label(
@@ -2428,28 +2320,9 @@ pub fn render_ui(
// Notfall-/Sicherheits-Dialog bei Fehlschlag des Speicherns vor dem Beenden
if let Some(err) = &state.shutdown_error {
let mut dismiss = false;
egui::Window::new("⚠ Ungespeicherte Änderungen")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
.frame(
egui::Frame::none()
.fill(egui::Color32::from_rgba_premultiplied(35, 20, 20, 250))
.rounding(egui::Rounding::same(12.0))
.stroke(egui::Stroke::new(
1.5_f32,
egui::Color32::from_rgb(235, 87, 87),
))
.inner_margin(egui::Margin::symmetric(24.0, 20.0))
.shadow(egui::epaint::Shadow {
offset: egui::vec2(0.0, 8.0),
blur: 24.0,
spread: 0.0,
color: egui::Color32::from_black_alpha(200),
}),
)
.show(ctx, |ui| {
dialog_window("⚠ Ungespeicherte Änderungen", state, true).show(ctx, |ui| {
ui.set_width(460.0);
touch_friendly_spacing(ui);
ui.horizontal(|ui| {
ui.colored_label(egui::Color32::from_rgb(255, 80, 80), "⚠");
ui.label(
@@ -2461,9 +2334,7 @@ pub fn render_ui(
});
ui.add_space(8.0);
ui.label(
egui::RichText::new(
"Ungespeicherte Änderungen konnten nicht gesichert werden:",
)
egui::RichText::new("Ungespeicherte Änderungen konnten nicht gesichert werden:")
.size(13.0)
.color(egui::Color32::WHITE),
);
@@ -3032,4 +2903,106 @@ mod tests {
);
}
}
/// Simuliert einen Klick (Drücken und Loslassen) über rohe Eingabe-Ereignisse.
fn click_at(
ctx: &egui::Context,
state: &mut UiState,
notebook: &Notebook,
pos: egui::Pos2,
) -> Vec<UiAction> {
let updater = crate::updater::UpdaterController::new();
let screen = Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(1280.0, 850.0),
));
let mut actions = Vec::new();
for pressed in [true, false] {
let mut events = vec![egui::Event::PointerMoved(pos)];
events.push(egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::default(),
});
ctx.begin_frame(egui::RawInput {
screen_rect: screen,
events,
..Default::default()
});
actions.extend(render_ui(
ctx,
state,
notebook,
AutosaveStatus::Saved,
&updater,
));
let _ = ctx.end_frame();
}
actions
}
#[test]
fn test_only_one_dialog_or_popover_is_open() {
let notebook = Notebook::new();
let mut state = UiState::new();
state.show_page_jump_popup = true;
let ctx = render_frames(&mut state, &notebook, 10);
let bar = state.toolbar_rect.unwrap();
// Erster Knopf der Leiste (Notizbücher): Rahmenrand 6 px + halbe Knopfhöhe (≥ 28 px)
let books = egui::pos2(bar.center().x, bar.min.y + 6.0 + 14.0);
let actions = click_at(&ctx, &mut state, &notebook, books);
assert!(
state.is_document_menu_open,
"Dokumenten-Manager muss offen sein"
);
assert!(
!state.show_page_jump_popup,
"Seitenauswahl muss geschlossen sein"
);
assert!(actions
.iter()
.any(|a| matches!(a, UiAction::RefreshNotebooks)));
state.close_overlays();
assert!(!state.is_document_menu_open && !state.show_page_jump_popup);
}
#[test]
fn test_page_picker_jumps_to_every_page() {
let mut notebook = Notebook::new();
for _ in 0..5 {
notebook.add_page(PageBackground::Grid5mm);
}
let mut probe = UiState::new();
probe.show_page_jump_popup = true;
render_frames(&mut probe, &notebook, 10);
let bar = probe.toolbar_rect.unwrap();
let pop = probe.popover_rect.expect("Seitenauswahl muss offen sein");
assert!(pop.min.x > bar.max.x, "öffnet zur Blattseite hin: {pop:?}");
assert!(
pop.width() < 320.0,
"so breit wie das Kachelraster: {pop:?}"
);
let mut reached = std::collections::BTreeSet::new();
let mut y = pop.min.y + 4.0;
while y < pop.max.y {
let mut x = pop.min.x + 4.0;
while x < pop.max.x {
let mut state = UiState::new();
state.show_page_jump_popup = true;
let ctx = render_frames(&mut state, &notebook, 10);
for a in click_at(&ctx, &mut state, &notebook, egui::pos2(x, y)) {
if let UiAction::GoToPage(idx) = a {
reached.insert(idx);
assert!(!state.show_page_jump_popup, "schließt nach der Auswahl");
}
}
x += 14.0;
}
y += 14.0;
}
assert_eq!(reached, (0..6).collect(), "alle Seiten erreichbar");
}
}