Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbf699b680 | ||
|
|
ade20ab3dc | ||
|
|
f24e7750e6 | ||
|
|
097933882c | ||
|
|
b3578175c2 | ||
|
|
1cb02bf8e9 | ||
|
|
26c4191e0f |
Generated
+1
-1
@@ -1393,7 +1393,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.26.0"
|
version = "0.26.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ab_glyph",
|
"ab_glyph",
|
||||||
"arboard",
|
"arboard",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.26.0"
|
version = "0.26.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||||
|
|||||||
@@ -61,12 +61,39 @@
|
|||||||
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
|
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
|
||||||
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
|
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
|
||||||
<url type="vcs-browser">https://gitea.pansi.eu/harald/ink-notes</url>
|
<url type="vcs-browser">https://gitea.pansi.eu/harald/ink-notes</url>
|
||||||
|
<screenshots>
|
||||||
|
<screenshot type="default">
|
||||||
|
<caption>Notes with highlighter, handwriting and text boxes</caption>
|
||||||
|
<caption xml:lang="de">Notizen mit Textmarker, Handschrift und Textfeldern</caption>
|
||||||
|
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/notes.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Recognized shapes: coordinate system, triangle, circle, octahedron</caption>
|
||||||
|
<caption xml:lang="de">Erkannte Formen: Koordinatensystem, Dreieck, Kreis, Oktaeder</caption>
|
||||||
|
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/shapes.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Annotating imported PDF slides</caption>
|
||||||
|
<caption xml:lang="de">Importierte PDF-Folien beschriften</caption>
|
||||||
|
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/pdf.png</image>
|
||||||
|
</screenshot>
|
||||||
|
</screenshots>
|
||||||
<branding>
|
<branding>
|
||||||
<color type="primary" scheme_preference="light">#a8c8f0</color>
|
<color type="primary" scheme_preference="light">#a8c8f0</color>
|
||||||
<color type="primary" scheme_preference="dark">#1f3a5f</color>
|
<color type="primary" scheme_preference="dark">#1f3a5f</color>
|
||||||
</branding>
|
</branding>
|
||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
<releases>
|
<releases>
|
||||||
|
<release version="0.26.2" date="2026-10-13">
|
||||||
|
<description>
|
||||||
|
<p>Smoother scrolling through large imported PDFs: pages no longer stall the display while they are prepared, and pages scrolled past quickly are skipped.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.26.1" date="2026-10-10">
|
||||||
|
<description>
|
||||||
|
<p>Screenshots in the app description for software centers. The app itself is unchanged.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
<release version="0.26.0" date="2026-10-10">
|
<release version="0.26.0" date="2026-10-10">
|
||||||
<description>
|
<description>
|
||||||
<p>Select and move objects with a finger; text and make-space tools also work with a finger. Exports go to the documents folder of the user directories.</p>
|
<p>Select and move objects with a finger; text and make-space tools also work with a finger. Exports go to the documents folder of the user directories.</p>
|
||||||
|
|||||||
@@ -50,6 +50,4 @@ modules:
|
|||||||
- target
|
- target
|
||||||
- .git
|
- .git
|
||||||
- .flatpak-builder
|
- .flatpak-builder
|
||||||
- ideas.txt
|
|
||||||
- issue.txt
|
|
||||||
- cargo-sources.json
|
- cargo-sources.json
|
||||||
|
|||||||
@@ -61,12 +61,39 @@
|
|||||||
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
|
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
|
||||||
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
|
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
|
||||||
<url type="vcs-browser">https://gitea.pansi.eu/harald/ink-notes</url>
|
<url type="vcs-browser">https://gitea.pansi.eu/harald/ink-notes</url>
|
||||||
|
<screenshots>
|
||||||
|
<screenshot type="default">
|
||||||
|
<caption>Notes with highlighter, handwriting and text boxes</caption>
|
||||||
|
<caption xml:lang="de">Notizen mit Textmarker, Handschrift und Textfeldern</caption>
|
||||||
|
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/notes.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Recognized shapes: coordinate system, triangle, circle, octahedron</caption>
|
||||||
|
<caption xml:lang="de">Erkannte Formen: Koordinatensystem, Dreieck, Kreis, Oktaeder</caption>
|
||||||
|
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/shapes.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Annotating imported PDF slides</caption>
|
||||||
|
<caption xml:lang="de">Importierte PDF-Folien beschriften</caption>
|
||||||
|
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/pdf.png</image>
|
||||||
|
</screenshot>
|
||||||
|
</screenshots>
|
||||||
<branding>
|
<branding>
|
||||||
<color type="primary" scheme_preference="light">#a8c8f0</color>
|
<color type="primary" scheme_preference="light">#a8c8f0</color>
|
||||||
<color type="primary" scheme_preference="dark">#1f3a5f</color>
|
<color type="primary" scheme_preference="dark">#1f3a5f</color>
|
||||||
</branding>
|
</branding>
|
||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
<releases>
|
<releases>
|
||||||
|
<release version="0.26.2" date="2026-10-13">
|
||||||
|
<description>
|
||||||
|
<p>Smoother scrolling through large imported PDFs: pages no longer stall the display while they are prepared, and pages scrolled past quickly are skipped.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.26.1" date="2026-10-10">
|
||||||
|
<description>
|
||||||
|
<p>Screenshots in the app description for software centers. The app itself is unchanged.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
<release version="0.26.0" date="2026-10-10">
|
<release version="0.26.0" date="2026-10-10">
|
||||||
<description>
|
<description>
|
||||||
<p>Select and move objects with a finger; text and make-space tools also work with a finger. Exports go to the documents folder of the user directories.</p>
|
<p>Select and move objects with a finger; text and make-space tools also work with a finger. Exports go to the documents folder of the user directories.</p>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
|
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
|
||||||
|
|
||||||
### Highlights:
|
### Highlights:
|
||||||
|
- **Flüssigeres Scrollen in großen PDFs**: Neu gerenderte PDF-Seiten halten die Anzeige nicht mehr an, und Seiten, über die man schnell hinwegscrollt, werden übersprungen statt der Reihe nach gerendert
|
||||||
|
- **Screenshots für Flathub**: Die App-Beschreibung (Metainfo) zeigt jetzt drei Screenshots; an der App selbst ändert sich nichts
|
||||||
- **Auswählen mit dem Finger**: Im Auswahlwerkzeug wählt und verschiebt ein Finger Objekte (auf leerer Fläche scrollt er weiter); Text und Platz schaffen gehen ebenfalls mit dem Finger
|
- **Auswählen mit dem Finger**: Im Auswahlwerkzeug wählt und verschiebt ein Finger Objekte (auf leerer Fläche scrollt er weiter); Text und Platz schaffen gehen ebenfalls mit dem Finger
|
||||||
- **Flatpak**: Manifest unter packaging/flatpak (Vorbereitung für Flathub); Exporte landen im Dokumente-Ordner aus den Benutzerordnern (z. B. ~/Dokumente)
|
- **Flatpak**: Manifest unter packaging/flatpak (Vorbereitung für Flathub); Exporte landen im Dokumente-Ordner aus den Benutzerordnern (z. B. ~/Dokumente)
|
||||||
- **Englische Oberfläche / English interface**: ink-notes folgt der Systemsprache (Deutsch oder Englisch); umstellen im ☰-Menü unter „Sprache / Language“
|
- **Englische Oberfläche / English interface**: ink-notes folgt der Systemsprache (Deutsch oder Englisch); umstellen im ☰-Menü unter „Sprache / Language“
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 184 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
+94
-27
@@ -45,26 +45,57 @@ pub fn quad_vertices(corners: &[glam::Vec2; 4]) -> [ImageVertex; 6] {
|
|||||||
[tl, tr, bl, bl, tr, br]
|
[tl, tr, bl, bl, tr, br]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mipmap-Stufen eines Bildes, jeweils halbe Kantenlänge bis 1 × 1.
|
/// Mipmap-Stufen eines Bildes, jeweils halbe Kantenlänge bis 1 × 1. Für eine gerenderte
|
||||||
pub fn mip_chain(base: &RgbaImage) -> Vec<RgbaImage> {
|
/// PDF-Seite dauert das spürbar (2560 px: einige 10 ms); deshalb erzeugt sie der
|
||||||
let mut levels = vec![base.clone()];
|
/// PDF-Render-Thread und nicht der Haupt-Thread.
|
||||||
let Some(mut current) =
|
pub fn mip_chain(base: RgbaImage) -> Vec<RgbaImage> {
|
||||||
image::RgbaImage::from_raw(base.width, base.height, base.pixels.clone())
|
let mut levels = vec![base];
|
||||||
else {
|
loop {
|
||||||
return levels;
|
let current = &levels[levels.len() - 1];
|
||||||
};
|
if current.width <= 1 && current.height <= 1 {
|
||||||
while current.width() > 1 || current.height() > 1 {
|
break;
|
||||||
let (w, h) = ((current.width() / 2).max(1), (current.height() / 2).max(1));
|
}
|
||||||
current = image::imageops::resize(¤t, w, h, image::imageops::FilterType::Triangle);
|
let next = half_size(current);
|
||||||
levels.push(RgbaImage {
|
levels.push(next);
|
||||||
width: w,
|
|
||||||
height: h,
|
|
||||||
pixels: current.as_raw().clone(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
levels
|
levels
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Halbiert ein Bild: Mittelwert aus je 2 × 2 Pixeln (bei ungerader Kante entfällt die
|
||||||
|
/// letzte Spalte bzw. Zeile, bei Kantenlänge 1 wird nur in der anderen Richtung gemittelt).
|
||||||
|
fn half_size(image: &RgbaImage) -> RgbaImage {
|
||||||
|
let (w, h) = (image.width as usize, image.height as usize);
|
||||||
|
let (nw, nh) = ((w / 2).max(1), (h / 2).max(1));
|
||||||
|
let mut pixels = vec![0u8; nw * nh * 4];
|
||||||
|
if image.pixels.len() < w * h * 4 {
|
||||||
|
return RgbaImage {
|
||||||
|
width: nw as u32,
|
||||||
|
height: nh as u32,
|
||||||
|
pixels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
for y in 0..nh {
|
||||||
|
let (y0, y1) = ((2 * y).min(h - 1), (2 * y + 1).min(h - 1));
|
||||||
|
let (row0, row1) = (&image.pixels[y0 * w * 4..], &image.pixels[y1 * w * 4..]);
|
||||||
|
let out = &mut pixels[y * nw * 4..(y + 1) * nw * 4];
|
||||||
|
for x in 0..nw {
|
||||||
|
let (x0, x1) = ((2 * x).min(w - 1) * 4, (2 * x + 1).min(w - 1) * 4);
|
||||||
|
for c in 0..4 {
|
||||||
|
let sum = row0[x0 + c] as u16
|
||||||
|
+ row0[x1 + c] as u16
|
||||||
|
+ row1[x0 + c] as u16
|
||||||
|
+ row1[x1 + c] as u16;
|
||||||
|
out[x * 4 + c] = ((sum + 2) / 4) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RgbaImage {
|
||||||
|
width: nw as u32,
|
||||||
|
height: nh as u32,
|
||||||
|
pixels,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Name der Ersatztextur für Bilder, deren Datei fehlt oder beschädigt ist.
|
/// Name der Ersatztextur für Bilder, deren Datei fehlt oder beschädigt ist.
|
||||||
const MISSING: &str = "";
|
const MISSING: &str = "";
|
||||||
/// Bis zu dieser Grafikspeichermenge bleiben Texturen anderer Seiten erhalten (schnelles
|
/// Bis zu dieser Grafikspeichermenge bleiben Texturen anderer Seiten erhalten (schnelles
|
||||||
@@ -74,6 +105,8 @@ const TEXTURE_CACHE_BYTES: u64 = 384 * 1024 * 1024;
|
|||||||
/// Antwort einer Texturquelle für einen Schlüssel.
|
/// Antwort einer Texturquelle für einen Schlüssel.
|
||||||
pub enum TextureSource {
|
pub enum TextureSource {
|
||||||
Ready(RgbaImage),
|
Ready(RgbaImage),
|
||||||
|
/// Fertige Mipmap-Stufen (siehe [`mip_chain`]), z. B. vom PDF-Render-Thread
|
||||||
|
ReadyMips(Vec<RgbaImage>),
|
||||||
/// Wird gerade erzeugt; später erneut fragen
|
/// Wird gerade erzeugt; später erneut fragen
|
||||||
Pending,
|
Pending,
|
||||||
Failed(String),
|
Failed(String),
|
||||||
@@ -233,10 +266,16 @@ impl ImageLayer {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let (bind_group, bytes) = match load(&key) {
|
let (bind_group, bytes) = match load(&key) {
|
||||||
TextureSource::Ready(image) => (
|
TextureSource::Ready(image) => {
|
||||||
self.upload_texture(device, queue, &key, &image),
|
self.upload_texture(device, queue, &key, mip_chain(image))
|
||||||
image.pixels.len() as u64 * 4 / 3,
|
}
|
||||||
),
|
TextureSource::ReadyMips(levels) if !levels.is_empty() => {
|
||||||
|
self.upload_texture(device, queue, &key, levels)
|
||||||
|
}
|
||||||
|
TextureSource::ReadyMips(_) => {
|
||||||
|
eprintln!("[Bilder] '{key}' kann nicht angezeigt werden: leeres Bild");
|
||||||
|
(self.missing_texture(device, queue), 4)
|
||||||
|
}
|
||||||
// Noch in Arbeit (PDF-Seite wird gerendert): beim nächsten Bild erneut fragen
|
// Noch in Arbeit (PDF-Seite wird gerendert): beim nächsten Bild erneut fragen
|
||||||
TextureSource::Pending => continue,
|
TextureSource::Pending => continue,
|
||||||
TextureSource::Failed(err) => {
|
TextureSource::Failed(err) => {
|
||||||
@@ -342,17 +381,19 @@ impl ImageLayer {
|
|||||||
height: 1,
|
height: 1,
|
||||||
pixels: vec![200, 200, 200, 255],
|
pixels: vec![200, 200, 200, 255],
|
||||||
};
|
};
|
||||||
self.upload_texture(device, queue, MISSING, &gray)
|
self.upload_texture(device, queue, MISSING, vec![gray]).0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lädt die Mipmap-Stufen hoch (Stufe 0 = volle Größe); liefert die Bind Group und den
|
||||||
|
/// belegten Grafikspeicher.
|
||||||
fn upload_texture(
|
fn upload_texture(
|
||||||
&self,
|
&self,
|
||||||
device: &wgpu::Device,
|
device: &wgpu::Device,
|
||||||
queue: &wgpu::Queue,
|
queue: &wgpu::Queue,
|
||||||
label: &str,
|
label: &str,
|
||||||
image: &RgbaImage,
|
levels: Vec<RgbaImage>,
|
||||||
) -> wgpu::BindGroup {
|
) -> (wgpu::BindGroup, u64) {
|
||||||
let levels = mip_chain(image);
|
let image = &levels[0];
|
||||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||||
label: Some(label),
|
label: Some(label),
|
||||||
size: wgpu::Extent3d {
|
size: wgpu::Extent3d {
|
||||||
@@ -389,8 +430,9 @@ impl ImageLayer {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
let bytes = levels.iter().map(|l| l.pixels.len() as u64).sum();
|
||||||
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
label: Some("Image Texture Bind Group"),
|
label: Some("Image Texture Bind Group"),
|
||||||
layout: &self.texture_layout,
|
layout: &self.texture_layout,
|
||||||
entries: &[
|
entries: &[
|
||||||
@@ -403,7 +445,8 @@ impl ImageLayer {
|
|||||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
|
(bind_group, bytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,11 +482,35 @@ mod tests {
|
|||||||
height: 3,
|
height: 3,
|
||||||
pixels: vec![128; 8 * 3 * 4],
|
pixels: vec![128; 8 * 3 * 4],
|
||||||
};
|
};
|
||||||
let levels = mip_chain(&base);
|
let levels = mip_chain(base);
|
||||||
let sizes: Vec<(u32, u32)> = levels.iter().map(|l| (l.width, l.height)).collect();
|
let sizes: Vec<(u32, u32)> = levels.iter().map(|l| (l.width, l.height)).collect();
|
||||||
assert_eq!(sizes, vec![(8, 3), (4, 1), (2, 1), (1, 1)]);
|
assert_eq!(sizes, vec![(8, 3), (4, 1), (2, 1), (1, 1)]);
|
||||||
for l in &levels {
|
for l in &levels {
|
||||||
assert_eq!(l.pixels.len(), (l.width * l.height * 4) as usize);
|
assert_eq!(l.pixels.len(), (l.width * l.height * 4) as usize);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_half_size_averages_two_by_two_blocks() {
|
||||||
|
// 3 × 2: links schwarz/weiß übereinander, Mitte grau; die dritte Spalte entfällt
|
||||||
|
let px = |v: u8| [v, v, v, 255];
|
||||||
|
let pixels = [px(0), px(100), px(7), px(255), px(100), px(7)].concat();
|
||||||
|
let base = RgbaImage {
|
||||||
|
width: 3,
|
||||||
|
height: 2,
|
||||||
|
pixels,
|
||||||
|
};
|
||||||
|
let half = half_size(&base);
|
||||||
|
assert_eq!((half.width, half.height), (1, 1));
|
||||||
|
// (0 + 100 + 255 + 100) / 4 = 113,75 → 114
|
||||||
|
assert_eq!(half.pixels, vec![114, 114, 114, 255]);
|
||||||
|
|
||||||
|
// Kantenlänge 1: nur in der anderen Richtung mitteln
|
||||||
|
let column = RgbaImage {
|
||||||
|
width: 1,
|
||||||
|
height: 2,
|
||||||
|
pixels: [px(10), px(20)].concat(),
|
||||||
|
};
|
||||||
|
assert_eq!(half_size(&column).pixels, vec![15, 15, 15, 255]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-18
@@ -941,25 +941,21 @@ fn texture_for(
|
|||||||
assets: &AssetStore,
|
assets: &AssetStore,
|
||||||
pdf_renderer: &mut PdfRenderService,
|
pdf_renderer: &mut PdfRenderService,
|
||||||
) -> TextureSource {
|
) -> TextureSource {
|
||||||
let result = match key.contains('#') {
|
if !key.contains('#') {
|
||||||
true => {
|
return match assets.load_rgba(key, ink_notes::assets::MAX_DISPLAY_DIMENSION) {
|
||||||
let Some((Ok(path), page, max_pixels)) = ink_notes::pdf_doc::parse_page_key(key)
|
Ok(image) => TextureSource::Ready(image),
|
||||||
.map(|(asset, page, px)| (assets.path_of(asset), page, px))
|
Err(err) => TextureSource::Failed(err.to_string()),
|
||||||
else {
|
};
|
||||||
return TextureSource::Failed(t!("Ungültiger PDF-Verweis '{key}'", key = key));
|
}
|
||||||
};
|
let Some((Ok(path), page, max_pixels)) = ink_notes::pdf_doc::parse_page_key(key)
|
||||||
match pdf_renderer.take(key, path, page, max_pixels) {
|
.map(|(asset, page, px)| (assets.path_of(asset), page, px))
|
||||||
Some(result) => result,
|
else {
|
||||||
None => return TextureSource::Pending,
|
return TextureSource::Failed(t!("Ungültiger PDF-Verweis '{key}'", key = key));
|
||||||
}
|
|
||||||
}
|
|
||||||
false => assets
|
|
||||||
.load_rgba(key, ink_notes::assets::MAX_DISPLAY_DIMENSION)
|
|
||||||
.map_err(|e| e.to_string()),
|
|
||||||
};
|
};
|
||||||
match result {
|
match pdf_renderer.take(key, path, page, max_pixels) {
|
||||||
Ok(image) => TextureSource::Ready(image),
|
Some(Ok(levels)) => TextureSource::ReadyMips(levels),
|
||||||
Err(err) => TextureSource::Failed(err),
|
Some(Err(err)) => TextureSource::Failed(err),
|
||||||
|
None => TextureSource::Pending,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3924,6 +3920,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
canvas.set_neighbor_pages(&[]);
|
canvas.set_neighbor_pages(&[]);
|
||||||
}
|
}
|
||||||
let assets = AssetStore::new(&storage_dir);
|
let assets = AssetStore::new(&storage_dir);
|
||||||
|
// PDF-Seiten, die nach diesem Bild nicht mehr gebraucht werden (schnelles
|
||||||
|
// Scrollen), verwirft end_frame, bevor sie gerendert werden
|
||||||
|
pdf_renderer.begin_frame();
|
||||||
canvas.prepare_images(&mut |key| texture_for(key, &assets, &mut pdf_renderer));
|
canvas.prepare_images(&mut |key| texture_for(key, &assets, &mut pdf_renderer));
|
||||||
// Nächste PDF-Seite vorab rendern, damit Blättern sofort klappt
|
// Nächste PDF-Seite vorab rendern, damit Blättern sofort klappt
|
||||||
if !canvas.is_waiting_for_images() {
|
if !canvas.is_waiting_for_images() {
|
||||||
@@ -3934,6 +3933,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pdf_renderer.end_frame();
|
||||||
match canvas.render(&clipped_primitives, &screen_descriptor, &full_output.textures_delta) {
|
match canvas.render(&clipped_primitives, &screen_descriptor, &full_output.textures_delta) {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
|
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
|
||||||
|
|||||||
+239
-29
@@ -1,11 +1,13 @@
|
|||||||
//! Lesen und Rendern importierter PDF-Dateien mit `hayro` (reines Rust, nur CPU).
|
//! Lesen und Rendern importierter PDF-Dateien mit `hayro` (reines Rust, nur CPU).
|
||||||
//!
|
//!
|
||||||
//! Das Rendern einer Seite kann einige hundert Millisekunden dauern; [`PdfRenderService`]
|
//! Das Rendern einer Seite kann einige hundert Millisekunden dauern; [`PdfRenderService`]
|
||||||
//! erledigt es deshalb in einem Hintergrund-Thread und meldet fertige Seiten zurück.
|
//! erledigt es deshalb in einem Hintergrund-Thread, samt Mipmaps für die Anzeige, und meldet
|
||||||
|
//! fertige Seiten zurück.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::mpsc::{self, Receiver, Sender};
|
use std::sync::mpsc::{self, Receiver};
|
||||||
|
use std::sync::{Arc, Condvar, Mutex};
|
||||||
|
|
||||||
use hayro::hayro_interpret::InterpreterSettings;
|
use hayro::hayro_interpret::InterpreterSettings;
|
||||||
use hayro::hayro_syntax::{LoadPdfError, Pdf};
|
use hayro::hayro_syntax::{LoadPdfError, Pdf};
|
||||||
@@ -104,30 +106,107 @@ struct RenderRequest {
|
|||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
page: usize,
|
page: usize,
|
||||||
max_pixels: u32,
|
max_pixels: u32,
|
||||||
|
/// Wichtigkeit: kleiner zuerst (Reihenfolge der Anfragen im aktuellen Bild)
|
||||||
|
rank: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ergebnis eines Render-Auftrags.
|
/// Wichtigkeit einer vorab gerenderten Seite: nach allen sichtbaren.
|
||||||
pub type RenderResult = Result<RgbaImage, String>;
|
const PREFETCH_RANK: usize = usize::MAX;
|
||||||
|
|
||||||
|
/// Warteschlange der noch nicht begonnenen Render-Aufträge, geteilt mit dem Render-Thread.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RenderQueue {
|
||||||
|
requests: Vec<RenderRequest>,
|
||||||
|
shutdown: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderQueue {
|
||||||
|
/// Fügt einen Auftrag hinzu oder macht einen wartenden wichtiger.
|
||||||
|
fn push(&mut self, request: RenderRequest) {
|
||||||
|
match self.requests.iter_mut().find(|r| r.key == request.key) {
|
||||||
|
Some(queued) => queued.rank = queued.rank.min(request.rank),
|
||||||
|
None => self.requests.push(request),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nimmt den wichtigsten Auftrag heraus.
|
||||||
|
fn pop(&mut self) -> Option<RenderRequest> {
|
||||||
|
let (index, _) = self
|
||||||
|
.requests
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.min_by_key(|(_, r)| r.rank)?;
|
||||||
|
Some(self.requests.swap_remove(index))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verwirft Aufträge, für die `rank_of` keinen Rang mehr liefert, und ordnet die übrigen
|
||||||
|
/// neu. Liefert die Schlüssel der verworfenen.
|
||||||
|
fn retain(&mut self, rank_of: impl Fn(&str) -> Option<usize>) -> Vec<String> {
|
||||||
|
let mut dropped = Vec::new();
|
||||||
|
self.requests.retain_mut(|r| match rank_of(&r.key) {
|
||||||
|
Some(rank) => {
|
||||||
|
r.rank = rank;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
dropped.push(r.key.clone());
|
||||||
|
false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dropped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SharedQueue = Arc<(Mutex<RenderQueue>, Condvar)>;
|
||||||
|
|
||||||
|
/// Ergebnis eines Render-Auftrags: die Mipmap-Stufen der Seite (siehe
|
||||||
|
/// [`crate::image_layer::mip_chain`]), Stufe 0 in voller Größe.
|
||||||
|
pub type RenderResult = Result<Vec<RgbaImage>, String>;
|
||||||
|
|
||||||
/// Rendert PDF-Seiten im Hintergrund. Fertige Seiten holt der Event-Loop mit
|
/// Rendert PDF-Seiten im Hintergrund. Fertige Seiten holt der Event-Loop mit
|
||||||
/// [`PdfRenderService::take`] ab; `on_ready` weckt ihn dafür auf.
|
/// [`PdfRenderService::take`] ab; `on_ready` weckt ihn dafür auf.
|
||||||
|
///
|
||||||
|
/// Je Bild meldet der Event-Loop zwischen [`Self::begin_frame`] und [`Self::end_frame`] mit
|
||||||
|
/// `take` alle Seiten an, die er gerade braucht (die wichtigste zuerst). Gerendert wird die
|
||||||
|
/// wichtigste wartende Seite zuerst; Seiten, die beim schnellen Scrollen schon wieder aus dem
|
||||||
|
/// Bild sind, verwirft `end_frame`, bevor der Render-Thread sie beginnt.
|
||||||
pub struct PdfRenderService {
|
pub struct PdfRenderService {
|
||||||
requests: Sender<RenderRequest>,
|
queue: SharedQueue,
|
||||||
|
worker: std::thread::JoinHandle<()>,
|
||||||
results: Receiver<(String, RenderResult)>,
|
results: Receiver<(String, RenderResult)>,
|
||||||
|
/// Wartende oder gerade gerenderte Seiten
|
||||||
pending: HashSet<String>,
|
pending: HashSet<String>,
|
||||||
ready: HashMap<String, RenderResult>,
|
ready: HashMap<String, RenderResult>,
|
||||||
|
/// Im aktuellen Bild angeforderte Seiten in ihrer Reihenfolge
|
||||||
|
wanted: Vec<String>,
|
||||||
|
/// Zuletzt vorab angeforderte Seite (bleibt erhalten, bis sie abgeholt wird)
|
||||||
|
prefetched: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PdfRenderService {
|
impl PdfRenderService {
|
||||||
pub fn new(on_ready: impl Fn() + Send + 'static) -> Self {
|
pub fn new(on_ready: impl Fn() + Send + 'static) -> Self {
|
||||||
let (req_tx, req_rx) = mpsc::channel::<RenderRequest>();
|
let queue: SharedQueue = Arc::default();
|
||||||
let (res_tx, res_rx) = mpsc::channel();
|
let (res_tx, res_rx) = mpsc::channel();
|
||||||
std::thread::Builder::new()
|
let worker_queue = Arc::clone(&queue);
|
||||||
|
let worker = std::thread::Builder::new()
|
||||||
.name("pdf-render".into())
|
.name("pdf-render".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
// Zuletzt geöffnetes PDF behalten: Blättern rendert meist dieselbe Datei
|
// Zuletzt geöffnetes PDF behalten: Blättern rendert meist dieselbe Datei
|
||||||
let mut open_pdf: Option<(PathBuf, Pdf)> = None;
|
let mut open_pdf: Option<(PathBuf, Pdf)> = None;
|
||||||
while let Ok(request) = req_rx.recv() {
|
loop {
|
||||||
|
let request = {
|
||||||
|
let (lock, ready) = &*worker_queue;
|
||||||
|
let mut queue = lock.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
loop {
|
||||||
|
if queue.shutdown {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(request) = queue.pop() {
|
||||||
|
break request;
|
||||||
|
}
|
||||||
|
queue = ready.wait(queue).unwrap_or_else(|e| e.into_inner());
|
||||||
|
}
|
||||||
|
};
|
||||||
if open_pdf.as_ref().map(|(p, _)| p) != Some(&request.path) {
|
if open_pdf.as_ref().map(|(p, _)| p) != Some(&request.path) {
|
||||||
open_pdf = std::fs::read(&request.path)
|
open_pdf = std::fs::read(&request.path)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
@@ -137,36 +216,64 @@ impl PdfRenderService {
|
|||||||
.map(|pdf| (request.path.clone(), pdf));
|
.map(|pdf| (request.path.clone(), pdf));
|
||||||
}
|
}
|
||||||
let result = match &open_pdf {
|
let result = match &open_pdf {
|
||||||
Some((_, pdf)) => render_page(pdf, request.page, request.max_pixels),
|
// Mipmaps hier statt im Haupt-Thread: sonst stockt das Scrollen bei
|
||||||
|
// jeder neu gerenderten Seite (Issue #28)
|
||||||
|
Some((_, pdf)) => render_page(pdf, request.page, request.max_pixels)
|
||||||
|
.map(crate::image_layer::mip_chain),
|
||||||
None => Err(t!("PDF kann nicht geöffnet werden").into()),
|
None => Err(t!("PDF kann nicht geöffnet werden").into()),
|
||||||
};
|
};
|
||||||
if res_tx.send((request.key, result)).is_err() {
|
if res_tx.send((request.key, result)).is_err() {
|
||||||
break;
|
return;
|
||||||
}
|
}
|
||||||
on_ready();
|
on_ready();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.expect("PDF-Render-Thread konnte nicht gestartet werden");
|
.expect("PDF-Render-Thread konnte nicht gestartet werden");
|
||||||
Self {
|
Self {
|
||||||
requests: req_tx,
|
queue,
|
||||||
|
worker,
|
||||||
results: res_rx,
|
results: res_rx,
|
||||||
pending: HashSet::new(),
|
pending: HashSet::new(),
|
||||||
ready: HashMap::new(),
|
ready: HashMap::new(),
|
||||||
|
wanted: Vec::new(),
|
||||||
|
prefetched: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Beginnt die Anfragen eines neuen Bildes (siehe [`Self::end_frame`]).
|
||||||
|
pub fn begin_frame(&mut self) {
|
||||||
|
self.wanted.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verwirft wartende Aufträge und fertige Ergebnisse für Seiten, die in diesem Bild nicht
|
||||||
|
/// mehr angefordert wurden (außer der vorab angeforderten), und ordnet die übrigen nach
|
||||||
|
/// der Reihenfolge der Anfragen.
|
||||||
|
pub fn end_frame(&mut self) {
|
||||||
|
let rank_of = |key: &str| {
|
||||||
|
self.wanted
|
||||||
|
.iter()
|
||||||
|
.position(|k| k == key)
|
||||||
|
.or_else(|| (self.prefetched.as_deref() == Some(key)).then_some(PREFETCH_RANK))
|
||||||
|
};
|
||||||
|
let dropped = {
|
||||||
|
let mut queue = self.queue.0.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
queue.retain(rank_of)
|
||||||
|
};
|
||||||
|
for key in dropped {
|
||||||
|
self.pending.remove(&key);
|
||||||
|
}
|
||||||
|
self.ready.retain(|key, _| rank_of(key).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
/// Rendert eine Seite vorab (z. B. die nächste), ohne auf das Ergebnis zu warten;
|
/// Rendert eine Seite vorab (z. B. die nächste), ohne auf das Ergebnis zu warten;
|
||||||
/// [`Self::take`] liefert es später sofort.
|
/// [`Self::take`] liefert es später sofort.
|
||||||
pub fn prefetch(&mut self, key: &str, path: PathBuf, page: usize) {
|
pub fn prefetch(&mut self, key: &str, path: PathBuf, page: usize) {
|
||||||
self.collect();
|
self.collect();
|
||||||
if self.ready.contains_key(key) || self.pending.contains(key) {
|
self.prefetched = Some(key.to_string());
|
||||||
|
if self.ready.contains_key(key) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Vorab gerenderte Seiten nicht unbegrenzt aufheben
|
self.request(key, path, page, DISPLAY_RENDER_PIXELS, PREFETCH_RANK);
|
||||||
if self.ready.len() >= 2 {
|
|
||||||
self.ready.clear();
|
|
||||||
}
|
|
||||||
self.request(key, path, page, DISPLAY_RENDER_PIXELS);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect(&mut self) {
|
fn collect(&mut self) {
|
||||||
@@ -176,21 +283,37 @@ impl PdfRenderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Erteilt den Auftrag, falls noch keiner läuft. `false`, wenn der Thread beendet ist.
|
/// Erteilt den Auftrag bzw. macht einen wartenden wichtiger. `false`, wenn der
|
||||||
fn request(&mut self, key: &str, path: PathBuf, page: usize, max_pixels: u32) -> bool {
|
/// Render-Thread beendet ist.
|
||||||
if !self.pending.insert(key.to_string()) {
|
fn request(
|
||||||
|
&mut self,
|
||||||
|
key: &str,
|
||||||
|
path: PathBuf,
|
||||||
|
page: usize,
|
||||||
|
max_pixels: u32,
|
||||||
|
rank: usize,
|
||||||
|
) -> bool {
|
||||||
|
if self.worker.is_finished() {
|
||||||
|
self.pending.remove(key);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let (lock, ready) = &*self.queue;
|
||||||
|
let mut queue = lock.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
// Steht ein angefragter Auftrag nicht mehr in der Warteschlange, rendert ihn der
|
||||||
|
// Thread gerade: nicht doppelt erteilen
|
||||||
|
let waiting = queue.requests.iter().any(|r| r.key == key);
|
||||||
|
if self.pending.contains(key) && !waiting {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let request = RenderRequest {
|
self.pending.insert(key.to_string());
|
||||||
|
queue.push(RenderRequest {
|
||||||
key: key.to_string(),
|
key: key.to_string(),
|
||||||
path,
|
path,
|
||||||
page,
|
page,
|
||||||
max_pixels,
|
max_pixels,
|
||||||
};
|
rank,
|
||||||
if self.requests.send(request).is_err() {
|
});
|
||||||
self.pending.remove(key);
|
ready.notify_one();
|
||||||
return false;
|
|
||||||
}
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,16 +327,29 @@ impl PdfRenderService {
|
|||||||
max_pixels: u32,
|
max_pixels: u32,
|
||||||
) -> Option<RenderResult> {
|
) -> Option<RenderResult> {
|
||||||
self.collect();
|
self.collect();
|
||||||
|
if self.prefetched.as_deref() == Some(key) {
|
||||||
|
self.prefetched = None;
|
||||||
|
}
|
||||||
if let Some(result) = self.ready.remove(key) {
|
if let Some(result) = self.ready.remove(key) {
|
||||||
return Some(result);
|
return Some(result);
|
||||||
}
|
}
|
||||||
if !self.request(key, path, page, max_pixels) {
|
let rank = self.wanted.len();
|
||||||
|
self.wanted.push(key.to_string());
|
||||||
|
if !self.request(key, path, page, max_pixels, rank) {
|
||||||
return Some(Err(t!("PDF-Render-Thread beendet").into()));
|
return Some(Err(t!("PDF-Render-Thread beendet").into()));
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Drop for PdfRenderService {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let (lock, ready) = &*self.queue;
|
||||||
|
lock.lock().unwrap_or_else(|e| e.into_inner()).shutdown = true;
|
||||||
|
ready.notify_all();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) mod tests {
|
pub(crate) mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -303,8 +439,12 @@ pub(crate) mod tests {
|
|||||||
wake_rx
|
wake_rx
|
||||||
.recv_timeout(std::time::Duration::from_secs(20))
|
.recv_timeout(std::time::Duration::from_secs(20))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let image = service.take("a#0", path.clone(), 0, px).unwrap().unwrap();
|
let levels = service.take("a#0", path.clone(), 0, px).unwrap().unwrap();
|
||||||
assert_eq!(image.width, DISPLAY_RENDER_PIXELS);
|
assert_eq!(levels[0].width, DISPLAY_RENDER_PIXELS);
|
||||||
|
assert_eq!(
|
||||||
|
(levels.last().unwrap().width, levels.last().unwrap().height),
|
||||||
|
(1, 1)
|
||||||
|
);
|
||||||
|
|
||||||
// Vorab rendern: danach sofort verfügbar
|
// Vorab rendern: danach sofort verfügbar
|
||||||
service.prefetch("a#0", path.clone(), 0);
|
service.prefetch("a#0", path.clone(), 0);
|
||||||
@@ -314,6 +454,76 @@ pub(crate) mod tests {
|
|||||||
assert!(service.take("a#0", path, 0, px).unwrap().is_ok());
|
assert!(service.take("a#0", path, 0, px).unwrap().is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn request(key: &str, rank: usize) -> RenderRequest {
|
||||||
|
RenderRequest {
|
||||||
|
key: key.into(),
|
||||||
|
path: PathBuf::new(),
|
||||||
|
page: 0,
|
||||||
|
max_pixels: 1,
|
||||||
|
rank,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_queue_priorities_and_stale_requests() {
|
||||||
|
let mut queue = RenderQueue::default();
|
||||||
|
queue.push(request("a", 2));
|
||||||
|
queue.push(request("b", 0));
|
||||||
|
queue.push(request("c", 1));
|
||||||
|
// Erneute Anfrage macht einen wartenden Auftrag wichtiger, ohne ihn zu verdoppeln
|
||||||
|
queue.push(request("a", 0));
|
||||||
|
assert_eq!(queue.requests.len(), 3);
|
||||||
|
assert_eq!(queue.pop().map(|r| r.key), Some("a".into()));
|
||||||
|
|
||||||
|
// Nach dem Scrollen wird nur noch „c“ gebraucht: „b“ fällt weg
|
||||||
|
let dropped = queue.retain(|key| (key == "c").then_some(0));
|
||||||
|
assert_eq!(dropped, vec!["b".to_string()]);
|
||||||
|
assert_eq!(queue.pop().map(|r| r.key), Some("c".into()));
|
||||||
|
assert!(queue.pop().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_end_frame_drops_unwanted_results_but_keeps_prefetch() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("a.pdf");
|
||||||
|
std::fs::write(&path, sample_pdf(&[(400.0, 300.0, 0), (400.0, 300.0, 0)])).unwrap();
|
||||||
|
let (wake_tx, wake_rx) = mpsc::channel();
|
||||||
|
let mut service = PdfRenderService::new(move || {
|
||||||
|
let _ = wake_tx.send(());
|
||||||
|
});
|
||||||
|
let px = DISPLAY_RENDER_PIXELS;
|
||||||
|
let wait = || {
|
||||||
|
wake_rx
|
||||||
|
.recv_timeout(std::time::Duration::from_secs(20))
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bild 1 braucht Seite 1; vorab gerendert wird Seite 2
|
||||||
|
service.begin_frame();
|
||||||
|
assert!(service.take("a#0", path.clone(), 0, px).is_none());
|
||||||
|
service.prefetch("a#1", path.clone(), 1);
|
||||||
|
service.end_frame();
|
||||||
|
wait();
|
||||||
|
wait();
|
||||||
|
|
||||||
|
// Bild 2 braucht keine Seite mehr (weggescrollt): Seite 1 wird verworfen,
|
||||||
|
// die vorab gerenderte Seite 2 bleibt
|
||||||
|
service.begin_frame();
|
||||||
|
service.collect();
|
||||||
|
service.end_frame();
|
||||||
|
assert!(!service.ready.contains_key("a#0"));
|
||||||
|
assert!(service.ready.contains_key("a#1"));
|
||||||
|
assert!(service.pending.is_empty());
|
||||||
|
|
||||||
|
service.begin_frame();
|
||||||
|
assert!(service.take("a#1", path.clone(), 1, px).unwrap().is_ok());
|
||||||
|
// Seite 1 wird bei Bedarf neu gerendert
|
||||||
|
assert!(service.take("a#0", path.clone(), 0, px).is_none());
|
||||||
|
service.end_frame();
|
||||||
|
wait();
|
||||||
|
assert!(service.take("a#0", path, 0, px).unwrap().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_page_key() {
|
fn test_parse_page_key() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
Reference in New Issue
Block a user