feat: Eigene Farben, Bildschirmfarben wie im Export (Issue #13)

- Jede Farbe der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen:
  gewählte Farbe erneut antippen, Vorgabe oder mit 🎨 frei wählen (Farbwähler
  direkt im Popover). Die Leiste merkt sich die Farben (ui.json).
- Tintenfarben sind sRGB-Werte wie im Export: Gezeichnet wird ohne
  sRGB-Umrechnung der GPU, Papier und Muster rechnet der Shader um. Schwarz ist
  am Bildschirm jetzt schwarz statt grau, der Textmarker mischt wie im PDF.
- PDF-Export: Musterfarbe wird nach sRGB umgerechnet (war zu dunkel).
- Auswahl wird in die Farben der Stifte umgefärbt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 16:51:39 +02:00
co-authored by Claude Opus 5.5
parent 568714d727
commit bb9dbb52d0
11 changed files with 479 additions and 161 deletions
+1
View File
@@ -19,6 +19,7 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
### Schreiben und Zeichnen ### Schreiben und Zeichnen
- **Vier Stifte** in der Werkzeugleiste, jeder mit eigener Farbe und Stärke (0,15–5 mm, mit Druckstärke). Ändert man Farbe oder Stärke, merkt sich der gewählte Stift das – auch über einen Neustart hinweg. **Textmarker** (halbtransparent, 4–8 mm) in vier Farben; innerhalb eines Strichs wird er nicht dunkler, erst ein neuer Strich deckt stärker. - **Vier Stifte** in der Werkzeugleiste, jeder mit eigener Farbe und Stärke (0,15–5 mm, mit Druckstärke). Ändert man Farbe oder Stärke, merkt sich der gewählte Stift das – auch über einen Neustart hinweg. **Textmarker** (halbtransparent, 4–8 mm) in vier Farben; innerhalb eines Strichs wird er nicht dunkler, erst ein neuer Strich deckt stärker.
- **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. - **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). - **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. - **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Stiftrückseite und Seitentaste des Stifts radieren ebenfalls.
+1 -1
View File
@@ -115,7 +115,7 @@ Jeder Strich ist ein eigener Draw-Call mit eigener Nummer im Stencil-Puffer: Ein
- **Textfelder** werden mit `ab_glyph` gerastert, in einer Auflösung, die dem Zoom folgt (in Vierteln einer Zweierpotenz, höchstens 8 Pixel je Einheit). Der Texturschlüssel enthält einen Hash von Inhalt, Schrift und Farbe; jede Änderung erzeugt eine neue Textur. - **Textfelder** werden mit `ab_glyph` gerastert, in einer Auflösung, die dem Zoom folgt (in Vierteln einer Zweierpotenz, höchstens 8 Pixel je Einheit). Der Texturschlüssel enthält einen Hash von Inhalt, Schrift und Farbe; jede Änderung erzeugt eine neue Textur.
### 4.2 Papier- und Musterfarben ### 4.2 Papier- und Musterfarben
Papierfarbe (`PaperTheme`) und Musterfarbe (`PatternColor`) stehen in `background.rs`. Alle Farben sind linear; die Fensterfläche ist sRGB, die Umrechnung übernimmt die GPU. Auf dunklem Papier kann der Shader dunkle Tinte aufhellen (`ink_params` in `CameraUniforms`), gespeichert werden immer die Originalfarben. Papierfarbe (`PaperTheme`) und Musterfarbe (`PatternColor`) stehen in `background.rs`; sie sind linear angegeben und werden im Shader nach sRGB umgerechnet. Tintenfarben (Striche, Text) sind sRGB-Werte – dieselben Zahlen landen im PDF und in den Farbpunkten der Oberfläche. Gezeichnet wird ohne sRGB-Umrechnung der GPU (`render_format`), Texturen sind `Rgba8Unorm`: So wird halbtransparente Tinte wie im PDF in sRGB gemischt und sieht am Bildschirm genauso aus wie im Export. Auf dunklem Papier kann der Shader dunkle Tinte aufhellen (`ink_params` in `CameraUniforms`), gespeichert werden immer die Originalfarben.
--- ---
+20 -2
View File
@@ -71,7 +71,8 @@ pub const LINED_SPACING: f32 = 75.0;
/// Papierfarbe der Anzeige. Betrifft nur die Darstellung am Bildschirm; gespeicherte /// Papierfarbe der Anzeige. Betrifft nur die Darstellung am Bildschirm; gespeicherte
/// Strichfarben und der PDF-Export bleiben unverändert (weißes Papier). /// Strichfarben und der PDF-Export bleiben unverändert (weißes Papier).
/// ///
/// Alle Farben sind linear (die Oberfläche ist sRGB, die Umrechnung macht die GPU). /// Papier- und Musterfarben sind linear (der Shader rechnet sie in sRGB um); Tintenfarben
/// dagegen sind sRGB-Werte wie im Export.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum PaperTheme { pub enum PaperTheme {
@@ -138,7 +139,24 @@ impl PaperTheme {
PaperTheme::DarkGray => (0.004, 0.004, 0.005), PaperTheme::DarkGray => (0.004, 0.004, 0.005),
PaperTheme::Black => (0.03, 0.03, 0.035), PaperTheme::Black => (0.03, 0.03, 0.035),
}; };
wgpu::Color { r, g, b, a: 1.0 } // Linear angegeben; gezeichnet wird ohne sRGB-Umrechnung
let srgb = |c: f64| f64::from(linear_to_srgb(c as f32));
wgpu::Color {
r: srgb(r),
g: srgb(g),
b: srgb(b),
a: 1.0,
}
}
}
/// Linearer Farbanteil (Papier, Muster) als sRGB-Wert (Tinte, Export).
pub fn linear_to_srgb(c: f32) -> f32 {
let c = c.clamp(0.0, 1.0);
if c <= 0.003_130_8 {
c * 12.92
} else {
1.055 * c.powf(1.0 / 2.4) - 0.055
} }
} }
+30 -14
View File
@@ -316,13 +316,20 @@ impl CanvasState {
) )
.await?; .await?;
// 4. Geringe Bildverzögerung (Mailbox, sonst Fifo) & Frame-Latency 1 // 4. Geringe Bildverzögerung (Mailbox, sonst Fifo) & Frame-Latency 1.
// Gezeichnet wird ohne sRGB-Umrechnung (siehe `render_format`); bietet die Fläche
// nur sRGB-Formate an, wird eine Ansicht ohne sRGB verwendet.
let surface_format = surface_caps let surface_format = surface_caps
.formats .formats
.iter() .iter()
.copied() .copied()
.find(|f| f.is_srgb()) .find(|f| !f.is_srgb())
.unwrap_or(surface_caps.formats[0]); .unwrap_or(surface_caps.formats[0]);
let view_formats = if surface_format.is_srgb() {
vec![surface_format.remove_srgb_suffix()]
} else {
vec![]
};
let config = wgpu::SurfaceConfiguration { let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
@@ -331,14 +338,14 @@ impl CanvasState {
height: size.height.max(1), height: size.height.max(1),
present_mode, present_mode,
alpha_mode: surface_caps.alpha_modes[0], alpha_mode: surface_caps.alpha_modes[0],
view_formats: vec![], view_formats,
desired_maximum_frame_latency: 1, // Minimiert Eingabelatenz auf 1 Frame desired_maximum_frame_latency: 1, // Minimiert Eingabelatenz auf 1 Frame
}; };
surface.configure(&device, &config); surface.configure(&device, &config);
// Kantenglättung der Zeichenfläche (4× Multisampling), falls das Format es erlaubt // Kantenglättung der Zeichenfläche (4× Multisampling), falls das Format es erlaubt
let msaa_samples = if adapter let msaa_samples = if adapter
.get_texture_format_features(config.format) .get_texture_format_features(render_format(&config))
.flags .flags
.sample_count_supported(MSAA_SAMPLES) .sample_count_supported(MSAA_SAMPLES)
{ {
@@ -414,7 +421,7 @@ impl CanvasState {
module: &shader, module: &shader,
entry_point: "fs_ink", entry_point: "fs_ink",
targets: &[Some(wgpu::ColorTargetState { targets: &[Some(wgpu::ColorTargetState {
format: config.format, format: render_format(&config),
blend: Some(wgpu::BlendState::ALPHA_BLENDING), blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL, write_mask: wgpu::ColorWrites::ALL,
})], })],
@@ -450,7 +457,7 @@ impl CanvasState {
module: &shader, module: &shader,
entry_point: "fs_none", entry_point: "fs_none",
targets: &[Some(wgpu::ColorTargetState { targets: &[Some(wgpu::ColorTargetState {
format: config.format, format: render_format(&config),
blend: None, blend: None,
write_mask: wgpu::ColorWrites::empty(), write_mask: wgpu::ColorWrites::empty(),
})], })],
@@ -477,7 +484,7 @@ impl CanvasState {
module: &shader, module: &shader,
entry_point: "fs_main", entry_point: "fs_main",
targets: &[Some(wgpu::ColorTargetState { targets: &[Some(wgpu::ColorTargetState {
format: config.format, format: render_format(&config),
blend: Some(wgpu::BlendState::ALPHA_BLENDING), blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL, write_mask: wgpu::ColorWrites::ALL,
})], })],
@@ -537,7 +544,7 @@ impl CanvasState {
module: &shader, module: &shader,
entry_point: "fs_pattern", entry_point: "fs_pattern",
targets: &[Some(wgpu::ColorTargetState { targets: &[Some(wgpu::ColorTargetState {
format: config.format, format: render_format(&config),
blend: Some(wgpu::BlendState::ALPHA_BLENDING), blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL, write_mask: wgpu::ColorWrites::ALL,
})], })],
@@ -570,12 +577,12 @@ impl CanvasState {
&device, &device,
&shader, &shader,
&bind_group_layout, &bind_group_layout,
config.format, render_format(&config),
msaa_samples, msaa_samples,
); );
// 8. egui Renderer für das schwebende UI initialisieren // 8. egui Renderer für das schwebende UI initialisieren
let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1); let egui_renderer = egui_wgpu::Renderer::new(&device, render_format(&config), None, 1);
Ok(Self { Ok(Self {
surface, surface,
@@ -1340,9 +1347,10 @@ impl CanvasState {
} }
let output = self.surface.get_current_texture()?; let output = self.surface.get_current_texture()?;
let view = output let view = output.texture.create_view(&wgpu::TextureViewDescriptor {
.texture format: Some(render_format(&self.config)),
.create_view(&wgpu::TextureViewDescriptor::default()); ..Default::default()
});
let mut encoder = self let mut encoder = self
.device .device
@@ -1509,6 +1517,14 @@ impl CanvasState {
} }
} }
/// Format, in das gezeichnet wird: ohne sRGB-Umrechnung. Die Farben werden also in sRGB
/// gemischt – wie im PDF-Export, damit halbtransparente Tinte (Textmarker) am Bildschirm
/// genauso aussieht. Tintenfarben sind sRGB-Werte, Papier- und Musterfarben rechnet der
/// Shader aus linearen Werten um.
fn render_format(config: &wgpu::SurfaceConfiguration) -> wgpu::TextureFormat {
config.format.remove_srgb_suffix()
}
/// Multisampling der Zeichenfläche gegen gezackte Strichkanten. /// Multisampling der Zeichenfläche gegen gezackte Strichkanten.
const MSAA_SAMPLES: u32 = 4; const MSAA_SAMPLES: u32 = 4;
@@ -1530,7 +1546,7 @@ fn create_msaa_view(
mip_level_count: 1, mip_level_count: 1,
sample_count: samples, sample_count: samples,
dimension: wgpu::TextureDimension::D2, dimension: wgpu::TextureDimension::D2,
format: config.format, format: render_format(config),
usage: wgpu::TextureUsages::RENDER_ATTACHMENT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[], view_formats: &[],
}) })
+4 -2
View File
@@ -445,8 +445,10 @@ fn render_background_layer(
) { ) {
let background = page.background; let background = page.background;
let (Mm(page_width_mm), Mm(page_height_mm)) = pdf_page_mm(page); let (Mm(page_width_mm), Mm(page_height_mm)) = pdf_page_mm(page);
// Exportiert wird immer auf weißem Papier // Exportiert wird immer auf weißem Papier; die Musterfarbe ist linear, PDF erwartet sRGB
let [r, g, b, _] = pattern.color(crate::background::PaperTheme::White); let [r, g, b, _] = pattern
.color(crate::background::PaperTheme::White)
.map(crate::background::linear_to_srgb);
let grid_color = Color::Rgb(Rgb::new(r, g, b, None)); let grid_color = Color::Rgb(Rgb::new(r, g, b, None));
layer.set_outline_color(grid_color.clone()); layer.set_outline_color(grid_color.clone());
layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt) layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt)
+2 -1
View File
@@ -363,7 +363,8 @@ impl ImageLayer {
mip_level_count: levels.len() as u32, mip_level_count: levels.len() as u32,
sample_count: 1, sample_count: 1,
dimension: wgpu::TextureDimension::D2, dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb, // sRGB-Werte ohne Umrechnung: gezeichnet wird in sRGB (siehe canvas::render_format)
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[], view_formats: &[],
}); });
+21 -12
View File
@@ -1060,6 +1060,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ui_state.continuous_scroll = ui_settings.continuous_scroll; ui_state.continuous_scroll = ui_settings.continuous_scroll;
ui_state.pen_presets = ui_settings.pen_presets; ui_state.pen_presets = ui_settings.pen_presets;
ui_state.select_pen(ui_settings.active_pen); 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);
ui_state.text_colors = ui_settings.text_colors;
ui_state.active_text_color = ui_settings.active_text_color;
ui_state.text_color = ui_settings.text_colors[ui_settings.active_text_color];
ui_state.current_notebook_id = manifest.id.clone(); ui_state.current_notebook_id = manifest.id.clone();
ui_state.document_title = manifest.title.clone(); ui_state.document_title = manifest.title.clone();
ui_state.available_notebooks = available_notebooks; ui_state.available_notebooks = available_notebooks;
@@ -1071,8 +1076,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Zuletzt gelöschte Seite (Notizbuch-Kennung, Index, Seite) zum Wiederherstellen in dieser // Zuletzt gelöschte Seite (Notizbuch-Kennung, Index, Seite) zum Wiederherstellen in dieser
// Sitzung // Sitzung
let mut last_deleted_page: Option<(String, usize, ink_notes::document::Page)> = None; let mut last_deleted_page: Option<(String, usize, ink_notes::document::Page)> = None;
// Stifte geändert, aber noch nicht gespeichert
let mut pen_presets_dirty = false;
let mut clipboard = ClipboardBridge::new(); let mut clipboard = ClipboardBridge::new();
// PDF-Seiten werden im Hintergrund gerendert und wecken danach den Event-Loop // PDF-Seiten werden im Hintergrund gerendert und wecken danach den Event-Loop
let mut pdf_renderer = { let mut pdf_renderer = {
@@ -3263,16 +3266,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
// Stifte: geänderte Farbe oder Stärke gehört zum gewählten Stift. Gespeichert // Stifte und eigene Farben: geänderte Farbe oder Stärke gehört zum gewählten
// wird erst nach dem Loslassen (nicht bei jedem Schritt eines Schiebereglers). // Stift bzw. zur gewählten Farbe. Gespeichert wird erst nach dem Loslassen
if ui_state.sync_pen_preset() || ui_settings.active_pen != ui_state.active_pen { // (nicht bei jedem Schritt eines Schiebereglers oder Farbwählers).
pen_presets_dirty = true; ui_state.sync_tool_colors();
ui_settings.active_pen = ui_state.active_pen; if !egui_ctx.is_using_pointer() {
} let mut updated = ui_settings.clone();
if pen_presets_dirty && !egui_ctx.is_using_pointer() { updated.pen_presets = ui_state.pen_presets;
pen_presets_dirty = false; updated.active_pen = ui_state.active_pen;
ui_settings.pen_presets = ui_state.pen_presets; updated.highlighter_colors = ui_state.highlighter_colors;
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); updated.active_highlighter = ui_state.active_highlighter;
updated.text_colors = ui_state.text_colors;
updated.active_text_color = ui_state.active_text_color;
if updated != ui_settings {
ui_settings = updated;
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
}
} }
// Textbearbeitung beenden: Esc, anderes Werkzeug oder andere Seite // Textbearbeitung beenden: Esc, anderes Werkzeug oder andere Seite
+73 -7
View File
@@ -87,13 +87,7 @@ impl PenPreset {
/// Begrenzt Werte aus einer (evtl. von Hand bearbeiteten) Einstellungsdatei; Stifte sind /// Begrenzt Werte aus einer (evtl. von Hand bearbeiteten) Einstellungsdatei; Stifte sind
/// immer deckend. /// immer deckend.
pub fn sanitized(&self, fallback: PenPreset) -> Self { pub fn sanitized(&self, fallback: PenPreset) -> Self {
let mut color = fallback.color; let color = sanitized_color(self.color, [0.0, 0.0, 0.0, 1.0]);
if self.color.iter().all(|c| c.is_finite()) {
for (out, c) in color.iter_mut().zip(self.color).take(3) {
*out = c.clamp(0.0, 1.0);
}
}
color[3] = 1.0;
let width_mm = if self.width_mm.is_finite() { let width_mm = if self.width_mm.is_finite() {
self.width_mm self.width_mm
.clamp(*Self::WIDTH_RANGE.start(), *Self::WIDTH_RANGE.end()) .clamp(*Self::WIDTH_RANGE.start(), *Self::WIDTH_RANGE.end())
@@ -127,6 +121,35 @@ pub const DEFAULT_PEN_PRESETS: [PenPreset; PEN_PRESET_COUNT] = [
}, },
]; ];
/// Farben des Textmarkers in der Werkzeugleiste (halbtransparent).
pub const DEFAULT_HIGHLIGHTER_COLORS: [[f32; 4]; 4] = [
[1.0, 0.95, 0.1, 0.35],
[0.2, 0.9, 0.3, 0.35],
[0.25, 0.7, 1.0, 0.35],
[1.0, 0.3, 0.65, 0.35],
];
/// Farben des Text-Werkzeugs in der Werkzeugleiste.
pub const DEFAULT_TEXT_COLORS: [[f32; 4]; 5] = [
[0.12, 0.12, 0.14, 1.0],
[0.15, 0.35, 0.85, 1.0],
[0.85, 0.20, 0.20, 1.0],
[0.18, 0.65, 0.35, 1.0],
[0.45, 0.45, 0.48, 1.0],
];
/// Begrenzt eine Farbe aus der Einstellungsdatei auf 0–1; die Deckkraft kommt immer aus
/// `fallback` (Stift und Text deckend, Textmarker halbtransparent).
fn sanitized_color(color: [f32; 4], fallback: [f32; 4]) -> [f32; 4] {
let mut out = fallback;
if color.iter().all(|c| c.is_finite()) {
for (o, c) in out.iter_mut().zip(color).take(3) {
*o = c.clamp(0.0, 1.0);
}
}
out
}
/// Benutzereinstellungen der Oberfläche. /// Benutzereinstellungen der Oberfläche.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
@@ -145,6 +168,11 @@ pub struct UiSettings {
/// Stifte der Werkzeugleiste und der gewählte davon /// Stifte der Werkzeugleiste und der gewählte davon
pub pen_presets: [PenPreset; PEN_PRESET_COUNT], pub pen_presets: [PenPreset; PEN_PRESET_COUNT],
pub active_pen: usize, pub active_pen: usize,
/// Eigene Farben von Textmarker und Text-Werkzeug und die gewählte davon
pub highlighter_colors: [[f32; 4]; 4],
pub active_highlighter: usize,
pub text_colors: [[f32; 4]; 5],
pub active_text_color: usize,
} }
impl Default for UiSettings { impl Default for UiSettings {
@@ -158,6 +186,10 @@ impl Default for UiSettings {
continuous_scroll: true, continuous_scroll: true,
pen_presets: DEFAULT_PEN_PRESETS, pen_presets: DEFAULT_PEN_PRESETS,
active_pen: 0, active_pen: 0,
highlighter_colors: DEFAULT_HIGHLIGHTER_COLORS,
active_highlighter: 0,
text_colors: DEFAULT_TEXT_COLORS,
active_text_color: 0,
} }
} }
} }
@@ -189,6 +221,22 @@ impl UiSettings {
*preset = preset.sanitized(fallback); *preset = preset.sanitized(fallback);
} }
settings.active_pen = settings.active_pen.min(PEN_PRESET_COUNT - 1); settings.active_pen = settings.active_pen.min(PEN_PRESET_COUNT - 1);
for (color, fallback) in settings
.highlighter_colors
.iter_mut()
.zip(DEFAULT_HIGHLIGHTER_COLORS)
{
*color = sanitized_color(*color, fallback);
}
settings.active_highlighter = settings
.active_highlighter
.min(settings.highlighter_colors.len() - 1);
for (color, fallback) in settings.text_colors.iter_mut().zip(DEFAULT_TEXT_COLORS) {
*color = sanitized_color(*color, fallback);
}
settings.active_text_color = settings
.active_text_color
.min(settings.text_colors.len() - 1);
settings settings
} }
@@ -225,6 +273,10 @@ mod tests {
width_mm: 1.5, width_mm: 1.5,
}; PEN_PRESET_COUNT], }; PEN_PRESET_COUNT],
active_pen: 2, active_pen: 2,
highlighter_colors: [[0.9, 0.5, 0.1, 0.35]; 4],
active_highlighter: 3,
text_colors: [[0.3, 0.1, 0.6, 1.0]; 5],
active_text_color: 4,
}; };
settings.save_to(&path).unwrap(); settings.save_to(&path).unwrap();
assert_eq!(UiSettings::load_from(&path), settings); assert_eq!(UiSettings::load_from(&path), settings);
@@ -280,6 +332,20 @@ mod tests {
assert_eq!(settings.pen_presets[0].color, [1.0, 0.0, 0.5, 1.0]); assert_eq!(settings.pen_presets[0].color, [1.0, 0.0, 0.5, 1.0]);
assert_eq!(settings.pen_presets[0].width_mm, 5.0); assert_eq!(settings.pen_presets[0].width_mm, 5.0);
assert_eq!(settings.pen_presets[1].width_mm, 0.15); assert_eq!(settings.pen_presets[1].width_mm, 0.15);
// Eigene Farben: Textmarker bleibt halbtransparent, Text deckend
std::fs::write(
&path,
br#"{"highlighter_colors": [[0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0],
[0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0]], "active_highlighter": 7,
"text_colors": [[5.0, 0.2, 0.3, 0.1], [0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0],
[0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0]]}"#,
)
.unwrap();
let settings = UiSettings::load_from(&path);
assert_eq!(settings.highlighter_colors[0], [0.1, 0.2, 0.3, 0.35]);
assert_eq!(settings.active_highlighter, 3);
assert_eq!(settings.text_colors[0], [1.0, 0.2, 0.3, 1.0]);
} }
#[test] #[test]
+26 -13
View File
@@ -30,22 +30,33 @@ fn vs_main(in: VertexInput) -> VertexOutput {
return out; return out;
} }
@fragment // Linearer Farbwert (Papier, Muster) -> sRGB: Gezeichnet wird ohne sRGB-Umrechnung der GPU
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { // (siehe `render_format` in canvas.rs), gemischt wird also wie im PDF in sRGB
return in.color; fn linear_to_srgb(c: vec3<f32>) -> vec3<f32> {
let low = c * 12.92;
let high = 1.055 * pow(c, vec3<f32>(1.0 / 2.4)) - vec3<f32>(0.055);
return select(high, low, c <= vec3<f32>(0.0031308));
} }
// Striche: bei dunklem Papier wird die HSL-Helligkeit dunkler Farben gespiegelt // Papier: Farbe linear
// (L -> 1 - L, Farbton und Sättigung bleiben). Entspricht `background::adapt_ink_color`. @fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
return vec4<f32>(linear_to_srgb(clamp(in.color.rgb, vec3<f32>(0.0), vec3<f32>(1.0))), in.color.a);
}
// Striche: Tintenfarben sind sRGB-Werte wie im Export und in der Oberfläche. Bei dunklem
// Papier wird die HSL-Helligkeit dunkler Farben gespiegelt (L -> 1 - L, Farbton und
// Sättigung bleiben). Entspricht `background::adapt_ink_color`.
@fragment @fragment
fn fs_ink(in: VertexOutput) -> @location(0) vec4<f32> { fn fs_ink(in: VertexOutput) -> @location(0) vec4<f32> {
if (camera.ink_params.x < 0.5) { var rgb = clamp(in.color.rgb, vec3<f32>(0.0), vec3<f32>(1.0));
return in.color; if (camera.ink_params.x >= 0.5) {
let max_c = max(max(rgb.r, rgb.g), rgb.b);
let min_c = min(min(rgb.r, rgb.g), rgb.b);
let lift = max(1.0 - max_c - min_c, 0.0);
rgb = min(rgb + vec3<f32>(lift), vec3<f32>(1.0));
} }
let max_c = max(max(in.color.r, in.color.g), in.color.b); return vec4<f32>(rgb, in.color.a);
let min_c = min(min(in.color.r, in.color.g), in.color.b);
let lift = max(1.0 - max_c - min_c, 0.0);
return vec4<f32>(in.color.rgb + vec3<f32>(lift), in.color.a);
} }
// Bildschirmfüllendes Dreieck ohne Farbe (setzt den Stencil-Puffer zurück) // Bildschirmfüllendes Dreieck ohne Farbe (setzt den Stencil-Puffer zurück)
@@ -61,7 +72,8 @@ fn fs_none() -> @location(0) vec4<f32> {
return vec4<f32>(0.0); return vec4<f32>(0.0);
} }
// Eingefügte Bilder: texturierte Vierecke unter der Tinte // Eingefügte Bilder: texturierte Vierecke unter der Tinte (Texturen enthalten sRGB-Werte
// und werden unverändert übernommen)
struct ImageVertexInput { struct ImageVertexInput {
@location(0) position: vec2<f32>, @location(0) position: vec2<f32>,
@location(1) uv: vec2<f32>, @location(1) uv: vec2<f32>,
@@ -169,5 +181,6 @@ fn fs_pattern(in: PatternOutput) -> @location(0) vec4<f32> {
coverage = clamp(radius_px + 0.5 - d, 0.0, 1.0); coverage = clamp(radius_px + 0.5 - d, 0.0, 1.0);
} }
} }
return vec4<f32>(in.color.rgb, in.color.a * coverage * fade); let rgb = linear_to_srgb(clamp(in.color.rgb, vec3<f32>(0.0), vec3<f32>(1.0)));
return vec4<f32>(rgb, in.color.a * coverage * fade);
} }
+4 -4
View File
@@ -348,7 +348,7 @@ const MAX_TEXTURE_SIDE: f32 = 4096.0;
const MAX_TEXTURE_PIXELS: f32 = 16.0 * 1024.0 * 1024.0; const MAX_TEXTURE_PIXELS: f32 = 16.0 * 1024.0 * 1024.0;
/// Rastert ein Textfeld mit `px_per_unit` Pixeln je Seiteneinheit (begrenzt auf eine /// Rastert ein Textfeld mit `px_per_unit` Pixeln je Seiteneinheit (begrenzt auf eine
/// handhabbare Texturgröße). `color` ist linear wie die Tinte; die Pixel sind sRGB. /// handhabbare Texturgröße). `color` ist sRGB wie die Tinte, ebenso die Pixel.
pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage { pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage {
let layout = text.layout(); let layout = text.layout();
let size = glam::Vec2::new(text.wrap_width.max(text.size), layout.height()); let size = glam::Vec2::new(text.wrap_width.max(text.size), layout.height());
@@ -383,7 +383,7 @@ pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage
} }
} }
let rgb = [0, 1, 2].map(|i| egui::ecolor::gamma_u8_from_linear_f32(color[i])); let rgb = [0, 1, 2].map(|i| (color[i].clamp(0.0, 1.0) * 255.0).round() as u8);
let alpha = color[3].clamp(0.0, 1.0); let alpha = color[3].clamp(0.0, 1.0);
let mut pixels = Vec::with_capacity(coverage.len() * 4); let mut pixels = Vec::with_capacity(coverage.len() * 4);
for c in coverage { for c in coverage {
@@ -476,8 +476,8 @@ mod tests {
assert_eq!(image.height as f32, text.layout().height().ceil()); assert_eq!(image.height as f32, text.layout().height().ceil());
let opaque = image.pixels.chunks(4).filter(|p| p[3] > 200).count(); let opaque = image.pixels.chunks(4).filter(|p| p[3] > 200).count();
assert!(opaque > 100, "{opaque}"); assert!(opaque > 100, "{opaque}");
// Farbe ist sRGB-kodiert // Die Farbe ist schon sRGB und wird unverändert übernommen
assert!(image.pixels.chunks(4).all(|p| p[0] > 230 && p[1] < 130)); assert!(image.pixels.chunks(4).all(|p| p[..3] == [217, 51, 51]));
} }
#[test] #[test]
+297 -105
View File
@@ -19,7 +19,8 @@ use crate::document::{
use crate::file_picker::{FilePicker, PickerKind}; use crate::file_picker::{FilePicker, PickerKind};
use crate::ruler::{Ruler, RulerHit}; use crate::ruler::{Ruler, RulerHit};
use crate::settings::{ use crate::settings::{
PenPreset, PressureCurve, SidebarSide, DEFAULT_PEN_PRESETS, PEN_PRESET_COUNT, PenPreset, PressureCurve, SidebarSide, DEFAULT_HIGHLIGHTER_COLORS, DEFAULT_PEN_PRESETS,
DEFAULT_TEXT_COLORS, PEN_PRESET_COUNT,
}; };
use crate::storage::NotebookSummary; use crate::storage::NotebookSummary;
use crate::text::{TextEditing, TextFont, TEXT_SIZES_PT}; use crate::text::{TextEditing, TextFont, TEXT_SIZES_PT};
@@ -266,6 +267,18 @@ pub struct UiState {
/// `pen_stroke_width_mm` gehören immer zum gewählten Stift /// `pen_stroke_width_mm` gehören immer zum gewählten Stift
pub pen_presets: [PenPreset; PEN_PRESET_COUNT], pub pen_presets: [PenPreset; PEN_PRESET_COUNT],
pub active_pen: usize, pub active_pen: usize,
/// Eigene Farben des Textmarkers in der Werkzeugleiste und die gewählte;
/// `highlighter_color` gehört immer zur gewählten
pub highlighter_colors: [[f32; 4]; 4],
pub active_highlighter: usize,
/// Eigene Farben des Text-Werkzeugs und die zuletzt angetippte (ein geöffnetes Textfeld
/// kann eine andere Farbe haben)
pub text_colors: [[f32; 4]; 5],
pub active_text_color: usize,
/// Popover „Textfarbe“ ist offen
pub is_text_color_menu_open: bool,
/// Farbwähler in den Farb-Popovern aufgeklappt
pub show_color_picker: bool,
/// Rückfrage „Seite löschen?“ im ☰-Menü ist offen /// Rückfrage „Seite löschen?“ im ☰-Menü ist offen
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
@@ -349,6 +362,12 @@ impl Default for UiState {
last_deleted_notebook: None, last_deleted_notebook: None,
pen_presets: DEFAULT_PEN_PRESETS, pen_presets: DEFAULT_PEN_PRESETS,
active_pen: 0, active_pen: 0,
highlighter_colors: DEFAULT_HIGHLIGHTER_COLORS,
active_highlighter: 0,
text_colors: DEFAULT_TEXT_COLORS,
active_text_color: 0,
is_text_color_menu_open: false,
show_color_picker: false,
confirm_delete_page: false, confirm_delete_page: false,
restorable_page: None, restorable_page: None,
shutdown_error: None, shutdown_error: None,
@@ -391,6 +410,7 @@ impl UiState {
/// sich nie mehrere überlagern. /// sich nie mehrere überlagern.
pub fn close_overlays(&mut self) { pub fn close_overlays(&mut self) {
self.confirm_delete_page = false; self.confirm_delete_page = false;
self.is_text_color_menu_open = false;
self.is_pen_menu_open = false; self.is_pen_menu_open = false;
self.is_highlighter_menu_open = false; self.is_highlighter_menu_open = false;
self.is_more_menu_open = false; self.is_more_menu_open = false;
@@ -500,9 +520,36 @@ impl UiState {
self.pen_stroke_width_mm = preset.width_mm; self.pen_stroke_width_mm = preset.width_mm;
} }
/// Wählt die eigene Textmarker-Farbe `index`.
pub fn select_highlighter_color(&mut self, index: usize) {
self.active_highlighter = index.min(self.highlighter_colors.len() - 1);
self.highlighter_color = self.highlighter_colors[self.active_highlighter];
}
/// Setzt die Textfarbe (auch für ein gerade bearbeitetes Textfeld).
pub fn set_text_color(&mut self, color: [f32; 4]) {
self.text_color = color;
if let Some(edit) = &mut self.text_edit {
edit.color = color;
edit.focus = true;
}
}
/// Übernimmt geänderte Farbe oder Stärke des Stifts in die Vorlage des gewählten Stifts
/// und die Textmarker-Farbe in die gewählte eigene Farbe; `true`, wenn sich etwas
/// geändert hat (dann speichern).
pub fn sync_tool_colors(&mut self) -> bool {
let index = self
.active_highlighter
.min(self.highlighter_colors.len() - 1);
let highlighter_changed = self.highlighter_colors[index] != self.highlighter_color;
self.highlighter_colors[index] = self.highlighter_color;
self.sync_pen_preset() | highlighter_changed
}
/// Übernimmt geänderte Farbe oder Stärke des Stifts in die Vorlage des gewählten Stifts; /// Übernimmt geänderte Farbe oder Stärke des Stifts in die Vorlage des gewählten Stifts;
/// `true`, wenn sie sich geändert hat (dann speichern). /// `true`, wenn sie sich geändert hat (dann speichern).
pub fn sync_pen_preset(&mut self) -> bool { fn sync_pen_preset(&mut self) -> bool {
let current = PenPreset { let current = PenPreset {
color: self.pen_color, color: self.pen_color,
width_mm: self.pen_stroke_width_mm, width_mm: self.pen_stroke_width_mm,
@@ -930,6 +977,67 @@ fn autosave_status_style(status: AutosaveStatus) -> (egui::Color32, &'static str
} }
/// Zeichnet einen runden Farbauswahl-Knopf (Color Swatch) mit anpassbarem Durchmesser. /// Zeichnet einen runden Farbauswahl-Knopf (Color Swatch) mit anpassbarem Durchmesser.
/// Farbwahl in den Popovern: Vorgaben als Farbpunkte, dahinter 🎨 für einen frei
/// wählbaren Farbton (aufklappbar). Die Deckkraft von `color` bleibt erhalten (Textmarker
/// halbtransparent). `true`, wenn sich die Farbe geändert hat.
fn color_editor(
ui: &mut egui::Ui,
presets: &[ColorPreset],
color: &mut [f32; 4],
picker_open: &mut bool,
) -> bool {
let mut changed = false;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(8.0, 0.0);
for &(preset, label) in presets {
let is_sel = preset[..3]
.iter()
.zip(&color[..3])
.all(|(a, b)| (a - b).abs() < 0.01);
if color_circle_swatch(ui, preset, is_sel, label, 30.0).clicked() {
*color = [preset[0], preset[1], preset[2], color[3]];
changed = true;
}
}
if ui
.add(egui::SelectableLabel::new(
*picker_open,
egui::RichText::new("🎨").size(18.0),
))
.on_hover_text("Eigene Farbe wählen")
.clicked()
{
*picker_open = !*picker_open;
}
});
if *picker_open {
ui.add_space(6.0);
let [r, g, b] = [0, 1, 2].map(|i| (color[i].clamp(0.0, 1.0) * 255.0).round() as u8);
let mut picked = egui::Color32::from_rgb(r, g, b);
let picked_changed = ui
.scope(|ui| {
ui.spacing_mut().slider_width = 170.0;
egui::color_picker::color_picker_color32(
ui,
&mut picked,
egui::color_picker::Alpha::Opaque,
)
})
.inner;
if picked_changed {
let [r, g, b, _] = picked.to_array();
*color = [
r as f32 / 255.0,
g as f32 / 255.0,
b as f32 / 255.0,
color[3],
];
changed = true;
}
}
changed
}
/// Stift der Werkzeugleiste: ein Strich in Farbe und (angedeuteter) Stärke auf einem /// Stift der Werkzeugleiste: ein Strich in Farbe und (angedeuteter) Stärke auf einem
/// Stück Papier, damit auch schwarze Tinte auf der dunklen Leiste sichtbar ist. /// Stück Papier, damit auch schwarze Tinte auf der dunklen Leiste sichtbar ist.
fn pen_preset_swatch( fn pen_preset_swatch(
@@ -1195,7 +1303,7 @@ fn text_edit_overlay(ctx: &egui::Context, state: &mut UiState) {
let height_px = font_size * ctx.pixels_per_point() * edit.text.font.height_per_em(); let height_px = font_size * ctx.pixels_per_point() * edit.text.font.height_per_em();
let rounding = height_px.round().max(1.0) / height_px; let rounding = height_px.round().max(1.0) / height_px;
let width = (edit.text.wrap_width.max(edit.text.size) * sx * view.scale * rounding).max(8.0); let width = (edit.text.wrap_width.max(edit.text.size) * sx * view.scale * rounding).max(8.0);
let color = linear_color32(if adapt { let color = color32_from(if adapt {
adapt_ink_color(edit.color) adapt_ink_color(edit.color)
} else { } else {
edit.color edit.color
@@ -2001,6 +2109,7 @@ pub fn render_ui(
let mut pen_btn_rect = None; let mut pen_btn_rect = None;
let mut high_btn_rect = None; let mut high_btn_rect = None;
let mut text_color_btn_rect = None;
let mut more_btn_rect = None; let mut more_btn_rect = None;
let mut page_btn_rect = None; let mut page_btn_rect = None;
@@ -2208,26 +2317,11 @@ pub fn render_ui(
// --- Stifte bzw. Farben des aktiven Werkzeugs --- // --- Stifte bzw. Farben des aktiven Werkzeugs ---
let pens = matches!(state.active_tool, ActiveTool::Pen | ActiveTool::Shape); let pens = matches!(state.active_tool, ActiveTool::Pen | ActiveTool::Shape);
let palette: Option<(&[ColorPreset], [f32; 4])> =
match state.active_tool {
ActiveTool::Pen | ActiveTool::Shape => None,
ActiveTool::Select => {
Some((&PEN_COLOR_PRESETS[..], state.pen_color))
}
ActiveTool::Highlighter => Some((
&HIGHLIGHTER_COLOR_PRESETS[..],
state.highlighter_color,
)),
ActiveTool::Text => {
Some((&PEN_COLOR_PRESETS[..], state.text_color))
}
ActiveTool::Eraser => None,
};
// Fester Platz für die längste Palette, damit die Knöpfe darunter // Fester Platz für die längste Palette, damit die Knöpfe darunter
// beim Werkzeugwechsel nicht springen // beim Werkzeugwechsel nicht springen
sidebar_divider(ui, btn); sidebar_divider(ui, btn);
let swatch = UiState::sidebar_swatch_size(btn); let swatch = UiState::sidebar_swatch_size(btn);
let mut shown = palette.map_or(0, |(presets, _)| presets.len()); let mut shown = 0;
if pens { if pens {
shown = PEN_PRESET_COUNT; shown = PEN_PRESET_COUNT;
for i in 0..PEN_PRESET_COUNT { for i in 0..PEN_PRESET_COUNT {
@@ -2267,29 +2361,74 @@ pub fn render_ui(
} }
} }
} }
if let Some((presets, current)) = palette { // Eigene Farben von Textmarker und Text: antippen wählt, erneut
for &(color, label) in presets { // tippen passt die Farbe an. Die Auswahl wird in die Farben der
let is_sel = (current[0] - color[0]).abs() < 0.05 // Stifte umgefärbt.
&& (current[1] - color[1]).abs() < 0.05 let tool = state.active_tool;
&& (current[2] - color[2]).abs() < 0.05; let slots: Option<Vec<[f32; 4]>> = match tool {
if color_circle_swatch(ui, color, is_sel, label, swatch) ActiveTool::Highlighter => Some(state.highlighter_colors.to_vec()),
.clicked() ActiveTool::Text => Some(state.text_colors.to_vec()),
{ ActiveTool::Select => {
match state.active_tool { Some(state.pen_presets.iter().map(|p| p.color).collect())
ActiveTool::Highlighter => { }
state.highlighter_color = color _ => None,
} };
ActiveTool::Text => { if let Some(colors) = slots {
state.text_color = color; shown = colors.len();
if let Some(edit) = &mut state.text_edit { for (i, color) in colors.into_iter().enumerate() {
edit.color = color; let selected = match tool {
edit.focus = true; ActiveTool::Highlighter => i == state.active_highlighter,
} ActiveTool::Text => {
} i == state.active_text_color
// Auswahl umfärben, der Stift bleibt && state.text_color == color
_ => {}
} }
actions.push(UiAction::SetColor(color)); _ => false,
};
let tooltip = match tool {
ActiveTool::Select => {
format!("Auswahl in der Farbe von Stift {} umfärben", i + 1)
}
_ if selected => format!(
"Farbe {} – erneut tippen, um sie anzupassen",
i + 1
),
_ => format!("Farbe {}", i + 1),
};
let response =
color_circle_swatch(ui, color, selected, &tooltip, swatch);
if selected {
// Das Farb-Popover erscheint neben der gewählten Farbe
match tool {
ActiveTool::Highlighter => high_btn_rect = Some(response.rect),
_ => text_color_btn_rect = Some(response.rect),
}
}
if !response.clicked() {
continue;
}
match tool {
ActiveTool::Highlighter if selected => {
let open = !state.is_highlighter_menu_open;
state.close_overlays();
state.is_highlighter_menu_open = open;
}
ActiveTool::Highlighter => {
state.close_overlays();
state.select_highlighter_color(i);
actions.push(UiAction::SetColor(color));
}
ActiveTool::Text if selected => {
let open = !state.is_text_color_menu_open;
state.close_overlays();
state.is_text_color_menu_open = open;
}
ActiveTool::Text => {
state.close_overlays();
state.active_text_color = i;
state.set_text_color(color);
actions.push(UiAction::SetColor(color));
}
_ => actions.push(UiAction::SetColor(color)),
} }
} }
} }
@@ -2565,36 +2704,14 @@ pub fn render_ui(
.color(egui::Color32::from_gray(160)), .color(egui::Color32::from_gray(160)),
); );
ui.add_space(3.0); ui.add_space(3.0);
ui.horizontal(|ui| { if color_editor(
ui.spacing_mut().item_spacing = egui::vec2(8.0, 0.0); ui,
for (color, label) in PEN_COLOR_PRESETS { &PEN_COLOR_PRESETS,
let is_sel = (state.pen_color[0] - color[0]).abs() < 0.05 &mut state.pen_color,
&& (state.pen_color[1] - color[1]).abs() < 0.05 &mut state.show_color_picker,
&& (state.pen_color[2] - color[2]).abs() < 0.05; ) {
if color_circle_swatch(ui, color, is_sel, label, 30.0).clicked() { actions.push(UiAction::SetColor(state.pen_color));
state.pen_color = color; }
actions.push(UiAction::SetColor(color));
}
}
// RGBA-Colorpicker
let mut egui_color = egui::Rgba::from_rgba_premultiplied(
state.pen_color[0],
state.pen_color[1],
state.pen_color[2],
state.pen_color[3],
);
if egui::color_picker::color_edit_button_rgba(
ui,
&mut egui_color,
egui::color_picker::Alpha::Opaque,
)
.changed()
{
state.pen_color = egui_color.to_array();
actions.push(UiAction::SetColor(state.pen_color));
}
});
ui.add_space(10.0); ui.add_space(10.0);
@@ -2662,10 +2779,13 @@ pub fn render_ui(
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label( ui.label(
egui::RichText::new("Textmarker-Konfiguration") egui::RichText::new(format!(
.strong() "Textmarker – Farbe {}",
.color(egui::Color32::WHITE) state.active_highlighter + 1
.size(12.5), ))
.strong()
.color(egui::Color32::WHITE)
.size(12.5),
); );
ui.with_layout( ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center), egui::Layout::right_to_left(egui::Align::Center),
@@ -2681,41 +2801,20 @@ pub fn render_ui(
// Zeile 1: Farbauswahl // Zeile 1: Farbauswahl
ui.label( ui.label(
egui::RichText::new("MARKER-FARBE") egui::RichText::new("FARBE")
.size(10.5) .size(10.5)
.strong() .strong()
.color(egui::Color32::from_gray(160)), .color(egui::Color32::from_gray(160)),
); );
ui.add_space(3.0); ui.add_space(3.0);
ui.horizontal(|ui| { if color_editor(
ui.spacing_mut().item_spacing = egui::vec2(8.0, 0.0); ui,
for (color, label) in HIGHLIGHTER_COLOR_PRESETS { &HIGHLIGHTER_COLOR_PRESETS,
let is_sel = (state.highlighter_color[0] - color[0]).abs() < 0.05 &mut state.highlighter_color,
&& (state.highlighter_color[1] - color[1]).abs() < 0.05 &mut state.show_color_picker,
&& (state.highlighter_color[2] - color[2]).abs() < 0.05; ) {
if color_circle_swatch(ui, color, is_sel, label, 30.0).clicked() { actions.push(UiAction::SetColor(state.highlighter_color));
state.highlighter_color = color; }
actions.push(UiAction::SetColor(color));
}
}
let mut egui_color = egui::Rgba::from_rgba_premultiplied(
state.highlighter_color[0],
state.highlighter_color[1],
state.highlighter_color[2],
state.highlighter_color[3],
);
if egui::color_picker::color_edit_button_rgba(
ui,
&mut egui_color,
egui::color_picker::Alpha::OnlyBlend,
)
.changed()
{
state.highlighter_color = egui_color.to_array();
actions.push(UiAction::SetColor(state.highlighter_color));
}
});
ui.add_space(10.0); ui.add_space(10.0);
@@ -2779,6 +2878,59 @@ pub fn render_ui(
popover_rect = Some(pop_response.inner); popover_rect = Some(pop_response.inner);
} }
} else if state.is_text_color_menu_open {
if let Some(btn_rect) = text_color_btn_rect {
let (popover_pos, pivot) =
popover_placement(state.sidebar_side, area_response.inner, btn_rect);
let pop_response = egui::Area::new(egui::Id::new("text_color_popover"))
.fixed_pos(popover_pos)
.pivot(pivot)
.constrain(true)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
let frame = popover_frame().show(ui, |ui| {
ui.set_width(320.0);
touch_friendly_spacing(ui);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format!(
"Textfarbe {}",
state.active_text_color + 1
))
.strong()
.color(egui::Color32::WHITE)
.size(12.5),
);
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if close_button(ui).clicked() {
state.is_text_color_menu_open = false;
}
},
);
});
ui.add_space(8.0);
let slot = state.active_text_color.min(state.text_colors.len() - 1);
let mut color = state.text_colors[slot];
if color_editor(
ui,
&PEN_COLOR_PRESETS,
&mut color,
&mut state.show_color_picker,
) {
state.text_colors[slot] = color;
state.set_text_color(color);
actions.push(UiAction::SetColor(color));
}
});
frame.response.rect
});
popover_rect = Some(pop_response.inner);
} else {
// Die Farbe ist nicht mehr zu sehen (anderes Werkzeug)
state.is_text_color_menu_open = false;
}
} }
// 2b. ☰-MENÜ (selten genutzte Funktionen), öffnet sich nach oben zur Blattseite hin // 2b. ☰-MENÜ (selten genutzte Funktionen), öffnet sich nach oben zur Blattseite hin
@@ -3223,6 +3375,7 @@ pub fn render_ui(
// Automatisches Schließen der Popover bei Klick außerhalb von Leiste und Popover // Automatisches Schließen der Popover bei Klick außerhalb von Leiste und Popover
if (state.is_pen_menu_open if (state.is_pen_menu_open
|| state.is_highlighter_menu_open || state.is_highlighter_menu_open
|| state.is_text_color_menu_open
|| state.is_more_menu_open || state.is_more_menu_open
|| state.show_page_jump_popup) || state.show_page_jump_popup)
&& ctx.input(|i| i.pointer.any_pressed()) && ctx.input(|i| i.pointer.any_pressed())
@@ -3233,6 +3386,7 @@ pub fn render_ui(
if !in_toolbar && !in_popover { if !in_toolbar && !in_popover {
state.is_pen_menu_open = false; state.is_pen_menu_open = false;
state.is_highlighter_menu_open = false; state.is_highlighter_menu_open = false;
state.is_text_color_menu_open = false;
state.is_more_menu_open = false; state.is_more_menu_open = false;
state.show_page_jump_popup = false; state.show_page_jump_popup = false;
} }
@@ -4542,6 +4696,44 @@ mod tests {
assert!(state.ruler.is_none()); assert!(state.ruler.is_none());
} }
#[test]
fn test_custom_colors_remember_changes() {
let mut state = UiState::new();
assert!(!state.sync_tool_colors(), "Ausgangszustand ist gespeichert");
// Stift 2 wählen und umfärben: der Stift merkt sich die Farbe
state.select_pen(1);
state.pen_color = [0.5, 0.1, 0.6, 1.0];
assert!(state.sync_tool_colors());
assert_eq!(state.pen_presets[1].color, [0.5, 0.1, 0.6, 1.0]);
state.select_pen(0);
state.select_pen(1);
assert_eq!(state.pen_color, [0.5, 0.1, 0.6, 1.0]);
// Textmarker-Farbe 3 anpassen
state.select_highlighter_color(2);
state.highlighter_color = [0.9, 0.5, 0.1, 0.35];
assert!(state.sync_tool_colors());
assert_eq!(state.highlighter_colors[2], [0.9, 0.5, 0.1, 0.35]);
assert_eq!(state.highlighter_colors[0], DEFAULT_HIGHLIGHTER_COLORS[0]);
assert!(!state.sync_tool_colors());
// Textfarbe gilt auch für das gerade bearbeitete Feld
state.text_edit = Some(TextEditing::new_at(
1,
glam::Vec2::new(100.0, 100.0),
PAGE_WIDTH,
50.0,
TextFont::Sans,
[0.0, 0.0, 0.0, 1.0],
));
state.set_text_color([0.2, 0.6, 0.3, 1.0]);
assert_eq!(
state.text_edit.as_ref().unwrap().color,
[0.2, 0.6, 0.3, 1.0]
);
}
#[test] #[test]
fn test_stroke_tool_follows_active_tool() { fn test_stroke_tool_follows_active_tool() {
assert_eq!(ActiveTool::Pen.stroke_tool(false), StrokeTool::Pen); assert_eq!(ActiveTool::Pen.stroke_tool(false), StrokeTool::Pen);