diff --git a/.github/scripts/dmg-background.swift b/.github/scripts/dmg-background.swift new file mode 100644 index 0000000..c402feb --- /dev/null +++ b/.github/scripts/dmg-background.swift @@ -0,0 +1,175 @@ +#!/usr/bin/env swift +import AppKit +import Foundation + +// Draw the Grimoire disk image background. Finder places the app icon and +// the Applications shortcut over the lower half, so the wide logo stays above them. +// Usage: dmg-background.swift <1x.png> <2x.png> + +let args = CommandLine.arguments +guard args.count == 8 else { + fputs("usage: dmg-background.swift <1x.png> <2x.png> \n", stderr) + exit(1) +} + +let logoURL = URL(fileURLWithPath: args[1]) +let out1x = args[2] +let out2x = args[3] +guard let width = Int(args[4]), let height = Int(args[5]), + let appX = Double(args[6]), let applicationsX = Double(args[7]), + width > 0, height > 0 else { + fputs("width, height, and icon positions must be numbers\n", stderr) + exit(1) +} +guard let source = NSImage(contentsOf: logoURL), source.size.width > 0, source.size.height > 0 else { + fputs("could not read logo \(logoURL.path)\n", stderr) + exit(1) +} + +func color(_ hex: UInt32, _ alpha: CGFloat = 1) -> NSColor { + let red = CGFloat((hex >> 16) & 0xFF) / 255 + let green = CGFloat((hex >> 8) & 0xFF) / 255 + let blue = CGFloat(hex & 0xFF) / 255 + return NSColor(srgbRed: red, green: green, blue: blue, alpha: alpha) +} + +// Quick Look pads a wide SVG into a square with white. Drop that padding so the +// logo keeps its own aspect ratio. +func trimmedLogo(_ image: NSImage) -> NSImage { + guard let rep = NSBitmapImageRep(data: image.tiffRepresentation ?? Data()) else { + return image + } + let pixelsWide = rep.pixelsWide + let pixelsHigh = rep.pixelsHigh + + func isPadding(_ x: Int, _ y: Int) -> Bool { + guard let sample = rep.colorAt(x: x, y: y)?.usingColorSpace(.sRGB) else { + return true + } + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + sample.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return alpha < 0.05 || (red > 0.97 && green > 0.97 && blue > 0.97) + } + + var minX = pixelsWide + var minY = pixelsHigh + var maxX = 0 + var maxY = 0 + for y in 0.. minX + 8, maxY > minY + 8 else { + return image + } + + // Drop the anti-aliased edge left where the logo's background met the + // white padding Quick Look adds around a wide SVG. + let crop = NSRect(x: minX + 3, y: minY + 3, width: maxX - minX - 5, height: maxY - minY - 5) + guard let cropped = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int(crop.width), + pixelsHigh: Int(crop.height), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + ) else { + return image + } + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: cropped) + image.draw( + in: NSRect(x: 0, y: 0, width: crop.width, height: crop.height), + from: crop, + operation: .copy, + fraction: 1 + ) + NSGraphicsContext.restoreGraphicsState() + + let result = NSImage(size: NSSize(width: crop.width, height: crop.height)) + result.addRepresentation(cropped) + return result +} + +let logo = trimmedLogo(source) + +func render(scale: Int, destination: String) { + let pixelsWide = width * scale + let pixelsHigh = height * scale + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: pixelsWide, + pixelsHigh: pixelsHigh, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + ) else { + fputs("could not create background bitmap\n", stderr) + exit(1) + } + + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) + let scale = CGFloat(scale) + let canvas = NSRect(x: 0, y: 0, width: CGFloat(width) * scale, height: CGFloat(height) * scale) + + color(0x1A1714).setFill() + NSBezierPath(rect: canvas).fill() + + let logoWidth = canvas.width * 0.86 + let logoHeight = logoWidth * (logo.size.height / logo.size.width) + let logoRect = NSRect( + x: (canvas.width - logoWidth) / 2, + y: canvas.height - logoHeight - 28 * scale, + width: logoWidth, + height: logoHeight + ) + logo.draw(in: logoRect, from: .zero, operation: .sourceOver, fraction: 1) + + // Finder draws icon names in black on a picture background, and it will not + // keep a custom label color. A light chip under each name keeps that text readable. + func labelChip(centerX: CGFloat, chipWidth: CGFloat) { + let chipHeight = 32 * scale + let rect = NSRect( + x: centerX * scale - chipWidth * scale / 2, + y: 48 * scale, + width: chipWidth * scale, + height: chipHeight + ) + color(0xEFE6D8).setFill() + NSBezierPath(roundedRect: rect, xRadius: 8 * scale, yRadius: 8 * scale).fill() + } + labelChip(centerX: CGFloat(appX), chipWidth: 112) + labelChip(centerX: CGFloat(applicationsX), chipWidth: 132) + + NSGraphicsContext.restoreGraphicsState() + + guard let png = rep.representation(using: .png, properties: [:]) else { + fputs("could not encode background\n", stderr) + exit(1) + } + do { + try png.write(to: URL(fileURLWithPath: destination)) + } catch { + fputs("could not write \(destination): \(error)\n", stderr) + exit(1) + } +} + +render(scale: 1, destination: out1x) +render(scale: 2, destination: out2x) diff --git a/.github/scripts/package-macos.sh b/.github/scripts/package-macos.sh index 928645c..e65aac1 100755 --- a/.github/scripts/package-macos.sh +++ b/.github/scripts/package-macos.sh @@ -26,8 +26,12 @@ fi STAGE="$(mktemp -d "${TMPDIR:-/tmp}/grimoire-app.XXXXXX")" KEYCHAIN_PATH="" PREVIOUS_KEYCHAINS="" +MOUNTED="" cleanup() { + if [[ -n "${MOUNTED}" ]]; then + hdiutil detach "${MOUNTED}" >/dev/null 2>&1 || hdiutil detach -force "${MOUNTED}" >/dev/null 2>&1 || true + fi if [[ -n "${KEYCHAIN_PATH}" ]]; then security delete-keychain "${KEYCHAIN_PATH}" >/dev/null 2>&1 || true fi @@ -115,20 +119,124 @@ if [[ -n "${IDENTITY}" ]]; then codesign --force --options runtime --timestamp --sign "${IDENTITY}" "${APP}" fi +# Window matches the background image. Icon positions are centers: +# Grimoire.app on the left, Applications on the right. +DMG_WIDTH=680 +DMG_HEIGHT=480 +DMG_ICON_SIZE=128 +DMG_APP_X=180 +DMG_APP_Y=330 +DMG_APPLICATIONS_X=500 +DMG_APPLICATIONS_Y=330 + +write_styled_dmg() { + local root="$1" + local dest="$2" + local rw="${STAGE}/grimoire-rw.dmg" + local logo_dir="${STAGE}/logo" + local logo="${logo_dir}/grimoire_logo-wide-dark.svg.png" + local volname + + mkdir -p "${logo_dir}" + # Quick Look renders the wide SVG. The background script crops the square padding. + qlmanage -t -s 3200 -o "${logo_dir}" "${ROOT}/static/grimoire_logo-wide-dark.svg" >/dev/null + if [[ ! -f "${logo}" ]]; then + echo "Could not render static/grimoire_logo-wide-dark.svg" >&2 + exit 1 + fi + + hdiutil create -size 240m -fs HFS+ -volname Grimoire -ov "${rw}" >/dev/null + # An open Grimoire disk takes /Volumes/Grimoire. The new image would then + # mount at "/Volumes/Grimoire 1", and Finder would style the wrong disk. + if [[ -d /Volumes/Grimoire ]]; then + echo "Ejecting the open Grimoire disk so the new image can use that name." + hdiutil detach "/Volumes/Grimoire" >/dev/null 2>&1 \ + || hdiutil detach -force "/Volumes/Grimoire" >/dev/null 2>&1 \ + || true + fi + # Mount under /Volumes so Finder sees the volume name. A private mountpoint + # shows up under the folder name instead, and the window styling cannot find it. + local attach_plist="${STAGE}/attach.plist" + hdiutil attach "${rw}" -noverify -noautoopen -plist > "${attach_plist}" + MOUNTED="$(python3 -c ' +import plistlib, sys +info = plistlib.load(open(sys.argv[1], "rb")) +for entity in info.get("system-entities", []): + point = entity.get("mount-point") + if point: + print(point) + raise SystemExit +raise SystemExit("mounted image has no mount point") +' "${attach_plist}")" + if [[ "${MOUNTED}" != "/Volumes/Grimoire" ]]; then + echo "The disk image mounted at ${MOUNTED} instead of /Volumes/Grimoire." >&2 + echo "Eject every open Grimoire disk, then package again." >&2 + exit 1 + fi + volname="Grimoire" + cp -R "${root}/." "${MOUNTED}/" + mkdir -p "${MOUNTED}/.background" + swift "${ROOT}/.github/scripts/dmg-background.swift" \ + "${logo}" \ + "${MOUNTED}/.background/background.png" \ + "${MOUNTED}/.background/background@2x.png" \ + "${DMG_WIDTH}" \ + "${DMG_HEIGHT}" \ + "${DMG_APP_X}" \ + "${DMG_APPLICATIONS_X}" + + osascript </dev/null + MOUNTED="" + hdiutil convert "${rw}" -format UDZO -imagekey zlib-level=9 -o "${dest}" >/dev/null +} + DMG_ROOT="${STAGE}/dmg" mkdir -p "${DMG_ROOT}" cp -R "${APP}" "${DMG_ROOT}/" ln -s /Applications "${DMG_ROOT}/Applications" -if [[ -z "${IDENTITY}" ]]; then - cp "${ROOT}/app/macos/Install Grimoire.command" "${DMG_ROOT}/Install Grimoire.command" - chmod +x "${DMG_ROOT}/Install Grimoire.command" - cp "${ROOT}/app/macos/How to install.txt" "${DMG_ROOT}/How to install.txt" -fi mkdir -p "${OUT}" DMG="${OUT}/Grimoire_${VERSION}_${ARCH}.dmg" rm -f "${DMG}" -hdiutil create -volname Grimoire -srcfolder "${DMG_ROOT}" -ov -format UDZO "${DMG}" >/dev/null +write_styled_dmg "${DMG_ROOT}" "${DMG}" if [[ -n "${IDENTITY}" ]]; then codesign --force --timestamp --sign "${IDENTITY}" "${DMG}" diff --git a/app/Cargo.toml b/app/Cargo.toml index f79a4c0..5a3c44e 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -45,8 +45,8 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png", [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" -objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication", "NSImage"] } -objc2-foundation = { version = "0.3", default-features = false, features = ["NSData"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication", "NSImage", "NSResponder", "NSSound"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["NSArray", "NSBundle", "NSData", "NSDate", "NSObject", "NSString", "NSURL"] } [profile.release] codegen-units = 1 diff --git a/app/macos/How to install.txt b/app/macos/How to install.txt deleted file mode 100644 index 4baa058..0000000 --- a/app/macos/How to install.txt +++ /dev/null @@ -1,18 +0,0 @@ -How to install Grimoire (macOS) -================================ - -If macOS says Grimoire is “damaged and can’t be opened”, that is Gatekeeper -blocking an unsigned download. It is not a corrupt file. - -Recommended: - 1. Open this disk image - 2. Double-click “Install Grimoire.command” - (If macOS blocks the script: right-click it → Open → Open) - 3. The installer clears Gatekeeper quarantine, copies Grimoire to - Applications, and launches it - -Manual alternative (Terminal): - xattr -cr /Applications/Grimoire.app - -After you have an Apple Developer account, signed + notarized builds will not -need this step. diff --git a/app/macos/Install Grimoire.command b/app/macos/Install Grimoire.command deleted file mode 100755 index cc7ff6c..0000000 --- a/app/macos/Install Grimoire.command +++ /dev/null @@ -1,53 +0,0 @@ -#!/bin/bash -# Install Grimoire for macOS without an Apple Developer certificate. -# Clears Gatekeeper quarantine (the "damaged and can't be opened" message), -# copies the app to /Applications, and launches it. -set -euo pipefail - -clear_quarantine() { - local path="$1" - if [[ -e "$path" ]]; then - xattr -cr "$path" 2>/dev/null || true - fi -} - -here="$(cd "$(dirname "$0")" && pwd)" -# Allow Terminal to run this .command even when the DMG is quarantined. -clear_quarantine "$0" -clear_quarantine "$here" - -app_src="" -if [[ -d "$here/Grimoire.app" ]]; then - app_src="$here/Grimoire.app" -else - echo "Could not find Grimoire.app next to this installer." >&2 - echo "Open the Grimoire .dmg, then double-click \"Install Grimoire.command\" inside it." >&2 - read -r -p "Press Return to close…" _ - exit 1 -fi - -dest="/Applications/Grimoire.app" - -echo "Installing Grimoire…" -echo " from: $app_src" -echo " to: $dest" -echo - -clear_quarantine "$app_src" - -if [[ -d "$dest" ]]; then - echo "Removing previous install…" - rm -rf "$dest" -fi - -# ditto preserves bundle metadata better than cp -R on macOS. -ditto "$app_src" "$dest" -clear_quarantine "$dest" - -echo -echo "Installed. Opening Grimoire…" -open "$dest" - -echo -echo "Done. You can eject the disk image." -read -r -p "Press Return to close…" _ diff --git a/app/src/comic.rs b/app/src/comic.rs index da5b18c..92991ae 100644 --- a/app/src/comic.rs +++ b/app/src/comic.rs @@ -27,10 +27,11 @@ pub fn list_comic_image_names(path: &Path) -> AppResult> { pub fn comic_page_data_url(path: &Path, index: usize) -> AppResult<(String, String, usize)> { let names = list_comic_image_names(path)?; let page_count = names.len(); - let name = names - .get(index) - .cloned() - .ok_or_else(|| err(format!("Comic page {index} is out of range ({page_count} pages).")))?; + let name = names.get(index).cloned().ok_or_else(|| { + err(format!( + "Comic page {index} is out of range ({page_count} pages)." + )) + })?; let bytes = read_named_entry(path, &name)?; let (_, mime) = cover_file_info(&bytes); let mime = mime_for_name(&name).unwrap_or(mime); @@ -237,11 +238,7 @@ mod tests { #[test] fn natural_sort_orders_page_numbers() { - let mut names = vec![ - "page10.jpg".into(), - "page2.jpg".into(), - "page1.jpg".into(), - ]; + let mut names = vec!["page10.jpg".into(), "page2.jpg".into(), "page1.jpg".into()]; sort_comic_names(&mut names); assert_eq!(names, vec!["page1.jpg", "page2.jpg", "page10.jpg"]); } @@ -275,11 +272,14 @@ mod tests { let mut zip = ZipWriter::new(file); let opts = SimpleFileOptions::default(); zip.start_file("page2.png", opts).unwrap(); - zip.write_all(&[0x89, b'P', b'N', b'G', 0, 0, 0, 0]).unwrap(); + zip.write_all(&[0x89, b'P', b'N', b'G', 0, 0, 0, 0]) + .unwrap(); zip.start_file("page10.png", opts).unwrap(); - zip.write_all(&[0x89, b'P', b'N', b'G', 0, 0, 0, 0]).unwrap(); + zip.write_all(&[0x89, b'P', b'N', b'G', 0, 0, 0, 0]) + .unwrap(); zip.start_file("page1.png", opts).unwrap(); - zip.write_all(&[0x89, b'P', b'N', b'G', 0, 0, 0, 0]).unwrap(); + zip.write_all(&[0x89, b'P', b'N', b'G', 0, 0, 0, 0]) + .unwrap(); zip.start_file("__MACOSX/._junk", opts).unwrap(); zip.write_all(b"skip").unwrap(); zip.finish().unwrap(); diff --git a/app/src/config.rs b/app/src/config.rs index aa0c55a..845a566 100644 --- a/app/src/config.rs +++ b/app/src/config.rs @@ -12,6 +12,18 @@ pub struct AppConfig { /// `publisher`, `serif`, `sans`, or `opendyslexic`. #[serde(default = "default_reader_font")] pub reader_font: String, + /// Text size as a percent of the default. Clamped when applied. + #[serde(default = "default_reader_zoom")] + pub reader_zoom: u32, + /// `native`, `ink`, `inverted`, or `paper`. + #[serde(default = "default_reader_colors")] + pub reader_colors: String, + /// Comic page size as a percent of the default. Clamped when applied. + #[serde(default = "default_comic_zoom")] + pub comic_zoom: u32, + /// `single` or `spread`. + #[serde(default = "default_comic_layout")] + pub comic_layout: String, } fn default_theme() -> String { @@ -22,12 +34,32 @@ fn default_reader_font() -> String { "serif".into() } +fn default_reader_zoom() -> u32 { + 100 +} + +fn default_reader_colors() -> String { + "native".into() +} + +fn default_comic_zoom() -> u32 { + 100 +} + +fn default_comic_layout() -> String { + "single".into() +} + impl Default for AppConfig { fn default() -> Self { Self { library_path: None, theme: default_theme(), reader_font: default_reader_font(), + reader_zoom: default_reader_zoom(), + reader_colors: default_reader_colors(), + comic_zoom: default_comic_zoom(), + comic_layout: default_comic_layout(), } } } @@ -96,6 +128,34 @@ pub fn set_reader_font(font: &str) -> AppResult { Ok(config) } +pub fn set_reader_zoom(zoom: u32) -> AppResult { + let mut config = load_config()?; + config.reader_zoom = zoom; + save_config(&config)?; + Ok(config) +} + +pub fn set_reader_colors(colors: &str) -> AppResult { + let mut config = load_config()?; + config.reader_colors = colors.to_string(); + save_config(&config)?; + Ok(config) +} + +pub fn set_comic_zoom(zoom: u32) -> AppResult { + let mut config = load_config()?; + config.comic_zoom = zoom; + save_config(&config)?; + Ok(config) +} + +pub fn set_comic_layout(layout: &str) -> AppResult { + let mut config = load_config()?; + config.comic_layout = layout.to_string(); + save_config(&config)?; + Ok(config) +} + #[cfg(test)] mod tests { use super::*; @@ -113,6 +173,10 @@ mod tests { assert!(config.library_path.is_none()); assert_eq!(config.theme, "dark"); assert_eq!(config.reader_font, "serif"); + assert_eq!(config.reader_zoom, 100); + assert_eq!(config.reader_colors, "native"); + assert_eq!(config.comic_zoom, 100); + assert_eq!(config.comic_layout, "single"); let config = parse_config("\u{feff}").expect("BOM-only config should default"); assert!(config.library_path.is_none()); } @@ -123,5 +187,9 @@ mod tests { assert_eq!(config.library_path.as_deref(), Some("C:\\Books")); assert_eq!(config.theme, "dark"); assert_eq!(config.reader_font, "serif"); + assert_eq!(config.reader_zoom, 100); + assert_eq!(config.reader_colors, "native"); + assert_eq!(config.comic_zoom, 100); + assert_eq!(config.comic_layout, "single"); } } diff --git a/app/src/convert.rs b/app/src/convert.rs index 23b9e74..3064f60 100644 --- a/app/src/convert.rs +++ b/app/src/convert.rs @@ -174,8 +174,7 @@ fn pick_source_for_epub(formats: &[String]) -> Option<&'static str> { fn book_formats(conn: &Connection, book_id: i64) -> AppResult> { let mut stmt = conn.prepare("SELECT format FROM formats WHERE book = ?1")?; let rows = stmt.query_map([book_id], |row| row.get(0))?; - rows.collect::, _>>() - .map_err(Into::into) + rows.collect::, _>>().map_err(Into::into) } fn book_title_author(conn: &Connection, book_id: i64) -> AppResult<(String, String, String)> { @@ -391,8 +390,8 @@ fn convert_mobi7_file_to_epub( fallback_title: &str, fallback_author: &str, ) -> AppResult<()> { - let book = Mobi::from_path(source) - .map_err(|e| err(format!("Could not parse AZW3/MOBI: {e}")))?; + let book = + Mobi::from_path(source).map_err(|e| err(format!("Could not parse AZW3/MOBI: {e}")))?; match book.encryption() { Encryption::No => {} @@ -450,8 +449,8 @@ fn convert_txt_file_to_epub( title: &str, author: &str, ) -> AppResult<()> { - let text = fs::read_to_string(source) - .map_err(|e| err(format!("Could not read text file: {e}")))?; + let text = + fs::read_to_string(source).map_err(|e| err(format!("Could not read text file: {e}")))?; let body = format!("
{}
", escape_xml(&text)); let html = format!( r#" diff --git a/app/src/db.rs b/app/src/db.rs index cf5e30f..eacfcb0 100644 --- a/app/src/db.rs +++ b/app/src/db.rs @@ -33,30 +33,23 @@ fn migrate(conn: &Connection) -> AppResult<()> { if current < 1 { conn.execute_batch(MIGRATION_001) .map_err(|e| err(format!("Migration 001 failed: {e}")))?; - conn.execute( - "INSERT INTO schema_migrations (version) VALUES (1)", - [], - )?; + conn.execute("INSERT INTO schema_migrations (version) VALUES (1)", [])?; } if current < 2 { conn.execute_batch(MIGRATION_002) .map_err(|e| err(format!("Migration 002 failed: {e}")))?; - conn.execute( - "INSERT INTO schema_migrations (version) VALUES (2)", - [], - )?; + conn.execute("INSERT INTO schema_migrations (version) VALUES (2)", [])?; } Ok(()) } pub fn refresh_fts(conn: &Connection, book_id: i64) -> AppResult<()> { - let title: String = conn.query_row( - "SELECT title FROM books WHERE id = ?1", - [book_id], - |row| row.get(0), - )?; + let title: String = + conn.query_row("SELECT title FROM books WHERE id = ?1", [book_id], |row| { + row.get(0) + })?; let mut authors = String::new(); { diff --git a/app/src/kf8_extract.rs b/app/src/kf8_extract.rs index 6f64116..d1b845b 100644 --- a/app/src/kf8_extract.rs +++ b/app/src/kf8_extract.rs @@ -358,9 +358,7 @@ pub fn extract_kf8_html(bytes: &[u8]) -> AppResult { text_end = records.len(); } if text_end <= text_start { - return Err(err( - "KF8 header reports no text records to decompress.", - )); + return Err(err("KF8 header reports no text records to decompress.")); } let mut raw_html = Vec::with_capacity(text_record_count.saturating_mul(4096)); diff --git a/app/src/kindle.rs b/app/src/kindle.rs index 259816f..32155bb 100644 --- a/app/src/kindle.rs +++ b/app/src/kindle.rs @@ -862,7 +862,12 @@ fn should_skip_device_dir(name: &str) -> bool { ) } -fn device_book_from_file(filename: &str, path: String, size: u64, handle: Option) -> Option { +fn device_book_from_file( + filename: &str, + path: String, + size: u64, + handle: Option, +) -> Option { let format = ebook_extension(filename)?.to_string(); Some(DeviceBook { title: title_from_filename(filename), @@ -936,7 +941,8 @@ fn scan_mount_ebooks(mount: &str) -> AppResult> { .map_err(|e| err(format!("Could not open device mount: {e}")))?; let mut books = Vec::new(); - let mut stack: Vec<(PathBuf, String, usize)> = vec![(canonical_mount.clone(), String::new(), 0)]; + let mut stack: Vec<(PathBuf, String, usize)> = + vec![(canonical_mount.clone(), String::new(), 0)]; while let Some((dir, path_prefix, depth)) = stack.pop() { if books.len() >= MAX_DEVICE_BOOKS || depth > MAX_DEVICE_SCAN_DEPTH { diff --git a/app/src/library.rs b/app/src/library.rs index 9cfa691..897ab8a 100644 --- a/app/src/library.rs +++ b/app/src/library.rs @@ -1,7 +1,7 @@ use crate::db::{open_database, refresh_fts, remove_fts}; use crate::error::{err, AppResult}; -use crate::openlibrary::MetadataMatch; use crate::metadata::{cover_file_info, extract_from_file}; +use crate::openlibrary::MetadataMatch; use crate::state::LibrarySession; use chrono::Utc; use rusqlite::{params, Connection, OptionalExtension}; @@ -126,7 +126,8 @@ pub fn import_book(conn: &mut Connection, root: &Path, source: &Path) -> AppResu extracted.authors.clone() }; - let author_folder = sanitize_component(authors.first().map(|s| s.as_str()).unwrap_or("Unknown")); + let author_folder = + sanitize_component(authors.first().map(|s| s.as_str()).unwrap_or("Unknown")); let title_folder = sanitize_component(&title); let rel_dir = PathBuf::from(&author_folder).join(&title_folder); let abs_dir = root.join(&rel_dir); @@ -158,7 +159,14 @@ pub fn import_book(conn: &mut Connection, root: &Path, source: &Path) -> AppResu tx.execute( "INSERT INTO books (uuid, title, sort_title, path, has_cover, timestamp) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![uuid, title, title.to_lowercase(), path_str, has_cover as i64, timestamp], + params![ + uuid, + title, + title.to_lowercase(), + path_str, + has_cover as i64, + timestamp + ], )?; let book_id = tx.last_insert_rowid(); @@ -191,11 +199,7 @@ pub fn import_book(conn: &mut Connection, root: &Path, source: &Path) -> AppResu let series_id = upsert_series(&tx, series_name)?; tx.execute( "INSERT INTO books_series (book, series, series_index) VALUES (?1, ?2, ?3)", - params![ - book_id, - series_id, - extracted.series_index.unwrap_or(1.0) - ], + params![book_id, series_id, extracted.series_index.unwrap_or(1.0)], )?; } @@ -233,9 +237,8 @@ pub fn list_books(conn: &Connection, query: Option<&str>) -> AppResult { @@ -282,11 +285,9 @@ pub fn get_book(conn: &Connection, _root: &Path, id: i64) -> AppResult AppResult { let exists: bool = conn - .query_row( - "SELECT 1 FROM books WHERE id = ?1", - [id], - |_| Ok(true), - ) + .query_row("SELECT 1 FROM books WHERE id = ?1", [id], |_| Ok(true)) .optional()? .unwrap_or(false); if !exists { @@ -443,8 +440,8 @@ pub(crate) fn apply_remote_match( .identifiers .iter() .any(|i| i.type_name.eq_ignore_ascii_case("isbn")); - let title_generic = current.title.trim().is_empty() - || current.title.eq_ignore_ascii_case("untitled"); + let title_generic = + current.title.trim().is_empty() || current.title.eq_ignore_ascii_case("untitled"); let mut next_title = current.title.clone(); if title_generic { @@ -497,11 +494,10 @@ pub(crate) fn apply_remote_match( } } - let book_path: String = conn.query_row( - "SELECT path FROM books WHERE id = ?1", - [id], - |row| row.get(0), - )?; + let book_path: String = + conn.query_row("SELECT path FROM books WHERE id = ?1", [id], |row| { + row.get(0) + })?; if let Some(bytes) = cover_bytes { write_cover_bytes(conn, root, id, &book_path, bytes)?; @@ -546,11 +542,10 @@ pub fn set_book_cover( id: i64, bytes: &[u8], ) -> AppResult { - let book_path: String = conn.query_row( - "SELECT path FROM books WHERE id = ?1", - [id], - |row| row.get(0), - )?; + let book_path: String = + conn.query_row("SELECT path FROM books WHERE id = ?1", [id], |row| { + row.get(0) + })?; write_cover_bytes(conn, root, id, &book_path, bytes)?; get_book(conn, root, id) } @@ -564,7 +559,13 @@ fn write_cover_bytes( ) -> AppResult<()> { let abs_dir = root.join(book_path); let (filename, _) = cover_file_info(bytes); - for name in ["cover.jpg", "cover.jpeg", "cover.png", "cover.webp", "cover.gif"] { + for name in [ + "cover.jpg", + "cover.jpeg", + "cover.png", + "cover.webp", + "cover.gif", + ] { let path = abs_dir.join(name); if path.exists() { let _ = fs::remove_file(path); @@ -577,11 +578,9 @@ fn write_cover_bytes( } pub fn delete_book(conn: &mut Connection, root: &Path, id: i64) -> AppResult<()> { - let path: String = conn.query_row( - "SELECT path FROM books WHERE id = ?1", - [id], - |row| row.get(0), - )?; + let path: String = conn.query_row("SELECT path FROM books WHERE id = ?1", [id], |row| { + row.get(0) + })?; let abs = root.join(&path); // contentless FTS5 cannot DELETE; clear the index row first. remove_fts(conn, id)?; @@ -620,7 +619,11 @@ pub struct BulkActionResult { pub failed: Vec, } -pub fn delete_ebooks(conn: &mut Connection, root: &Path, ids: &[i64]) -> AppResult { +pub fn delete_ebooks( + conn: &mut Connection, + root: &Path, + ids: &[i64], +) -> AppResult { let mut updated = 0i64; let mut failed = Vec::new(); for &id in ids { @@ -670,10 +673,8 @@ fn patch_one_book(conn: &mut Connection, id: i64, patch: &BookBulkPatch) -> AppR .map(|t| t.trim().to_string()) .filter(|t| !t.is_empty()) .collect(); - let touching_anything = !add_tags.is_empty() - || patch.set_series - || patch.set_series_index - || patch.set_comment; + let touching_anything = + !add_tags.is_empty() || patch.set_series || patch.set_series_index || patch.set_comment; if !touching_anything { return Ok(()); } @@ -682,10 +683,7 @@ fn patch_one_book(conn: &mut Connection, id: i64, patch: &BookBulkPatch) -> AppR if !add_tags.is_empty() { let existing = book_tags(&tx, id)?; - let mut seen: Vec = existing - .iter() - .map(|t| t.to_ascii_lowercase()) - .collect(); + let mut seen: Vec = existing.iter().map(|t| t.to_ascii_lowercase()).collect(); for tag in &add_tags { let key = tag.to_ascii_lowercase(); if seen.iter().any(|s| s == &key) { @@ -767,7 +765,12 @@ pub fn cover_path(conn: &Connection, root: &Path, id: i64) -> AppResult AppResult { +pub fn format_file_path( + conn: &Connection, + root: &Path, + book_id: i64, + format: &str, +) -> AppResult { let (path, filename): (String, String) = conn.query_row( "SELECT b.path, f.filename FROM books b JOIN formats f ON f.book = b.id @@ -865,29 +868,29 @@ fn upsert_author(conn: &Connection, name: &str) -> AppResult { "INSERT OR IGNORE INTO authors (name, sort) VALUES (?1, ?2)", params![name, name.to_lowercase()], )?; - Ok(conn.query_row( - "SELECT id FROM authors WHERE name = ?1", - [name], - |row| row.get(0), - )?) + Ok( + conn.query_row("SELECT id FROM authors WHERE name = ?1", [name], |row| { + row.get(0) + })?, + ) } fn upsert_tag(conn: &Connection, name: &str) -> AppResult { conn.execute("INSERT OR IGNORE INTO tags (name) VALUES (?1)", [name])?; - Ok(conn.query_row( - "SELECT id FROM tags WHERE name = ?1", - [name], - |row| row.get(0), - )?) + Ok( + conn.query_row("SELECT id FROM tags WHERE name = ?1", [name], |row| { + row.get(0) + })?, + ) } fn upsert_series(conn: &Connection, name: &str) -> AppResult { conn.execute("INSERT OR IGNORE INTO series (name) VALUES (?1)", [name])?; - Ok(conn.query_row( - "SELECT id FROM series WHERE name = ?1", - [name], - |row| row.get(0), - )?) + Ok( + conn.query_row("SELECT id FROM series WHERE name = ?1", [name], |row| { + row.get(0) + })?, + ) } fn format_from_ext(ext: &str) -> Option<&'static str> { diff --git a/app/src/metadata.rs b/app/src/metadata.rs index 2e231bc..64976ef 100644 --- a/app/src/metadata.rs +++ b/app/src/metadata.rs @@ -34,7 +34,12 @@ pub fn extract_from_file(path: &Path) -> AppResult { _ => ExtractedMetadata::default(), }; - if meta.title.as_ref().map(|t| t.trim().is_empty()).unwrap_or(true) { + if meta + .title + .as_ref() + .map(|t| t.trim().is_empty()) + .unwrap_or(true) + { meta.title = path.file_stem().map(|s| s.to_string_lossy().into_owned()); } if meta.authors.is_empty() { @@ -52,9 +57,8 @@ fn extract_comic(path: &Path) -> AppResult { fn extract_epub(path: &Path) -> AppResult { let file = File::open(path)?; - let mut archive = ZipArchive::new(file).map_err(|e| { - crate::error::err(format!("Could not read EPUB archive: {e}")) - })?; + let mut archive = ZipArchive::new(file) + .map_err(|e| crate::error::err(format!("Could not read EPUB archive: {e}")))?; let container_xml = read_zip_string(&mut archive, "META-INF/container.xml")?; let opf_path = find_opf_path(&container_xml).unwrap_or_else(|| "content.opf".to_string()); @@ -131,7 +135,10 @@ fn identifier_kind(value: &str, opf: &str) -> String { } fn looks_like_isbn(s: &str) -> bool { - let digits: String = s.chars().filter(|c| c.is_ascii_digit() || *c == 'x' || *c == 'X').collect(); + let digits: String = s + .chars() + .filter(|c| c.is_ascii_digit() || *c == 'x' || *c == 'X') + .collect(); digits.len() == 10 || digits.len() == 13 } @@ -228,10 +235,7 @@ fn decode_pdf_literal(s: &str) -> String { fn decode_pdf_hex(s: &str) -> Option { let end = s.find('>')?; - let hex: String = s[..end] - .chars() - .filter(|c| c.is_ascii_hexdigit()) - .collect(); + let hex: String = s[..end].chars().filter(|c| c.is_ascii_hexdigit()).collect(); if hex.len() < 2 { return None; } @@ -325,10 +329,10 @@ fn extract_mobi(path: &Path) -> AppResult { let mut cover_record: Option = None; if exth_off + 12 <= record0.len() && &record0[exth_off..exth_off + 4] == b"EXTH" { - let exth_len = u32::from_be_bytes(record0[exth_off + 4..exth_off + 8].try_into().unwrap()) - as usize; - let count = u32::from_be_bytes(record0[exth_off + 8..exth_off + 12].try_into().unwrap()) - as usize; + let exth_len = + u32::from_be_bytes(record0[exth_off + 4..exth_off + 8].try_into().unwrap()) as usize; + let count = + u32::from_be_bytes(record0[exth_off + 8..exth_off + 12].try_into().unwrap()) as usize; let mut pos = exth_off + 12; let end = (exth_off + exth_len).min(record0.len()); for _ in 0..count { @@ -399,7 +403,8 @@ fn extract_mobi(path: &Path) -> AppResult { } } - let cover = cover_record.and_then(|idx| read_mobi_image_record(&data, num_records, idx as usize)); + let cover = + cover_record.and_then(|idx| read_mobi_image_record(&data, num_records, idx as usize)); Ok(ExtractedMetadata { title, @@ -567,7 +572,9 @@ fn find_cover( opf_path: &str, ) -> Option> { let href = cover_href_from_opf(opf)?; - let base = Path::new(opf_path).parent().unwrap_or_else(|| Path::new("")); + let base = Path::new(opf_path) + .parent() + .unwrap_or_else(|| Path::new("")); let full = normalize_zip_path(&base.join(href).to_string_lossy()); let mut file = archive.by_name(&full).ok()?; let mut buf = Vec::new(); @@ -618,9 +625,7 @@ fn item_href_for_id(opf: &str, id: &str) -> Option { } fn normalize_zip_path(path: &str) -> String { - path.replace('\\', "/") - .trim_start_matches("./") - .to_string() + path.replace('\\', "/").trim_start_matches("./").to_string() } /// Pick a cover filename and MIME type from image magic bytes. @@ -663,30 +668,49 @@ mod tests { "#; assert_eq!( - first_tag_text(opf, "dc:title").map(|s| decode_xml_entities(&s)).as_deref(), + first_tag_text(opf, "dc:title") + .map(|s| decode_xml_entities(&s)) + .as_deref(), Some("Dune & Messiah") ); assert_eq!(all_tag_texts(opf, "dc:creator").len(), 2); - assert_eq!(meta_content_by_name(opf, "calibre:series").as_deref(), Some("Dune")); assert_eq!( - meta_content_by_name(opf, "calibre:series_index") - .and_then(|s| s.parse::().ok()), + meta_content_by_name(opf, "calibre:series").as_deref(), + Some("Dune") + ); + assert_eq!( + meta_content_by_name(opf, "calibre:series_index").and_then(|s| s.parse::().ok()), Some(2.0) ); - assert_eq!(cover_href_from_opf(opf).as_deref(), Some("images/cover.jpg")); + assert_eq!( + cover_href_from_opf(opf).as_deref(), + Some("images/cover.jpg") + ); } #[test] fn parses_pdf_info_literals() { let pdf = "%PDF-1.4\n1 0 obj<< /Title (Neuromancer) /Author (William Gibson) >>endobj"; - assert_eq!(pdf_info_string(pdf, "/Title").as_deref(), Some("Neuromancer")); - assert_eq!(pdf_info_string(pdf, "/Author").as_deref(), Some("William Gibson")); + assert_eq!( + pdf_info_string(pdf, "/Title").as_deref(), + Some("Neuromancer") + ); + assert_eq!( + pdf_info_string(pdf, "/Author").as_deref(), + Some("William Gibson") + ); } #[test] fn detects_cover_mime() { - assert_eq!(cover_file_info(&[0x89, b'P', b'N', b'G']), ("cover.png", "image/png")); - assert_eq!(cover_file_info(&[0xFF, 0xD8, 0xFF, 0xE0]), ("cover.jpg", "image/jpeg")); + assert_eq!( + cover_file_info(&[0x89, b'P', b'N', b'G']), + ("cover.png", "image/png") + ); + assert_eq!( + cover_file_info(&[0xFF, 0xD8, 0xFF, 0xE0]), + ("cover.jpg", "image/jpeg") + ); } #[test] diff --git a/app/src/openlibrary.rs b/app/src/openlibrary.rs index 976c777..5fa0f1b 100644 --- a/app/src/openlibrary.rs +++ b/app/src/openlibrary.rs @@ -69,7 +69,9 @@ struct EditionDoc { fn http_client() -> AppResult { reqwest::Client::builder() .timeout(Duration::from_secs(20)) - .user_agent("Grimoire/0.1 (ebook library manager; +https://github.com/BoxingOctopus/grimoire)") + .user_agent( + "Grimoire/0.1 (ebook library manager; +https://github.com/BoxingOctopus/grimoire)", + ) .build() .map_err(|e| err(format!("Could not create HTTP client: {e}"))) } @@ -143,7 +145,10 @@ pub async fn lookup_book_candidates( .into_iter() .map(|(k, v)| (k.to_string(), v)) .collect(); - let refs: Vec<(&str, &str)> = owned.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); + let refs: Vec<(&str, &str)> = owned + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); match search_docs(&client, &refs, max_results).await { Ok(items) => merge(items, &mut collected), @@ -288,7 +293,11 @@ fn expand_doc(doc: SearchDoc) -> Vec { let description = match doc.first_sentence { Some(FirstSentence::One(s)) => { let t = s.trim().to_string(); - if t.is_empty() { None } else { Some(t) } + if t.is_empty() { + None + } else { + Some(t) + } } Some(FirstSentence::Many(list)) => list .into_iter() diff --git a/app/src/reader.rs b/app/src/reader.rs index de25228..b01d1b2 100644 --- a/app/src/reader.rs +++ b/app/src/reader.rs @@ -43,11 +43,10 @@ pub fn resolve_reader_source( book_id: i64, preferred_format: Option<&str>, ) -> AppResult { - let title: String = conn.query_row( - "SELECT title FROM books WHERE id = ?1", - [book_id], - |row| row.get(0), - )?; + let title: String = + conn.query_row("SELECT title FROM books WHERE id = ?1", [book_id], |row| { + row.get(0) + })?; let mut authors = Vec::new(); { @@ -71,9 +70,12 @@ pub fn resolve_reader_source( let has_mobi = formats.iter().any(|f| f == "MOBI"); let has_azw3_only = formats.iter().any(|f| f == "AZW3") - && !formats.iter().any(|f| RENDERABLE.contains(&f.as_str()) || f == "MOBI"); + && !formats + .iter() + .any(|f| RENDERABLE.contains(&f.as_str()) || f == "MOBI"); - let format = if let Some(preferred) = preferred_format.map(str::trim).filter(|s| !s.is_empty()) { + let format = if let Some(preferred) = preferred_format.map(str::trim).filter(|s| !s.is_empty()) + { let upper = preferred.to_ascii_uppercase(); if formats.iter().any(|f| f == &upper) && RENDERABLE.contains(&upper.as_str()) { upper @@ -143,14 +145,10 @@ pub fn resolve_reader_source( fn book_format_list(conn: &Connection, book_id: i64) -> AppResult> { let mut stmt = conn.prepare("SELECT format FROM formats WHERE book = ?1")?; let rows = stmt.query_map([book_id], |row| row.get(0))?; - rows.collect::, _>>() - .map_err(Into::into) + rows.collect::, _>>().map_err(Into::into) } -pub fn get_reading_progress( - conn: &Connection, - book_id: i64, -) -> AppResult> { +pub fn get_reading_progress(conn: &Connection, book_id: i64) -> AppResult> { conn.query_row( "SELECT book, format, location, percent, updated_at FROM reading_progress WHERE book = ?1", diff --git a/app/src/ui/about.rs b/app/src/ui/about.rs new file mode 100644 index 0000000..ee45e73 --- /dev/null +++ b/app/src/ui/about.rs @@ -0,0 +1,219 @@ +use std::sync::Arc; + +use gpui_kit::component::link::Link; +use gpui_kit::component::ActiveTheme as _; +use gpui_kit::prelude::{ + InteractiveElement as _, ParentElement as _, Styled as _, StyledImage as _, +}; +use gpui_kit::AppContext as _; +use gpui_kit::{ + div, img, point, px, size, AnyWindowHandle, App, Context, Div, FontWeight, Global, Hsla, Image, + ImageFormat, IntoElement, MouseButton, ObjectFit, Render, Stateful, TitlebarOptions, Window, + WindowBounds, WindowOptions, +}; + +use super::icon; +use super::theme::{DISPLAY, UI_FONT}; + +const PUBLISHER: &str = "Boxing Octopus Creative Engineering"; +const PUBLISHER_URL: &str = "https://boxingoctop.us"; +const LICENSE: &str = "MPL-2.0"; +const LICENSE_URL: &str = "https://mozilla.org/MPL/2.0/"; +const WEBSITE: &str = "grimoirebooks.app"; +const WEBSITE_URL: &str = "https://grimoirebooks.app"; +const VERSION_LABEL: &str = concat!("Version ", env!("CARGO_PKG_VERSION")); + +struct OpenAbout(AnyWindowHandle); + +impl Global for OpenAbout {} + +/// Opens the About window, or brings the existing one forward. +pub(crate) fn show(cx: &mut App) { + if let Some(existing) = cx.try_global::().map(|open| open.0) { + if existing + .update(cx, |_, window, _| window.activate_window()) + .is_ok() + { + return; + } + } + + let Ok((window, _)) = + gpui_kit::open_window(window_options(cx), cx, |_, cx| cx.new(|_| About::new())) + else { + return; + }; + cx.set_global(OpenAbout(window)); +} + +fn window_options(cx: &App) -> WindowOptions { + let mut options = WindowOptions::default(); + let bounds = size(px(440.), px(520.)); + options.window_bounds = Some(WindowBounds::centered(bounds, cx)); + options.window_min_size = Some(bounds); + options.is_resizable = false; + options.titlebar = Some(TitlebarOptions { + title: Some("About Grimoire".into()), + appears_transparent: true, + traffic_light_position: Some(point(px(14.), px(18.))), + }); + options.app_owns_titlebar_drag = true; + icon::apply(&mut options); + options +} + +struct About { + dragging: bool, + logo: Arc, +} + +impl About { + fn new() -> Self { + Self { + dragging: false, + logo: Arc::new(Image::from_bytes( + ImageFormat::Svg, + include_bytes!("../../../static/grimoire_icon.svg").to_vec(), + )), + } + } +} + +impl Render for About { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let ink = cx.theme().foreground; + let muted = cx.theme().muted_foreground; + let accent = cx.theme().primary; + + div() + .size_full() + .flex() + .flex_col() + .items_center() + .bg(cx.theme().background) + .text_color(ink) + .font_family(UI_FONT) + .text_size(px(15.)) + .px(px(28.)) + .child( + draggable("about-drag", cx) + .w_full() + .flex() + .flex_col() + .items_center() + .pt(px(40.)) + .child( + img(self.logo.clone()) + .w(px(126.)) + .h(px(148.)) + .object_fit(ObjectFit::Contain), + ) + .child( + div() + .mt(px(14.)) + .flex() + .items_end() + .font_family(DISPLAY) + .text_size(px(32.)) + .font_weight(FontWeight::SEMIBOLD) + .child("Grimoire") + .child(div().text_color(accent).child(".")), + ) + .child( + div() + .mt(px(4.)) + .text_size(px(13.)) + .text_color(muted) + .child(VERSION_LABEL), + ), + ) + .child( + div() + .mt(px(28.)) + .w_full() + .flex() + .flex_col() + .items_center() + .gap(px(16.)) + .child(fact( + "Publisher", + "about-publisher", + PUBLISHER, + PUBLISHER_URL, + muted, + )) + .child(fact( + "License", + "about-license", + LICENSE, + LICENSE_URL, + muted, + )) + .child(fact( + "Website", + "about-website", + WEBSITE, + WEBSITE_URL, + muted, + )), + ) + .child( + draggable("about-drag-rest", cx) + .w_full() + .flex_1() + .pb(px(32.)), + ) + } +} + +fn draggable(id: &'static str, cx: &mut Context) -> Stateful
{ + div() + .id(id) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, _| this.dragging = true), + ) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _, _, _| this.dragging = false), + ) + .on_mouse_down_out(cx.listener(|this, _, _, _| this.dragging = false)) + .on_mouse_move(cx.listener(|this, _, window, _| { + if this.dragging { + this.dragging = false; + window.start_window_move(); + } + })) +} + +fn fact( + caption: &'static str, + id: &'static str, + label: &'static str, + href: &'static str, + muted: Hsla, +) -> impl IntoElement { + div() + .flex() + .flex_col() + .items_center() + .gap(px(2.)) + .child(div().text_size(px(12.)).text_color(muted).child(caption)) + .child(Link::new(id).href(href).text_center().child(label)) +} + +#[cfg(test)] +mod tests { + use super::{LICENSE, LICENSE_URL, PUBLISHER, PUBLISHER_URL, VERSION_LABEL, WEBSITE_URL}; + + #[test] + fn about_states_publisher_license_and_site() { + assert_eq!(PUBLISHER, "Boxing Octopus Creative Engineering"); + assert_eq!(PUBLISHER_URL, "https://boxingoctop.us"); + assert_eq!(LICENSE, "MPL-2.0"); + assert_eq!(LICENSE, env!("CARGO_PKG_LICENSE")); + assert!(VERSION_LABEL.contains(env!("CARGO_PKG_VERSION"))); + assert_eq!(LICENSE_URL, "https://mozilla.org/MPL/2.0/"); + assert_eq!(WEBSITE_URL, "https://grimoirebooks.app"); + } +} diff --git a/app/src/ui/backend.rs b/app/src/ui/backend.rs index 7230c82..f1acb9c 100644 --- a/app/src/ui/backend.rs +++ b/app/src/ui/backend.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; +use std::fs; use std::path::{Path, PathBuf}; use gpui_kit::Global; @@ -5,7 +7,8 @@ use tokio::runtime::Handle; use crate::comic::comic_page_data_url; use crate::config::{ - clear_library_path, load_config, set_library_path, set_reader_font, AppConfig, + clear_library_path, load_config, set_comic_layout, set_comic_zoom, set_library_path, + set_reader_colors, set_reader_font, set_reader_zoom, AppConfig, }; use crate::convert::{ convert_book_format, kindle_convert_available, list_convertible_targets, resolve_kindle_file, @@ -91,7 +94,10 @@ pub fn list(state: &AppState, query: Option<&str>) -> AppResult state.with_conn(|conn, _| list_books(conn, query)) } -pub fn covers_for(state: &AppState, books: &[BookSummary]) -> std::collections::HashMap { +pub fn covers_for( + state: &AppState, + books: &[BookSummary], +) -> std::collections::HashMap { let mut covers = std::collections::HashMap::new(); let _ = state.with_conn(|conn, root| { for book in books { @@ -126,7 +132,11 @@ pub fn remove_books(state: &AppState, ids: &[i64]) -> AppResult AppResult { +pub fn patch_books( + state: &AppState, + ids: &[i64], + patch: BookBulkPatch, +) -> AppResult { state.with_conn_mut(|conn, root| bulk_patch_ebooks(conn, root, ids, &patch)) } @@ -142,11 +152,19 @@ pub fn reader_source(state: &AppState, id: i64) -> AppResult { state.with_conn_mut(|conn, root| resolve_reader_source(conn, root, id, None)) } +pub fn book_cover(state: &AppState, id: i64) -> AppResult> { + state.with_conn(|conn, root| cover_path(conn, root, id)) +} + pub fn progress(state: &AppState, id: i64) -> AppResult> { state.with_conn(|conn, _| get_reading_progress(conn, id)) } -pub fn save_progress(state: &AppState, id: i64, update: ReadingProgressUpdate) -> AppResult { +pub fn save_progress( + state: &AppState, + id: i64, + update: ReadingProgressUpdate, +) -> AppResult { state.with_conn(|conn, _| save_reading_progress(conn, id, update)) } @@ -166,7 +184,10 @@ pub async fn scan_device(id: String) -> AppResult { scan_device_library(id).await } -pub async fn delete_on_device(id: String, books: Vec) -> AppResult { +pub async fn delete_on_device( + id: String, + books: Vec, +) -> AppResult { delete_device_books(id, books).await } @@ -248,6 +269,22 @@ pub fn save_reader_font(font: &str) -> AppResult { set_reader_font(font) } +pub fn save_reader_zoom(zoom: u32) -> AppResult { + set_reader_zoom(zoom) +} + +pub fn save_reader_colors(colors: &str) -> AppResult { + set_reader_colors(colors) +} + +pub fn save_comic_zoom(zoom: u32) -> AppResult { + set_comic_zoom(zoom) +} + +pub fn save_comic_layout(layout: &str) -> AppResult { + set_comic_layout(layout) +} + pub fn isbn_of(book: &BookDetail) -> Option { book.identifiers .iter() @@ -290,3 +327,113 @@ pub fn extension_of(path: &Path) -> String { .unwrap_or("") .to_ascii_lowercase() } + +/// Expand dropped files and folders into supported ebook paths. +/// +/// Directories are walked. Unsupported files, unreadable entries, and +/// AppleDouble sidecars are skipped. +pub fn collect_ebook_paths(paths: &[PathBuf]) -> Vec { + let mut found = Vec::new(); + let mut seen_dirs = HashSet::new(); + let mut seen_files = HashSet::new(); + for path in paths { + walk_for_ebooks(path, &mut found, &mut seen_dirs, &mut seen_files, 0); + } + found +} + +fn walk_for_ebooks( + path: &Path, + found: &mut Vec, + seen_dirs: &mut HashSet, + seen_files: &mut HashSet, + depth: u32, +) { + if depth > 32 { + return; + } + let Ok(meta) = fs::metadata(path) else { + return; + }; + if meta.is_dir() { + if let Ok(canonical) = fs::canonicalize(path) { + if !seen_dirs.insert(canonical) { + return; + } + } + let Ok(entries) = fs::read_dir(path) else { + return; + }; + for entry in entries.flatten() { + if entry.file_name() == "__MACOSX" { + continue; + } + walk_for_ebooks(&entry.path(), found, seen_dirs, seen_files, depth + 1); + } + return; + } + if !meta.is_file() || !is_importable_ebook(path) { + return; + } + if let Ok(canonical) = fs::canonicalize(path) { + if !seen_files.insert(canonical) { + return; + } + } + found.push(path.to_path_buf()); +} + +fn is_importable_ebook(path: &Path) -> bool { + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("._")) + { + return false; + } + SUPPORTED_EXT.contains(&extension_of(path).as_str()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn names(paths: &[PathBuf]) -> Vec { + let mut names: Vec = paths + .iter() + .filter_map(|path| { + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + }) + .collect(); + names.sort(); + names + } + + #[test] + fn collect_ebook_paths_walks_folders_and_skips_other_files() { + let root = std::env::temp_dir().join(format!("grimoire-drop-{}", uuid::Uuid::new_v4())); + let nested = root.join("Author").join("Series"); + fs::create_dir_all(&nested).unwrap(); + fs::create_dir_all(root.join("__MACOSX")).unwrap(); + fs::write(nested.join("Tale.EPUB"), b"epub").unwrap(); + fs::write(nested.join("notes.md"), b"nope").unwrap(); + fs::write(nested.join("._Tale.epub"), b"sidecar").unwrap(); + fs::write(root.join("loose.pdf"), b"pdf").unwrap(); + fs::write(root.join("cover.jpg"), b"jpg").unwrap(); + fs::write(root.join("__MACOSX").join("Book.mobi"), b"junk").unwrap(); + + let found = collect_ebook_paths(&[ + root.join("Author"), + root.join("loose.pdf"), + root.join("cover.jpg"), + root.join("missing.epub"), + ]); + assert_eq!( + names(&found), + vec!["Tale.EPUB".to_string(), "loose.pdf".to_string()] + ); + + let _ = fs::remove_dir_all(&root); + } +} diff --git a/app/src/ui/book.rs b/app/src/ui/book.rs index 415d2cd..519acc3 100644 --- a/app/src/ui/book.rs +++ b/app/src/ui/book.rs @@ -5,7 +5,7 @@ use gpui_kit::prelude::{ FluentBuilder as _, InteractiveElement as _, StatefulInteractiveElement as _, Styled as _, StyledImage as _, }; -use gpui_kit::{Context, IntoElement, ObjectFit, ParentElement as _, Window, div, img, px}; +use gpui_kit::{div, img, px, Context, IntoElement, ObjectFit, ParentElement as _, Window}; use super::backend; use super::shell::Shell; @@ -13,7 +13,11 @@ use super::theme::DISPLAY; use crate::library::{BookDetail, BookUpdate}; impl Shell { - pub fn book_page(&mut self, window: &mut Window, cx: &mut Context) -> gpui_kit::AnyElement { + pub fn book_page( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> gpui_kit::AnyElement { let Some(id) = self.book_id else { return div().child("No book selected.").into_any_element(); }; @@ -21,7 +25,11 @@ impl Shell { { self.load_book(id, window, cx); } - let title = self.book.as_ref().map(|book| book.title.clone()).unwrap_or_else(|| "Book".into()); + let title = self + .book + .as_ref() + .map(|book| book.title.clone()) + .unwrap_or_else(|| "Book".into()); let cover = self.book_cover.clone(); let formats = self .book @@ -37,7 +45,15 @@ impl Shell { let can_read = self .book .as_ref() - .map(|book| backend::can_read(&book.formats.iter().map(|format| format.format.clone()).collect::>())) + .map(|book| { + backend::can_read( + &book + .formats + .iter() + .map(|format| format.format.clone()) + .collect::>(), + ) + }) .unwrap_or(false); let targets = self.convert_targets.clone(); let devices = self.devices.clone(); @@ -62,26 +78,41 @@ impl Shell { ) .child(div().font_family(DISPLAY).text_size(px(28.)).child(title)) .when(!self.message.is_empty(), |this| { - this.child(div().text_color(cx.theme().primary).child(self.message.clone())) + this.child( + div() + .text_color(cx.theme().primary) + .child(self.message.clone()), + ) }) .when(!self.error.is_empty(), |this| { - this.child(div().text_color(cx.theme().danger).child(self.error.clone())) + this.child( + div() + .text_color(cx.theme().danger) + .child(self.error.clone()), + ) }) .child( - div().flex().gap(px(20.)).child(book_cover(cover, cx)).child( - div() - .flex_1() - .flex() - .flex_col() - .gap(px(8.)) - .child(field("Title", Input::new(&self.title_input))) - .child(field("Authors", Input::new(&self.authors_input))) - .child(field("Tags", Input::new(&self.tags_input))) - .child(field("Series", Input::new(&self.series_input))) - .child(field("Series index", Input::new(&self.series_index_input))) - .child(field("ISBN", Input::new(&self.isbn_input))) - .child(field("Notes", Textarea::new(&self.comment_input).h(px(90.)))), - ), + div() + .flex() + .gap(px(20.)) + .child(book_cover(cover, cx)) + .child( + div() + .flex_1() + .flex() + .flex_col() + .gap(px(8.)) + .child(field("Title", Input::new(&self.title_input))) + .child(field("Authors", Input::new(&self.authors_input))) + .child(field("Tags", Input::new(&self.tags_input))) + .child(field("Series", Input::new(&self.series_input))) + .child(field("Series index", Input::new(&self.series_index_input))) + .child(field("ISBN", Input::new(&self.isbn_input))) + .child(field( + "Notes", + Textarea::new(&self.comment_input).h(px(90.)), + )), + ), ) .child( div() @@ -93,40 +124,45 @@ impl Shell { .primary() .label(if self.busy { "Working…" } else { "Save" }) .disabled(self.busy) - .on_click(cx.listener(|this, _, window, cx| this.save_book_form(window, cx))), + .on_click( + cx.listener(|this, _, window, cx| this.save_book_form(window, cx)), + ), ) .child( Button::new("fetch-ol") .outline() .label("Fetch from Open Library") .disabled(self.busy) - .on_click(cx.listener(|this, _, window, cx| this.fetch_book(window, cx))), + .on_click( + cx.listener(|this, _, window, cx| this.fetch_book(window, cx)), + ), ) .child( Button::new("choose-cover") .outline() .label("Choose cover") .disabled(self.busy) - .on_click(cx.listener(|this, _, window, cx| this.choose_cover(window, cx))), + .on_click( + cx.listener(|this, _, window, cx| this.choose_cover(window, cx)), + ), ) .when(can_read, |this| { - this.child( - Button::new("read-book") - .outline() - .label("Read") - .on_click(cx.listener(|this, _, _, cx| { - if let Some(id) = this.book_id { - this.open_reader(id, cx); - } - })), - ) + this.child(Button::new("read-book").outline().label("Read").on_click( + cx.listener(|this, _, _, cx| { + if let Some(id) = this.book_id { + this.open_reader(id, cx); + } + }), + )) }) .child( Button::new("delete-book") .danger() .label("Delete") .disabled(self.busy) - .on_click(cx.listener(|this, _, window, cx| this.confirm_delete_book(window, cx))), + .on_click(cx.listener(|this, _, window, cx| { + this.confirm_delete_book(window, cx) + })), ), ) .child( @@ -148,13 +184,22 @@ impl Shell { }); this.child(div().flex().flex_wrap().gap(px(8.)).children(buttons)) }) - .child(div().font_family(DISPLAY).text_size(px(18.)).child("Send to device")) + .child( + div() + .font_family(DISPLAY) + .text_size(px(18.)) + .child("Send to device"), + ) .children(devices.into_iter().map(|device| { let id = device.id.clone(); let label = format!( "{} · {}", device.product, - if device.supports_send { "Send" } else { "Detected" } + if device.supports_send { + "Send" + } else { + "Detected" + } ); Button::new(SharedId(format!("send-{}", device.id))) .outline() @@ -250,7 +295,11 @@ impl Shell { tags, series: { let series = series.trim().to_string(); - if series.is_empty() { None } else { Some(series) } + if series.is_empty() { + None + } else { + Some(series) + } }, series_index: index.trim().parse().ok(), comment, @@ -323,7 +372,11 @@ impl Shell { self.spawn_task( window, cx, - async move { backend::cover_candidates(&state, id).await.map_err(|e| e.to_string()) }, + async move { + backend::cover_candidates(&state, id) + .await + .map_err(|e| e.to_string()) + }, |this, result, window, cx| { this.busy = false; match result { @@ -352,11 +405,9 @@ impl Shell { Button::new(SharedId(format!("cover-{}", candidate.id))) .outline() .label(label) - .on_click(move |_, _, cx| { + .on_click(move |_, window, cx| { let urls = urls.clone(); - shell - .downgrade().update_in(cx, |this, window, cx| this.apply_picked_cover(urls, window, cx)) - .ok(); + shell.update(cx, |this, cx| this.apply_picked_cover(urls, window, cx)); }), ); } @@ -364,7 +415,12 @@ impl Shell { }); } - fn apply_picked_cover(&mut self, urls: Vec, window: &mut Window, cx: &mut Context) { + fn apply_picked_cover( + &mut self, + urls: Vec, + window: &mut Window, + cx: &mut Context, + ) { let Some(id) = self.book_id else { return; }; @@ -373,7 +429,11 @@ impl Shell { self.spawn_task( window, cx, - async move { backend::apply_cover(&state, id, &urls).await.map_err(|e| e.to_string()) }, + async move { + backend::apply_cover(&state, id, &urls) + .await + .map_err(|e| e.to_string()) + }, |this, result, window, cx| { this.busy = false; match result { @@ -437,7 +497,11 @@ impl Shell { self.spawn_task( window, cx, - async move { backend::send(&state, id, &device_id).await.map_err(|e| e.to_string()) }, + async move { + backend::send(&state, id, &device_id) + .await + .map_err(|e| e.to_string()) + }, |this, result, _, cx| { this.busy = false; match result { @@ -460,15 +524,16 @@ impl Shell { .description("Delete this book from the library?") .on_ok({ let shell = shell.clone(); - move |_, _, cx| { - shell - .downgrade().update_in(cx, |this, window, cx| { + move |_, window, cx| { + shell.update(cx, |this, cx| { if let Some(id) = this.book_id { let state = this.state.clone(); this.spawn_io( window, cx, - move || backend::remove_book(&state, id).map_err(|e| e.to_string()), + move || { + backend::remove_book(&state, id).map_err(|e| e.to_string()) + }, |this, result, window, cx| { if let Err(error) = result { this.error = error; @@ -481,9 +546,8 @@ impl Shell { }, ); } - }) - .ok(); - true + }); + true } }) }); @@ -507,7 +571,10 @@ fn book_cover(path: Option, cx: &gpui_kit::App) -> impl Into .overflow_hidden() .bg(cx.theme().muted) .child(match path { - Some(path) => img(path).size_full().object_fit(ObjectFit::Cover).into_any_element(), + Some(path) => img(path) + .size_full() + .object_fit(ObjectFit::Cover) + .into_any_element(), None => div().size_full().into_any_element(), }) } diff --git a/app/src/ui/chime.rs b/app/src/ui/chime.rs new file mode 100644 index 0000000..b674aa1 --- /dev/null +++ b/app/src/ui/chime.rs @@ -0,0 +1,208 @@ +//! Two-note chimes for an eReader connecting and disconnecting. +//! Connect rises from C4 to E4. Disconnect falls from E4 to C4. + +const RATE: u32 = 22_050; +const SECONDS: f32 = 0.72; +const C4: f32 = 261.63; +const E4: f32 = 329.63; + +pub fn play_connect_chime() { + play_wav(&chime_wav(true)); +} + +pub fn play_disconnect_chime() { + play_wav(&chime_wav(false)); +} + +fn chime_wav(rising: bool) -> Vec { + let notes = if rising { + [(C4, 0.0_f32), (E4, 0.14)] + } else { + [(E4, 0.0), (C4, 0.14)] + }; + let count = (RATE as f32 * SECONDS) as usize; + let mut samples = vec![0.0_f32; count]; + for (freq, start) in notes { + let offset = (start * RATE as f32) as usize; + for (index, sample) in samples.iter_mut().enumerate().skip(offset) { + let t = (index - offset) as f32 / RATE as f32; + let attack = 1.0 - (-t * 90.0).exp(); + let decay = (-t * 6.2).exp(); + let angle = t * freq * std::f32::consts::TAU; + *sample += (angle.sin() + 0.28 * (angle * 2.0).sin()) * attack * decay; + } + } + let pcm: Vec = samples + .into_iter() + .map(|sample| (sample.clamp(-1.0, 1.0) * 0.269 * i16::MAX as f32) as i16) + .collect(); + wav_bytes(&pcm, RATE) +} + +fn wav_bytes(samples: &[i16], rate: u32) -> Vec { + let data_len = samples.len() * 2; + let mut out = Vec::with_capacity(44 + data_len); + out.extend_from_slice(b"RIFF"); + push_u32(&mut out, (36 + data_len) as u32); + out.extend_from_slice(b"WAVE"); + out.extend_from_slice(b"fmt "); + push_u32(&mut out, 16); + push_u16(&mut out, 1); + push_u16(&mut out, 1); + push_u32(&mut out, rate); + push_u32(&mut out, rate * 2); + push_u16(&mut out, 2); + push_u16(&mut out, 16); + out.extend_from_slice(b"data"); + push_u32(&mut out, data_len as u32); + for sample in samples { + out.extend_from_slice(&sample.to_le_bytes()); + } + out +} + +fn push_u16(out: &mut Vec, value: u16) { + out.extend_from_slice(&value.to_le_bytes()); +} + +fn push_u32(out: &mut Vec, value: u32) { + out.extend_from_slice(&value.to_le_bytes()); +} + +#[cfg(target_os = "macos")] +fn play_wav(bytes: &[u8]) { + use std::cell::RefCell; + + use objc2::AnyThread; + use objc2_app_kit::NSSound; + use objc2_foundation::NSData; + + thread_local! { + static PLAYING: RefCell>> = RefCell::new(None); + } + + let data = NSData::with_bytes(bytes); + let Some(sound) = NSSound::initWithData(NSSound::alloc(), &data) else { + return; + }; + if !sound.play() { + return; + } + // Dropping the sound stops it, so keep this one until the next chime. + PLAYING.with(|playing| *playing.borrow_mut() = Some(sound)); +} + +#[cfg(target_os = "windows")] +fn play_wav(bytes: &[u8]) { + use std::sync::Mutex; + + static CLIP: Mutex>> = Mutex::new(None); + const ASYNC: u32 = 0x0001; + const NO_DEFAULT: u32 = 0x0002; + const MEMORY: u32 = 0x0004; + + #[link(name = "winmm")] + extern "system" { + fn PlaySoundA(name: *const u8, module: *mut core::ffi::c_void, flags: u32) -> i32; + } + + let mut clip = CLIP.lock().unwrap_or_else(|poison| poison.into_inner()); + *clip = Some(bytes.to_vec()); + let ptr = clip + .as_ref() + .map(|wav| wav.as_ptr()) + .unwrap_or(std::ptr::null()); + unsafe { + PlaySoundA(ptr, std::ptr::null_mut(), ASYNC | NO_DEFAULT | MEMORY); + } +} + +/// Linux has no audio crate in this app. `paplay` or `aplay` plays the same clip when one is installed. +#[cfg(all(unix, not(target_os = "macos")))] +fn play_wav(bytes: &[u8]) { + let path = std::env::temp_dir().join(format!( + "grimoire-chime-{}-{}.wav", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|time| time.as_nanos()) + .unwrap_or(0) + )); + if std::fs::write(&path, bytes).is_err() { + return; + } + std::thread::spawn(move || { + for program in ["paplay", "aplay"] { + let played = std::process::Command::new(program) + .arg(&path) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + if played.map(|status| status.success()).unwrap_or(false) { + break; + } + } + let _ = std::fs::remove_file(&path); + }); +} + +#[cfg(test)] +mod tests { + use super::{chime_wav, RATE}; + + #[test] + fn connect_chime_is_a_short_quiet_wav() { + assert_quiet_chime(&chime_wav(true)); + } + + #[test] + fn disconnect_chime_falls_where_connect_rises() { + let rising = chime_wav(true); + let falling = chime_wav(false); + assert_quiet_chime(&falling); + assert_ne!(rising, falling); + let opening = (RATE as f32 * 0.12) as usize; + assert!( + zero_crossings(&falling, opening) > zero_crossings(&rising, opening), + "disconnect should open on the higher note" + ); + } + + fn assert_quiet_chime(wav: &[u8]) { + assert_eq!(&wav[..4], b"RIFF"); + assert_eq!(&wav[8..12], b"WAVE"); + let data_len = u32::from_le_bytes(wav[40..44].try_into().unwrap()) as usize; + assert_eq!(wav.len(), 44 + data_len); + let mut peak = 0_i16; + let mut audible = 0_usize; + for frame in wav[44..].chunks_exact(2) { + let sample = i16::from_le_bytes(frame.try_into().unwrap()); + peak = peak.max(sample.abs()); + if sample.abs() > 400 { + audible += 1; + } + } + assert!(peak > 1000, "chime was silent"); + assert!(peak < 20_000, "chime was too loud"); + assert!(audible > 1000); + assert!(wav.len() < 40_000, "chime was longer than a short chime"); + } + + fn zero_crossings(wav: &[u8], samples: usize) -> usize { + let mut previous = 0_i16; + let mut crossings = 0_usize; + for frame in wav[44..].chunks_exact(2).take(samples) { + let sample = i16::from_le_bytes(frame.try_into().unwrap()); + if previous.signum() != 0 + && sample.signum() != 0 + && previous.signum() != sample.signum() + { + crossings += 1; + } + if sample != 0 { + previous = sample; + } + } + crossings + } +} diff --git a/app/src/ui/devices.rs b/app/src/ui/devices.rs index 7f0fc64..ffeb358 100644 --- a/app/src/ui/devices.rs +++ b/app/src/ui/devices.rs @@ -5,7 +5,7 @@ use gpui_kit::component::{ActiveTheme as _, Disableable as _, Selectable as _, W use gpui_kit::prelude::{ FluentBuilder as _, InteractiveElement as _, StatefulInteractiveElement as _, Styled as _, }; -use gpui_kit::{Context, IntoElement, ParentElement as _, Window, div, px}; +use gpui_kit::{div, px, Context, IntoElement, ParentElement as _, Window}; use super::backend; use super::shell::Shell; @@ -13,7 +13,11 @@ use super::theme::DISPLAY; use crate::kindle::{ConnectionKind, DeviceBook, DeviceBookTarget}; impl Shell { - pub fn devices_page(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + pub fn devices_page( + &mut self, + _window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { let devices = self.devices.clone(); let history = self.sync_history.clone(); div() @@ -120,6 +124,84 @@ impl Shell { })) } + pub fn ask_to_manage( + &mut self, + devices: Vec, + window: &mut Window, + cx: &mut Context, + ) { + #[cfg(target_os = "macos")] + cx.activate(true); + window.activate_window(); + // Activation stops a chime that is already playing, so start this one after the window is front. + self.chime_after_activation(window, cx); + let products: Vec = devices + .iter() + .map(|device| device.product.clone()) + .collect(); + let ids: Vec = devices.iter().map(|device| device.id.clone()).collect(); + let (title, description) = manage_prompt(&products); + let shell = cx.entity().clone(); + window.open_alert_dialog(cx, move |alert, _, _| { + alert + .confirm() + .title(title.clone()) + .description(description.clone()) + .ok_text("Manage") + .cancel_text("Not now") + .on_ok({ + let shell = shell.clone(); + let ids = ids.clone(); + move |_, window, cx| { + shell.update(cx, |this, cx| { + this.open_connected_devices(ids.clone(), window, cx); + }); + true + } + }) + .on_close({ + let shell = shell.clone(); + move |_, _, cx| { + shell.update(cx, |this, _cx| this.device_prompt_open = false); + } + }) + }); + } + + fn chime_after_activation(&self, window: &mut Window, cx: &mut Context) { + let runtime = self.runtime(cx); + cx.spawn_in(window, async move |_this, cx| { + let _ = runtime + .spawn(async { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + }) + .await; + let _ = cx.update(|_window, _cx| super::chime::play_connect_chime()); + }) + .detach(); + } + + fn open_connected_devices( + &mut self, + ids: Vec, + window: &mut Window, + cx: &mut Context, + ) { + self.book_id = None; + self.route = super::shell::Route::Devices; + self.device_selected.clear(); + self.device_select_mode = false; + if let [id] = ids.as_slice() { + self.device_id = Some(id.clone()); + self.device_library = None; + self.scan_device(window, cx); + } else { + self.device_id = None; + self.refresh_devices(window, cx); + } + cx.notify(); + } + pub fn refresh_devices(&mut self, window: &mut Window, cx: &mut Context) { self.devices_loading = true; let state = self.state.clone(); @@ -150,13 +232,22 @@ impl Shell { ); } - pub fn device_library_page(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + pub fn device_library_page( + &mut self, + _window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { let books = self .device_library .as_ref() .map(|library| library.books.clone()) .unwrap_or_default(); - let query = self.device_query.read(cx).value().to_string().to_lowercase(); + let query = self + .device_query + .read(cx) + .value() + .to_string() + .to_lowercase(); let filtered: Vec = books .into_iter() .filter(|book| { @@ -201,7 +292,11 @@ impl Shell { .child( Button::new("device-select") .ghost() - .label(if self.device_select_mode { "Done" } else { "Select" }) + .label(if self.device_select_mode { + "Done" + } else { + "Select" + }) .selected(self.device_select_mode) .on_click(cx.listener(|this, _, _, cx| { this.device_select_mode = !this.device_select_mode; @@ -217,7 +312,9 @@ impl Shell { .danger() .label(format!("Delete {selected}")) .disabled(self.busy) - .on_click(cx.listener(|this, _, window, cx| this.confirm_device_delete(window, cx))), + .on_click(cx.listener(|this, _, window, cx| { + this.confirm_device_delete(window, cx) + })), ) }), ) @@ -231,13 +328,25 @@ impl Shell { .flex_col() .gap(px(8.)) .when(self.device_library.is_none(), |this| { - this.child(div().text_color(cx.theme().muted_foreground).child("Scanning device…")) + this.child( + div() + .text_color(cx.theme().muted_foreground) + .child("Scanning device…"), + ) }) .when(!self.message.is_empty(), |this| { - this.child(div().text_color(cx.theme().primary).child(self.message.clone())) + this.child( + div() + .text_color(cx.theme().primary) + .child(self.message.clone()), + ) }) .when(!self.error.is_empty(), |this| { - this.child(div().text_color(cx.theme().danger).child(self.error.clone())) + this.child( + div() + .text_color(cx.theme().danger) + .child(self.error.clone()), + ) }) .children(filtered.into_iter().map(|book| { let key = book_key(&book); @@ -274,19 +383,54 @@ impl Shell { .flex_col() .child(book.title.clone()) .child( - div().text_size(px(12.)).text_color(cx.theme().muted_foreground).child( - format!("{} · {} · {}", book.format, book.filename, format_size(book.size)), - ), + div() + .text_size(px(12.)) + .text_color(cx.theme().muted_foreground) + .child(format!( + "{} · {} · {}", + book.format, + book.filename, + format_size(book.size) + )), ), ) })), ) } + pub(crate) fn begin_device_io(&mut self) { + self.device_io = self.device_io.saturating_add(1); + } + + pub(crate) fn end_device_io(&mut self) { + self.device_io = self.device_io.saturating_sub(1); + } + + pub(crate) fn note_open_device(&mut self, connected: &std::collections::HashSet) { + let missing = self.route == super::shell::Route::Devices + && self + .device_id + .as_ref() + .is_some_and(|id| !connected.contains(id)); + self.device_missing_polls = + note_device_presence(missing, self.device_io, self.device_missing_polls); + if unplugged_by_user(self.device_missing_polls, self.device_io) { + self.device_id = None; + self.device_library = None; + self.device_selected.clear(); + self.device_select_mode = false; + self.route = super::shell::Route::Devices; + self.error.clear(); + self.message.clear(); + self.device_missing_polls = 0; + } + } + pub fn scan_device(&mut self, window: &mut Window, cx: &mut Context) { let Some(id) = self.device_id.clone() else { return; }; + self.begin_device_io(); self.busy = true; self.error.clear(); self.spawn_task( @@ -294,6 +438,7 @@ impl Shell { cx, async move { backend::scan_device(id).await.map_err(|e| e.to_string()) }, |this, result, _, cx| { + this.end_device_io(); this.busy = false; match result { Ok(library) => this.device_library = Some(library), @@ -314,12 +459,10 @@ impl Shell { .description("Remove the selected files from the device?") .on_ok({ let shell = shell.clone(); - move |_, _, cx| { - shell - .downgrade().update_in(cx, |this, window, cx| this.delete_device_selection(window, cx)) - .ok(); - true - } + move |_, window, cx| { + shell.update(cx, |this, cx| this.delete_device_selection(window, cx)); + true + } }) }); } @@ -341,12 +484,18 @@ impl Shell { handle: book.handle, }) .collect::>(); + self.begin_device_io(); self.busy = true; self.spawn_task( window, cx, - async move { backend::delete_on_device(device_id, targets).await.map_err(|e| e.to_string()) }, + async move { + backend::delete_on_device(device_id, targets) + .await + .map_err(|e| e.to_string()) + }, |this, result, window, cx| { + this.end_device_io(); this.busy = false; match result { Ok(result) => { @@ -362,8 +511,65 @@ impl Shell { } } +pub(crate) const NOTICE_SECS: u64 = 5; +pub(crate) const DEVICE_UNPLUG_POLLS: u8 = 2; + +pub(crate) fn note_device_presence(missing: bool, device_io: u32, polls: u8) -> u8 { + if device_io > 0 || !missing { + 0 + } else { + polls.saturating_add(1) + } +} + +pub(crate) fn unplugged_by_user(polls: u8, device_io: u32) -> bool { + device_io == 0 && polls >= DEVICE_UNPLUG_POLLS +} + +pub(crate) fn offer_manage(ids: &[String], open_device: Option<&str>) -> bool { + match ids { + [] => false, + [id] => open_device != Some(id.as_str()), + _ => true, + } +} + +pub(crate) fn manage_prompt(products: &[String]) -> (String, String) { + if products.len() == 1 { + ( + "eReader connected".into(), + format!("{} is connected. Manage it with Grimoire?", products[0]), + ) + } else { + ( + "eReaders connected".into(), + format!( + "{} are connected. Manage them with Grimoire?", + join_names(products) + ), + ) + } +} + +fn join_names(names: &[String]) -> String { + match names { + [] => String::new(), + [one] => one.clone(), + [first, second] => format!("{first} and {second}"), + many => { + let (last, rest) = many.split_last().unwrap(); + format!("{}, and {last}", rest.join(", ")) + } + } +} + fn book_key(book: &DeviceBook) -> String { - format!("{}|{}|{}", book.path, book.handle.unwrap_or(0), book.filename) + format!( + "{}|{}|{}", + book.path, + book.handle.unwrap_or(0), + book.filename + ) } fn panel_title(text: &str, _cx: &gpui_kit::App) -> impl IntoElement { @@ -395,6 +601,55 @@ fn format_size(bytes: u64) -> String { } } +#[cfg(test)] +mod tests { + use super::{ + manage_prompt, note_device_presence, offer_manage, unplugged_by_user, NOTICE_SECS, + }; + + #[test] + fn a_connected_reader_asks_to_manage() { + let (title, description) = manage_prompt(&["Kindle Paperwhite".into()]); + assert_eq!(title, "eReader connected"); + assert_eq!( + description, + "Kindle Paperwhite is connected. Manage it with Grimoire?" + ); + assert!(offer_manage(&["k1".into()], None)); + assert!(!offer_manage(&["k1".into()], Some("k1"))); + assert!(offer_manage(&["k1".into(), "k2".into()], Some("k1"))); + assert!(!offer_manage(&[], None)); + } + + #[test] + fn unplug_returns_after_the_device_stays_gone() { + assert_eq!(NOTICE_SECS, 5); + assert_eq!(note_device_presence(true, 0, 0), 1); + assert!(!unplugged_by_user(1, 0)); + assert_eq!(note_device_presence(true, 0, 1), 2); + assert!(unplugged_by_user(2, 0)); + assert_eq!(note_device_presence(true, 1, 1), 0); + assert!(!unplugged_by_user(0, 1)); + assert_eq!(note_device_presence(false, 0, 2), 0); + } + + #[test] + fn several_readers_are_named_together() { + let (title, description) = manage_prompt(&["Kindle".into(), "Kobo Clara".into()]); + assert_eq!(title, "eReaders connected"); + assert_eq!( + description, + "Kindle and Kobo Clara are connected. Manage them with Grimoire?" + ); + let (_, description) = + manage_prompt(&["Kindle".into(), "Kobo Clara".into(), "BOOX".into()]); + assert_eq!( + description, + "Kindle, Kobo Clara, and BOOX are connected. Manage them with Grimoire?" + ); + } +} + struct Id(String); impl From for gpui_kit::ElementId { diff --git a/app/src/ui/document.rs b/app/src/ui/document.rs index e2e8a67..470d97b 100644 --- a/app/src/ui/document.rs +++ b/app/src/ui/document.rs @@ -125,7 +125,11 @@ fn html_title(html: &str) -> Option { let end_rel = lower[after..].find(" String { @@ -206,3 +210,231 @@ pub fn html_to_text(html: &str) -> String { } collapsed.trim().to_string() } + +/// One screen of reading. A chapter or section can span many pages, and a short +/// section continues on the same page as the text that follows it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PageBlock { + pub heading: Option, + pub text: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Page { + pub blocks: Vec, + /// Chapter index and body offset where this page starts. + pub chapter: usize, + pub offset: usize, +} + +/// Pack chapter text into pages of `lines_per_page` wrapped lines. +pub fn paginate(chapters: &[Chapter], lines_per_page: usize, chars_per_line: usize) -> Vec { + let lines_per_page = lines_per_page.max(1); + let chars_per_line = chars_per_line.max(1); + let mut pages = Vec::new(); + let mut draft = Draft::new(0, 0); + + for (chapter_index, chapter) in chapters.iter().enumerate() { + let heading_lines = 2.min(lines_per_page); + if draft.lines_used > 0 && draft.lines_used + heading_lines + 1 > lines_per_page { + pages.push(draft.finish()); + draft = Draft::new(chapter_index, 0); + } + if draft.blocks.is_empty() { + draft.chapter = chapter_index; + draft.offset = 0; + } + draft.blocks.push(PageBlock { + heading: Some(chapter.title.clone()), + text: String::new(), + }); + draft.lines_used += heading_lines; + + let mut offset = 0usize; + for (paragraph_index, paragraph) in chapter.body.split('\n').enumerate() { + if paragraph_index > 0 { + offset += 1; + } + if paragraph.is_empty() { + if draft.lines_used > 0 && draft.lines_used < lines_per_page { + push_line(&mut draft.blocks, ""); + draft.lines_used += 1; + } + continue; + } + let wrapped = wrap_words(paragraph, chars_per_line); + let mut consumed = 0usize; + for line in wrapped { + if draft.lines_used >= lines_per_page { + pages.push(draft.finish()); + draft = Draft::new(chapter_index, offset + consumed); + draft.blocks.push(PageBlock { + heading: None, + text: String::new(), + }); + } + push_line(&mut draft.blocks, &line); + draft.lines_used += 1; + consumed += line.chars().count(); + if consumed < paragraph.chars().count() { + consumed += 1; + } + } + offset += paragraph.chars().count(); + } + } + if draft.lines_used > 0 { + pages.push(draft.finish()); + } + pages +} + +/// Page whose start is at or just before `chapter` / `offset`. +pub fn page_index_at(pages: &[Page], chapter: usize, offset: usize) -> usize { + let mut found = 0; + for (index, page) in pages.iter().enumerate() { + if page.chapter < chapter || (page.chapter == chapter && page.offset <= offset) { + found = index; + } else { + break; + } + } + found +} + +struct Draft { + blocks: Vec, + lines_used: usize, + chapter: usize, + offset: usize, +} + +impl Draft { + fn new(chapter: usize, offset: usize) -> Self { + Self { + blocks: Vec::new(), + lines_used: 0, + chapter, + offset, + } + } + + fn finish(self) -> Page { + Page { + blocks: self.blocks, + chapter: self.chapter, + offset: self.offset, + } + } +} + +fn push_line(blocks: &mut [PageBlock], line: &str) { + let Some(block) = blocks.last_mut() else { + return; + }; + if !block.text.is_empty() { + block.text.push('\n'); + } + block.text.push_str(line); +} + +fn wrap_words(paragraph: &str, width: usize) -> Vec { + let mut lines = Vec::new(); + let mut current = String::new(); + for word in paragraph.split_whitespace() { + let word_len = word.chars().count(); + if current.is_empty() { + if word_len <= width { + current = word.to_string(); + } else { + let mut rest = word; + while rest.chars().count() > width { + let (head, tail) = split_chars(rest, width); + lines.push(head.to_string()); + rest = tail; + } + current = rest.to_string(); + } + continue; + } + if current.chars().count() + 1 + word_len <= width { + current.push(' '); + current.push_str(word); + } else { + lines.push(std::mem::take(&mut current)); + current = word.to_string(); + } + } + if !current.is_empty() { + lines.push(current); + } + lines +} + +fn split_chars(text: &str, count: usize) -> (&str, &str) { + let mut end = text.len(); + for (index, (byte, _)) in text.char_indices().enumerate() { + if index == count { + end = byte; + break; + } + } + text.split_at(end) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chapter(title: &str, body: &str) -> Chapter { + Chapter { + title: title.into(), + body: body.into(), + } + } + + #[test] + fn long_chapter_turns_one_screen_at_a_time() { + let body = (0..40) + .map(|n| format!("sentence {n} of the same chapter")) + .collect::>() + .join(" "); + let pages = paginate(&[chapter("One", &body)], 4, 24); + assert!( + pages.len() > 1, + "a long chapter should be more than one page" + ); + assert!(pages.iter().all(|page| page.chapter == 0)); + let first = &pages[0].blocks[0].text; + let second = &pages[1].blocks[0].text; + assert!(!first.is_empty()); + assert!(!second.is_empty()); + assert_ne!(first, second); + assert!(pages[0].blocks[0].heading.as_deref() == Some("One")); + assert!(pages[1].blocks[0].heading.is_none()); + } + + #[test] + fn short_sections_share_a_page() { + let pages = paginate( + &[chapter("Alpha", "alpha text"), chapter("Beta", "beta text")], + 12, + 40, + ); + assert_eq!(pages.len(), 1); + assert_eq!(pages[0].blocks.len(), 2); + assert_eq!(pages[0].blocks[1].heading.as_deref(), Some("Beta")); + } + + #[test] + fn page_index_stays_inside_a_split_chapter() { + let body = (0..30) + .map(|n| format!("word{n}")) + .collect::>() + .join(" "); + let pages = paginate(&[chapter("One", &body), chapter("Two", "end")], 3, 12); + let middle = pages.len() / 2; + let page = &pages[middle]; + assert_eq!(page_index_at(&pages, page.chapter, page.offset), middle); + } +} diff --git a/app/src/ui/library.rs b/app/src/ui/library.rs index c18fb18..1e60551 100644 --- a/app/src/ui/library.rs +++ b/app/src/ui/library.rs @@ -13,10 +13,12 @@ use gpui_kit::prelude::{ StyledImage as _, }; use gpui_kit::AppContext as _; -use gpui_kit::{AnyElement, Context, IntoElement, ObjectFit, ParentElement as _, Window, div, img, px}; +use gpui_kit::{ + div, img, px, AnyElement, Context, IntoElement, ObjectFit, ParentElement as _, Window, +}; use super::backend::{self, LibraryStatus}; -use super::shell::{FormatKind, ReadingStatus, Route, Shell}; +use super::shell::{FormatKind, ImportProgress, ReadingStatus, Route, Shell}; use super::theme::DISPLAY; use crate::library::{BookBulkPatch, BookSummary}; @@ -406,7 +408,11 @@ impl Shell { .label("Select all") .disabled(self.busy) .on_click(cx.listener(|this, _, _, cx| { - this.selected = this.filtered_books().into_iter().map(|book| book.id).collect(); + this.selected = this + .filtered_books() + .into_iter() + .map(|book| book.id) + .collect(); cx.notify(); })), ) @@ -448,7 +454,9 @@ impl Shell { .danger() .label("Delete") .disabled(self.busy) - .on_click(cx.listener(|this, _, window, cx| this.confirm_bulk_delete(window, cx))), + .on_click( + cx.listener(|this, _, window, cx| this.confirm_bulk_delete(window, cx)), + ), ) } @@ -493,23 +501,31 @@ impl Shell { cx.notify(); })), ) - .child(gpui_kit::component::input::Textarea::new(&self.bulk_notes_input)) + .child(gpui_kit::component::input::Textarea::new( + &self.bulk_notes_input, + )) .child( - div().flex().gap(px(8.)).child( - Button::new("bulk-cancel") - .outline() - .label("Cancel") - .on_click(cx.listener(|this, _, _, cx| { - this.bulk_open = false; - cx.notify(); - })), - ).child( - Button::new("bulk-apply") - .primary() - .label("Apply") - .disabled(self.busy) - .on_click(cx.listener(|this, _, window, cx| this.apply_bulk(window, cx))), - ), + div() + .flex() + .gap(px(8.)) + .child( + Button::new("bulk-cancel") + .outline() + .label("Cancel") + .on_click(cx.listener(|this, _, _, cx| { + this.bulk_open = false; + cx.notify(); + })), + ) + .child( + Button::new("bulk-apply") + .primary() + .label("Apply") + .disabled(self.busy) + .on_click( + cx.listener(|this, _, window, cx| this.apply_bulk(window, cx)), + ), + ), ) } @@ -537,8 +553,9 @@ impl Shell { cx, move || { let query = query.trim(); - let books = backend::list(&state, if query.is_empty() { None } else { Some(query) }) - .map_err(|e| e.to_string())?; + let books = + backend::list(&state, if query.is_empty() { None } else { Some(query) }) + .map_err(|e| e.to_string())?; let covers = backend::covers_for(&state, &books); Ok((books, covers)) }, @@ -567,7 +584,8 @@ impl Shell { Ok(status) => { self.apply_status(status); self.error.clear(); - self.message = "Library path saved. Grimoire will open it on the next launch too.".into(); + self.message = + "Library path saved. Grimoire will open it on the next launch too.".into(); self.reload_books(window, cx); } Err(error) => self.error = error.to_string(), @@ -586,67 +604,124 @@ impl Shell { self.import_paths(paths, window, cx); } - pub fn import_paths(&mut self, paths: Vec, window: &mut Window, cx: &mut Context) { - let ebook_paths: Vec<_> = paths - .into_iter() - .filter(|path| backend::SUPPORTED_EXT.contains(&backend::extension_of(path).as_str())) - .collect(); - if ebook_paths.is_empty() { - self.error = "No supported eBooks in that drop. Use EPUB, PDF, AZW3, MOBI, TXT, CBZ, or CBR.".into(); - cx.notify(); - return; - } - if !self.library_open { - self.error = "Open a library folder before importing books.".into(); - cx.notify(); + pub fn import_paths( + &mut self, + paths: Vec, + window: &mut Window, + cx: &mut Context, + ) { + if self.busy { return; } self.busy = true; self.error.clear(); self.message.clear(); + self.import_progress = Some(ImportProgress { + done: 0, + total: 0, + name: "Scanning for books…".into(), + }); + let library_open = self.library_open; let state = self.state.clone(); - self.spawn_task( - window, - cx, - async move { - let mut imported = 0; - let mut failures = Vec::new(); - for path in ebook_paths { - match backend::import(&state, &path) { - Ok(book) => { - imported += 1; - if needs_enrichment(&book) { - let _ = backend::enrich(&state, book.id).await; + let runtime = self.runtime(cx); + cx.notify(); + cx.spawn_in(window, async move |this, cx| { + let scanned = runtime + .spawn_blocking(move || backend::collect_ebook_paths(&paths)) + .await; + let ebook_paths = match scanned { + Ok(paths) => paths, + Err(error) => { + this.update_in(cx, |this, _, cx| { + this.busy = false; + this.import_progress = None; + this.error = error.to_string(); + cx.notify(); + }) + .ok(); + return; + } + }; + if ebook_paths.is_empty() { + this.update_in(cx, |this, _, cx| { + this.busy = false; + this.import_progress = None; + this.error = "No supported eBooks in that drop. Use EPUB, PDF, AZW3, MOBI, TXT, CBZ, or CBR.".into(); + cx.notify(); + }) + .ok(); + return; + } + if !library_open { + this.update_in(cx, |this, _, cx| { + this.busy = false; + this.import_progress = None; + this.error = "Open a library folder before importing books.".into(); + cx.notify(); + }) + .ok(); + return; + } + + let total = ebook_paths.len(); + let mut imported = 0usize; + let mut failures = Vec::new(); + for (index, path) in ebook_paths.into_iter().enumerate() { + let name = path + .file_name() + .and_then(|file_name| file_name.to_str()) + .unwrap_or("book") + .to_string(); + this.update(cx, |this, cx| { + this.import_progress = Some(ImportProgress { + done: index, + total, + name: name.clone(), + }); + cx.notify(); + }) + .ok(); + + let state = state.clone(); + let outcome = runtime + .spawn(async move { + match backend::import(&state, &path) { + Ok(book) => { + if needs_enrichment(&book) { + let _ = backend::enrich(&state, book.id).await; + } + Ok(()) } + Err(error) => Err(error.to_string()), } - Err(error) => { - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("book"); - failures.push(format!("{name}: {error}")); - } - } + }) + .await; + match outcome { + Ok(Ok(())) => imported += 1, + Ok(Err(error)) => failures.push(format!("{name}: {error}")), + Err(error) => failures.push(format!("{name}: {error}")), } - Ok((imported, failures)) - }, - |this, result, window, cx| { + } + + this.update_in(cx, |this, window, cx| { this.busy = false; - match result { - Ok((imported, failures)) => { - if imported > 0 { - this.message = if imported == 1 { - "Imported 1 book.".into() - } else { - format!("Imported {imported} books.") - }; - } - if !failures.is_empty() { - this.error = failures.into_iter().take(3).collect::>().join(" · "); - } - } - Err(error) => this.error = error, + this.import_progress = None; + if imported > 0 { + this.message = if imported == 1 { + "Imported 1 book.".into() + } else { + format!("Imported {imported} books.") + }; } + if !failures.is_empty() { + this.error = failures.into_iter().take(3).collect::>().join(" · "); + } + cx.notify(); this.reload_books(window, cx); - }, - ); + }) + .ok(); + }) + .detach(); } fn apply_bulk(&mut self, window: &mut Window, cx: &mut Context) { @@ -672,7 +747,11 @@ impl Shell { set_series: self.bulk_series, series: if self.bulk_series { let series = series.trim().to_string(); - if series.is_empty() { None } else { Some(series) } + if series.is_empty() { + None + } else { + Some(series) + } } else { None }, @@ -737,8 +816,14 @@ impl Shell { } fn bulk_send(&mut self, window: &mut Window, cx: &mut Context) { - let Some(device) = self.devices.iter().find(|device| device.supports_send).cloned() else { - self.error = "No Kindle connected. Connect over MTP / USB file transfer, then try again.".into(); + let Some(device) = self + .devices + .iter() + .find(|device| device.supports_send) + .cloned() + else { + self.error = + "No Kindle connected. Connect over MTP / USB file transfer, then try again.".into(); cx.notify(); return; }; @@ -778,12 +863,9 @@ impl Shell { .on_ok({ let shell = shell.clone(); move |_, window, cx| { - shell - .downgrade().update_in(cx, |this, window, cx| this.bulk_delete(window, cx)) - .ok(); - let _ = window; - true - } + shell.update(cx, |this, cx| this.bulk_delete(window, cx)); + true + } }) }); } @@ -815,7 +897,12 @@ impl Shell { self.load_book(id, window, cx); } - pub fn open_reader_for(&mut self, book: &BookSummary, window: &mut Window, cx: &mut Context) { + pub fn open_reader_for( + &mut self, + book: &BookSummary, + window: &mut Window, + cx: &mut Context, + ) { if backend::can_read(&book.formats) { self.open_reader(book.id, cx); return; @@ -835,9 +922,14 @@ impl Shell { pub fn open_reader(&mut self, id: i64, cx: &mut Context) { let state = self.state.clone(); - let _ = gpui_kit::open_window(super::reader_window_options("Grimoire", cx), cx, |window, cx| { - cx.new(|cx| super::reader::ReaderWindow::new(state, id, window, cx)) - }); + let Ok((handle, _)) = gpui_kit::open_window( + super::reader_window_options("Grimoire", cx), + cx, + |window, cx| cx.new(|cx| super::reader::ReaderWindow::new(state, id, window, cx)), + ) else { + return; + }; + let _ = handle.update(cx, |_, window, _| window.activate_window()); } } @@ -849,7 +941,10 @@ fn book_matches(book: &BookSummary, shell: &Shell) -> bool { return false; } if !shell.filter_genres.is_empty() - && !book.tags.iter().any(|tag| shell.filter_genres.contains(tag.trim())) + && !book + .tags + .iter() + .any(|tag| shell.filter_genres.contains(tag.trim())) { return false; } @@ -974,8 +1069,18 @@ fn empty_panel(title: &str, body: &str, cx: &gpui_kit::App) -> impl IntoElement .bg(cx.theme().popover) .border_1() .border_color(cx.theme().border) - .child(div().font_family(DISPLAY).text_size(px(22.)).child(title.to_string())) - .child(div().mt(px(6.)).text_color(cx.theme().muted_foreground).child(body.to_string())) + .child( + div() + .font_family(DISPLAY) + .text_size(px(22.)) + .child(title.to_string()), + ) + .child( + div() + .mt(px(6.)) + .text_color(cx.theme().muted_foreground) + .child(body.to_string()), + ) } fn book_section( @@ -996,9 +1101,20 @@ fn book_section( .gap(px(10.)) .child(super::shell::section_title(title, Some(books.len()))) .child( - div().flex().flex_wrap().gap(px(16.)).children(books.iter().map(|book| { - book_card(book, covers.get(&book.id), select_mode, selected.contains(&book.id), shell.clone(), cx) - })), + div() + .flex() + .flex_wrap() + .gap(px(16.)) + .children(books.iter().map(|book| { + book_card( + book, + covers.get(&book.id), + select_mode, + selected.contains(&book.id), + shell.clone(), + cx, + ) + })), ) } @@ -1013,10 +1129,8 @@ fn book_card( let id = book.id; let title = book.title.clone(); let authors = author_labels(book).join(", "); - let can_read = backend::can_read(&book.formats); let progress = book.progress_percent.filter(|percent| *percent < 100.0); let menu_shell = shell.clone(); - let open_shell = shell.clone(); let card_title = title.clone(); let menu_title = title.clone(); @@ -1025,25 +1139,23 @@ fn book_card( .w(px(148.)) .cursor_pointer() .when(is_selected, |this| { - this.border_1().border_color(cx.theme().primary).rounded(px(8.)).p(px(4.)) - }) - .on_click(move |_, window, cx| { - open_shell - .downgrade().update_in(cx, |this, window, cx| { - if this.select_mode { - if this.selected.contains(&id) { - this.selected.remove(&id); - } else { - this.selected.insert(id); - } - cx.notify(); - } else if let Some(book) = this.books.iter().find(|book| book.id == id).cloned() { - this.open_reader_for(&book, window, cx); - } - }) - .ok(); - let _ = window; + this.border_1() + .border_color(cx.theme().primary) + .rounded(px(8.)) + .p(px(4.)) }) + .on_click(cx.listener(move |this, _, window, cx| { + if this.select_mode { + if this.selected.contains(&id) { + this.selected.remove(&id); + } else { + this.selected.insert(id); + } + cx.notify(); + } else if let Some(book) = this.books.iter().find(|book| book.id == id).cloned() { + this.open_reader_for(&book, window, cx); + } + })) .context_menu(move |menu, _, _| { let info = menu_shell.clone(); let read = menu_shell.clone(); @@ -1053,55 +1165,56 @@ fn book_card( let title_for_delete = card_title.clone(); let send_title = menu_title.clone(); menu.item( - PopupMenuItem::new("Info").on_click(move |_, _, cx| { - info.downgrade().update_in(cx, |this, window, cx| this.show_book(id, window, cx)).ok(); + PopupMenuItem::element(|_, _| { + div().font_weight(gpui_kit::FontWeight::BOLD).child("Read") + }) + .on_click(move |_, window, cx| { + read.update(cx, |this, cx| { + if let Some(book) = this.books.iter().find(|book| book.id == id).cloned() { + this.open_reader_for(&book, window, cx); + } + }); }), ) - .when(can_read, |menu| { - menu.item(PopupMenuItem::new("Read").on_click(move |_, _, cx| { - read.update(cx, |this, cx| this.open_reader(id, cx)); - })) - }) + .separator() + .item(PopupMenuItem::new("Info").on_click(move |_, window, cx| { + info.update(cx, |this, cx| this.show_book(id, window, cx)); + })) .item(PopupMenuItem::new("Edit metadata").on_click({ let info = menu_shell.clone(); - move |_, _, cx| { - info.downgrade().update_in(cx, |this, window, cx| this.show_book(id, window, cx)).ok(); + move |_, window, cx| { + info.update(cx, |this, cx| this.show_book(id, window, cx)); } })) - .item(PopupMenuItem::new("Fetch metadata").on_click(move |_, _, cx| { - fetch - .downgrade().update_in(cx, |this, window, cx| this.fetch_one(id, window, cx)) - .ok(); - })) + .item( + PopupMenuItem::new("Fetch metadata").on_click(move |_, window, cx| { + fetch.update(cx, |this, cx| this.fetch_one(id, window, cx)); + }), + ) .item(PopupMenuItem::new("Send to device").on_click({ let send_title = send_title.clone(); - move |_, _, cx| { - send.downgrade().update_in(cx, |this, window, cx| this.send_one(id, &send_title, window, cx)) - .ok(); - } + move |_, window, cx| { + send.update(cx, |this, cx| this.send_one(id, &send_title, window, cx)); + } })) .separator() - .item( - PopupMenuItem::new("Delete").on_click(move |_, window, cx| { - let delete = delete.clone(); - let title = title_for_delete.clone(); - window.open_alert_dialog(cx, move |alert, _, _| { - alert - .confirm() - .title("Delete book") - .description(format!("Delete \"{title}\" from the library?")) - .on_ok({ - let delete = delete.clone(); - move |_, _, cx| { - delete - .downgrade().update_in(cx, |this, window, cx| this.delete_one(id, window, cx)) - .ok(); + .item(PopupMenuItem::new("Delete").on_click(move |_, window, cx| { + let delete = delete.clone(); + let title = title_for_delete.clone(); + window.open_alert_dialog(cx, move |alert, _, _| { + alert + .confirm() + .title("Delete book") + .description(format!("Delete \"{title}\" from the library?")) + .on_ok({ + let delete = delete.clone(); + move |_, window, cx| { + delete.update(cx, |this, cx| this.delete_one(id, window, cx)); true } - }) - }); - }), - ) + }) + }); + })) }) .child(cover_box(cover, &title, progress, cx)) .child( @@ -1138,7 +1251,10 @@ fn cover_box( .overflow_hidden() .bg(cx.theme().muted) .child(match cover { - Some(path) => img(path.clone()).size_full().object_fit(ObjectFit::Cover).into_any_element(), + Some(path) => img(path.clone()) + .size_full() + .object_fit(ObjectFit::Cover) + .into_any_element(), None => div() .size_full() .flex() @@ -1194,8 +1310,14 @@ impl Shell { } fn send_one(&mut self, id: i64, title: &str, window: &mut Window, cx: &mut Context) { - let Some(device) = self.devices.iter().find(|device| device.supports_send).cloned() else { - self.error = "No Kindle connected. Connect over MTP / USB file transfer, then try again.".into(); + let Some(device) = self + .devices + .iter() + .find(|device| device.supports_send) + .cloned() + else { + self.error = + "No Kindle connected. Connect over MTP / USB file transfer, then try again.".into(); cx.notify(); return; }; @@ -1205,7 +1327,12 @@ impl Shell { self.spawn_task( window, cx, - async move { backend::send(&state, id, &device.id).await.map(|result| (result, device.product)).map_err(|e| e.to_string()) }, + async move { + backend::send(&state, id, &device.id) + .await + .map(|result| (result, device.product)) + .map_err(|e| e.to_string()) + }, move |this, result, _, cx| { this.busy = false; match result { diff --git a/app/src/ui/menu.rs b/app/src/ui/menu.rs new file mode 100644 index 0000000..b3f5251 --- /dev/null +++ b/app/src/ui/menu.rs @@ -0,0 +1,132 @@ +#[cfg(target_os = "macos")] +use gpui_kit::base::input::{Copy, Cut, Paste, Redo, SelectAll, Undo}; +use gpui_kit::{AnyWindowHandle, App, Entity, KeyBinding}; +#[cfg(target_os = "macos")] +use gpui_kit::{Menu, MenuItem, OsAction, SystemMenuType}; + +use super::shell::{Route, Shell}; + +gpui_kit::actions!( + grimoire, + [ + About, + OpenSettings, + Hide, + HideOthers, + ShowAll, + Quit, + CloseWindow, + Minimize, + Zoom + ] +); + +/// Installs quit, close, and settings shortcuts, plus the macOS menu bar. +/// `secondary` is Command on macOS and Control elsewhere. +/// Closing the library window quits Grimoire. A reader window can close on its own. +pub fn install(main_window: AnyWindowHandle, shell: Entity, cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("secondary-q", Quit, None), + KeyBinding::new("secondary-w", CloseWindow, None), + KeyBinding::new("alt-f4", CloseWindow, None), + KeyBinding::new("secondary-,", OpenSettings, None), + ]); + #[cfg(target_os = "macos")] + { + cx.bind_keys([ + KeyBinding::new("cmd-h", Hide, None), + KeyBinding::new("cmd-alt-h", HideOthers, None), + KeyBinding::new("cmd-m", Minimize, None), + ]); + cx.set_menus(menus()); + } + + let main_id = main_window.window_id(); + cx.on_window_closed(move |cx, closed| { + if closed == main_id { + cx.quit(); + } + }) + .detach(); + + let shell = shell.downgrade(); + cx.on_action(move |_: &OpenSettings, cx| { + let _ = shell.update(cx, |shell, cx| shell.go(Route::Settings, cx)); + // Menu actions run inside the active window update, so window calls wait a turn. + cx.defer(move |cx| { + let _ = main_window.update(cx, |_, window, _| window.activate_window()); + }); + }); + cx.on_action(|_: &About, cx| { + cx.defer(|cx| super::about::show(cx)); + }); + cx.on_action(|_: &Quit, cx| cx.quit()); + cx.on_action(|_: &Hide, cx| cx.hide()); + cx.on_action(|_: &HideOthers, cx| cx.hide_other_apps()); + cx.on_action(|_: &ShowAll, cx| cx.unhide_other_apps()); + cx.on_action(|_: &CloseWindow, cx| close_active(cx)); + cx.on_action(|_: &Minimize, cx| { + with_active(cx, |window| window.minimize_window()); + }); + cx.on_action(|_: &Zoom, cx| { + with_active(cx, |window| window.zoom_window()); + }); +} + +#[cfg(target_os = "macos")] +fn menus() -> Vec { + vec![ + Menu::new("Grimoire").items([ + MenuItem::action("About Grimoire", About), + MenuItem::separator(), + MenuItem::action("Settings…", OpenSettings), + MenuItem::separator(), + MenuItem::os_submenu("Services", SystemMenuType::Services), + MenuItem::separator(), + MenuItem::action("Hide Grimoire", Hide), + MenuItem::action("Hide Others", HideOthers), + MenuItem::action("Show All", ShowAll), + MenuItem::separator(), + MenuItem::action("Quit Grimoire", Quit), + ]), + Menu::new("File").items([MenuItem::action("Close", CloseWindow)]), + Menu::new("Edit").items([ + MenuItem::os_action("Undo", Undo, OsAction::Undo), + MenuItem::os_action("Redo", Redo, OsAction::Redo), + MenuItem::separator(), + MenuItem::os_action("Cut", Cut, OsAction::Cut), + MenuItem::os_action("Copy", Copy, OsAction::Copy), + MenuItem::os_action("Paste", Paste, OsAction::Paste), + MenuItem::separator(), + MenuItem::os_action("Select All", SelectAll, OsAction::SelectAll), + ]), + Menu::new("Window").items([ + MenuItem::action("Minimize", Minimize), + MenuItem::action("Zoom", Zoom), + ]), + ] +} + +fn close_active(cx: &mut App) { + with_active(cx, |window| window.remove_window()); +} + +fn with_active(cx: &mut App, f: impl FnOnce(&mut gpui_kit::Window) + 'static) { + let Some(handle) = cx.active_window() else { + return; + }; + cx.defer(move |cx| { + let _ = handle.update(cx, |_, window, _| f(window)); + }); +} + +#[cfg(test)] +mod tests { + #[test] + fn quit_and_close_shortcuts_parse() { + for shortcut in ["secondary-q", "secondary-w", "alt-f4", "secondary-,"] { + gpui_kit::Keystroke::parse(shortcut) + .unwrap_or_else(|_| panic!("{shortcut} is not a valid shortcut")); + } + } +} diff --git a/app/src/ui/mod.rs b/app/src/ui/mod.rs index 1b08d20..36951a2 100644 --- a/app/src/ui/mod.rs +++ b/app/src/ui/mod.rs @@ -1,18 +1,19 @@ +mod about; mod backend; mod book; +mod chime; mod devices; mod document; mod icon; mod library; +mod menu; mod reader; mod settings; mod shell; mod theme; use gpui_kit::AppContext as _; -use gpui_kit::{ - App, TitlebarOptions, WindowBounds, WindowOptions, application, point, px, size, -}; +use gpui_kit::{application, point, px, size, App, TitlebarOptions, WindowBounds, WindowOptions}; use std::future::pending; pub use shell::Shell; @@ -35,10 +36,16 @@ pub fn launch() { cx.set_global(backend::TokioRt(handle)); theme::apply_saved(cx); icon::install(); - gpui_kit::open_window(main_window_options(cx), cx, |window, cx| { - cx.new(|cx| Shell::new(window, cx)) - }) - .expect("failed to open Grimoire"); + let (main_window, shell) = + gpui_kit::open_window(main_window_options(cx), cx, |window, cx| { + cx.new(|cx| Shell::new(window, cx)) + }) + .expect("failed to open Grimoire"); + menu::install(main_window, shell, cx); + // A new window does not take focus away from the app that launched Grimoire. + #[cfg(target_os = "macos")] + cx.activate(true); + let _ = main_window.update(cx, |_, window, _| window.activate_window()); }); } diff --git a/app/src/ui/reader.rs b/app/src/ui/reader.rs index bffc26e..3b8d83b 100644 --- a/app/src/ui/reader.rs +++ b/app/src/ui/reader.rs @@ -1,19 +1,20 @@ use std::path::PathBuf; use std::sync::Arc; -use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::base::InteractiveElementExt as _; +use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::component::{ActiveTheme as _, Disableable as _}; use gpui_kit::prelude::{ - InteractiveElement as _, StatefulInteractiveElement as _, Styled as _, StyledImage as _, + FluentBuilder as _, InteractiveElement as _, StatefulInteractiveElement as _, Styled as _, + StyledImage as _, }; use gpui_kit::{ - Context, Image, ImageFormat, IntoElement, MouseButton, ObjectFit, ParentElement as _, Render, - ScrollHandle, Window, div, img, px, + div, img, px, rgb, Context, FontWeight, Hsla, Image, ImageFormat, IntoElement, MouseButton, + ObjectFit, ParentElement as _, Pixels, Render, Size, Window, }; use super::backend; -use super::document::{self, Chapter}; +use super::document::{self, Page}; use super::settings::reader_family; use super::theme::DISPLAY; use crate::reader::ReadingProgressUpdate; @@ -26,20 +27,54 @@ pub struct ReaderWindow { authors: String, format: String, path: PathBuf, + cover: Option, + on_cover: bool, error: String, loading: bool, - chapters: Vec, - chapter: usize, + chapters: Vec, + pages: Vec, + page: usize, + anchor_chapter: usize, + anchor_offset: usize, + layout_key: (u32, u32, u32), + zoom: u32, + colors: TextColors, + comic_zoom: u32, + comic_spread: bool, comic_count: usize, comic_index: usize, - comic_image: Option>, - scroll: ScrollHandle, + comic_images: Vec, + comic_load: u64, percent: f64, titlebar_drag: bool, + /// `Some(true)` is the right side, `Some(false)` the left. `None` when the pointer is in the middle. + page_edge: Option, } impl ReaderWindow { pub fn new(state: AppState, book_id: i64, window: &mut Window, cx: &mut Context) -> Self { + let saved = backend::config().ok(); + let zoom = clamp_zoom( + saved + .as_ref() + .map(|config| config.reader_zoom) + .unwrap_or(100), + ); + let colors = TextColors::parse( + saved + .as_ref() + .map(|config| config.reader_colors.as_str()) + .unwrap_or("native"), + ); + let comic_zoom = clamp_comic_zoom( + saved + .as_ref() + .map(|config| config.comic_zoom) + .unwrap_or(100), + ); + let comic_spread = saved + .as_ref() + .is_some_and(|config| comic_layout_spread(&config.comic_layout)); let reader = Self { state, book_id, @@ -47,16 +82,27 @@ impl ReaderWindow { authors: String::new(), format: String::new(), path: PathBuf::new(), + cover: None, + on_cover: false, error: String::new(), loading: true, chapters: Vec::new(), - chapter: 0, + pages: Vec::new(), + page: 0, + anchor_chapter: 0, + anchor_offset: 0, + layout_key: (0, 0, 0), + zoom, + colors, + comic_zoom, + comic_spread, comic_count: 0, comic_index: 0, - comic_image: None, - scroll: ScrollHandle::new(), + comic_images: Vec::new(), + comic_load: 0, percent: 0.0, titlebar_drag: false, + page_edge: None, }; reader.load(window, cx); reader @@ -73,28 +119,40 @@ impl ReaderWindow { cx.spawn_in(window, async move |this, cx| { let loaded = runtime .spawn_blocking(move || { - let source = backend::reader_source(&state, book_id).map_err(|e| e.to_string())?; + let source = + backend::reader_source(&state, book_id).map_err(|e| e.to_string())?; + let cover = backend::book_cover(&state, book_id).ok().flatten(); let saved = backend::progress(&state, book_id).ok().flatten(); - Ok::<_, String>((source, saved)) + Ok::<_, String>((source, cover, saved)) }) .await .unwrap_or_else(|error| Err(error.to_string())); this.update_in(cx, |this, window, cx| { this.loading = false; match loaded { - Ok((source, saved)) => { + Ok((source, cover, saved)) => { this.title = source.title.clone(); this.authors = source.authors.join(", "); this.format = source.format.clone(); this.path = PathBuf::from(&source.path); + this.cover = cover; if let Some(saved) = saved { this.percent = saved.percent; - if let Some(index) = saved.location.strip_prefix("chapter:") { - this.chapter = index.parse().unwrap_or(0); - } - if let Some(index) = saved.location.strip_prefix("page:") { + if saved.location == "cover" { + this.on_cover = this.cover.is_some(); + } else if let Some(rest) = saved.location.strip_prefix("loc:") { + let mut parts = rest.split(':'); + this.anchor_chapter = + parts.next().unwrap_or("0").parse().unwrap_or(0); + this.anchor_offset = + parts.next().unwrap_or("0").parse().unwrap_or(0); + } else if let Some(index) = saved.location.strip_prefix("chapter:") { + this.anchor_chapter = index.parse().unwrap_or(0); + } else if let Some(index) = saved.location.strip_prefix("page:") { this.comic_index = index.parse().unwrap_or(0); } + } else { + this.on_cover = this.cover.is_some(); } this.open_format(window, cx); } @@ -113,6 +171,9 @@ impl ReaderWindow { return; } if format == "CBZ" || format == "CBR" { + if self.comic_spread { + self.comic_index = align_comic_spread(self.comic_index); + } self.load_comic_page(window, cx); return; } @@ -127,10 +188,13 @@ impl ReaderWindow { this.update(cx, |this, cx| { match chapters { Ok(chapters) => { - if this.chapter >= chapters.len() { - this.chapter = 0; + if this.anchor_chapter >= chapters.len() { + this.anchor_chapter = 0; + this.anchor_offset = 0; } this.chapters = chapters; + this.pages.clear(); + this.layout_key = (0, 0, 0); } Err(error) => this.error = error, } @@ -144,19 +208,48 @@ impl ReaderWindow { fn load_comic_page(&mut self, window: &mut Window, cx: &mut Context) { let path = self.path.clone(); let index = self.comic_index; + let spread = self.comic_spread && !self.on_cover; + self.comic_load = self.comic_load.wrapping_add(1); + let generation = self.comic_load; let runtime = self.runtime(cx); cx.spawn_in(window, async move |this, cx| { let page = runtime - .spawn_blocking(move || backend::comic_page(&path, index).map_err(|e| e.to_string())) + .spawn_blocking(move || { + let (_name, data_url, count) = + backend::comic_page(&path, index).map_err(|e| e.to_string())?; + let next = if spread && index + 1 < count { + let (_name, next_url, _) = + backend::comic_page(&path, index + 1).map_err(|e| e.to_string())?; + Some(next_url) + } else { + None + }; + Ok::<_, String>((data_url, next, count)) + }) .await .unwrap_or_else(|error| Err(error.to_string())); this.update(cx, |this, cx| { + if this.comic_load != generation { + return; + } match page { - Ok((name, data_url, count)) => { + Ok((data_url, next, count)) => { this.comic_count = count; - this.comic_image = image_from_data_url(&data_url); - let _ = name; - this.remember("page", this.comic_index, cx); + let mut images = Vec::new(); + if let Some(image) = image_from_data_url(&data_url) { + images.push(image); + } + if let Some(next) = next { + if let Some(image) = image_from_data_url(&next) { + images.push(image); + } + } + this.comic_images = images; + if this.on_cover { + this.percent = slot_percent(true, 0, this.cover.is_some(), count); + } else { + this.remember("page", this.comic_index, cx); + } } Err(error) => this.error = error, } @@ -168,12 +261,17 @@ impl ReaderWindow { } fn remember(&mut self, kind: &str, index: usize, cx: &mut Context) { - let total = if kind == "page" { - self.comic_count.max(1) + let body_len = if kind == "page" { + self.comic_count } else { - self.chapters.len().max(1) + self.chapters.len() }; - let percent = ((index as f64 + 1.0) / total as f64) * 100.0; + let percent = slot_percent( + false, + index, + self.cover.is_some() && kind == "page", + body_len, + ); self.percent = percent; let state = self.state.clone(); let book_id = self.book_id; @@ -198,23 +296,105 @@ impl ReaderWindow { .detach(); } - fn note_scroll(&mut self, cx: &mut Context) { - let max = self.scroll.max_offset().y.as_f32(); - if max <= 1.0 || self.chapters.is_empty() { + fn ensure_pages(&mut self, window: &Window) { + if self.chapters.is_empty() { return; } - let offset = self.scroll.offset().y.as_f32().max(0.0); - let within = (offset / max).clamp(0.0, 1.0) as f64; - let total = self.chapters.len() as f64; - let percent = ((self.chapter as f64 + within) / total) * 100.0; - if (percent - self.percent).abs() < 1.0 { + let size = window.viewport_size(); + let key = ( + (size.width.as_f32() / 8.0) as u32, + (size.height.as_f32() / 8.0) as u32, + self.zoom, + ); + if key == self.layout_key && !self.pages.is_empty() { return; } - self.percent = percent; + let (lines, cols) = page_capacity(size, self.zoom); + self.pages = document::paginate(&self.chapters, lines, cols); + self.layout_key = key; + if !self.on_cover { + self.page = + document::page_index_at(&self.pages, self.anchor_chapter, self.anchor_offset); + } + self.percent = slot_percent( + self.on_cover, + self.page, + self.cover.is_some(), + self.pages.len(), + ); + } + + fn turn(&mut self, forward: bool) -> bool { + if self.is_comic() { + let Some((on_cover, index)) = step_comic( + self.on_cover, + self.comic_index, + self.cover.is_some(), + self.comic_count, + self.comic_spread, + forward, + ) else { + return false; + }; + self.on_cover = on_cover; + self.comic_index = index; + return true; + } + let Some((on_cover, index)) = step_place( + self.on_cover, + self.body_index(), + self.cover.is_some(), + self.body_len(), + forward, + ) else { + return false; + }; + self.on_cover = on_cover; + self.set_body_index(index); + true + } + + fn body_len(&self) -> usize { + if self.is_comic() { + self.comic_count + } else { + self.pages.len() + } + } + + fn body_index(&self) -> usize { + if self.is_comic() { + self.comic_index + } else { + self.page + } + } + + fn set_body_index(&mut self, index: usize) { + if self.is_comic() { + self.comic_index = index; + } else { + self.page = index; + } + } + + fn is_comic(&self) -> bool { + let format = self.format.to_ascii_uppercase(); + format == "CBZ" || format == "CBR" + } + + fn remember_page(&mut self, cx: &mut Context) { + let Some(page) = self.pages.get(self.page) else { + return; + }; + self.anchor_chapter = page.chapter; + self.anchor_offset = page.offset; + self.percent = slot_percent(false, self.page, self.cover.is_some(), self.pages.len()); let state = self.state.clone(); let book_id = self.book_id; let format = self.format.clone(); - let location = format!("chapter:{}", self.chapter); + let location = format!("loc:{}:{}", page.chapter, page.offset); + let percent = self.percent; let runtime = self.runtime(cx); cx.spawn(async move |_this, _cx| { let _ = runtime @@ -233,6 +413,120 @@ impl ReaderWindow { }) .detach(); } + + fn remember_cover(&mut self, cx: &mut Context) { + self.percent = slot_percent(true, 0, true, self.body_len()); + let state = self.state.clone(); + let book_id = self.book_id; + let format = self.format.clone(); + let percent = self.percent; + let runtime = self.runtime(cx); + cx.spawn(async move |_this, _cx| { + let _ = runtime + .spawn_blocking(move || { + backend::save_progress( + &state, + book_id, + ReadingProgressUpdate { + format, + location: "cover".into(), + percent, + }, + ) + }) + .await; + }) + .detach(); + } + + fn set_zoom(&mut self, zoom: u32, cx: &mut Context) { + if let Some(page) = self.pages.get(self.page) { + self.anchor_chapter = page.chapter; + self.anchor_offset = page.offset; + } + self.zoom = clamp_zoom(zoom); + self.layout_key = (0, 0, 0); + let _ = backend::save_reader_zoom(self.zoom); + cx.notify(); + } + + fn set_comic_zoom(&mut self, zoom: u32, cx: &mut Context) { + self.comic_zoom = clamp_comic_zoom(zoom); + let _ = backend::save_comic_zoom(self.comic_zoom); + cx.notify(); + } + + fn set_comic_spread(&mut self, spread: bool, window: &mut Window, cx: &mut Context) { + if self.comic_spread == spread { + return; + } + self.comic_spread = spread; + let _ = backend::save_comic_layout(comic_layout_id(spread)); + if spread && !self.on_cover { + self.comic_index = align_comic_spread(self.comic_index); + } + if self.on_cover { + cx.notify(); + return; + } + self.load_comic_page(window, cx); + } + + fn set_colors(&mut self, colors: TextColors, cx: &mut Context) { + self.colors = colors; + let _ = backend::save_reader_colors(colors.id()); + cx.notify(); + } + + fn hover_page_edge(&mut self, edge: Option, cx: &mut Context) { + if self.page_edge == edge { + return; + } + self.page_edge = edge; + cx.notify(); + } + + /// Side band over the page. Clicks turn the page. The chevron shows while the pointer is in the band. + fn page_edge( + &mut self, + forward: bool, + at_start: bool, + at_end: bool, + width: f32, + ink: Hsla, + cx: &mut Context, + ) -> impl IntoElement { + let blocked = if forward { at_end } else { at_start }; + let hinted = visible_page_edge(self.page_edge, at_start, at_end) == Some(forward); + div() + .absolute() + .top(px(0.)) + .bottom(px(0.)) + .w(px(width)) + .flex() + .items_center() + .when(forward, |zone| zone.right(px(0.)).justify_end().pr(px(16.))) + .when(!forward, |zone| { + zone.left(px(0.)).justify_start().pl(px(16.)) + }) + .when(!blocked, |zone| zone.cursor_pointer()) + .when(hinted, |zone| zone.child(page_chevron(forward, ink))) + .block_mouse_except_scroll() + .id(if forward { "turn-forward" } else { "turn-back" }) + .on_mouse_move(cx.listener(move |this, _, _, cx| { + this.hover_page_edge(Some(forward), cx); + })) + .on_mouse_exit(cx.listener(move |this, _, _, cx| { + if this.page_edge == Some(forward) { + this.hover_page_edge(None, cx); + } + })) + .on_click(cx.listener(move |this, _, window, cx| { + if this.turn(forward) { + this.finish_turn(window, cx); + } + })) + } } impl Render for ReaderWindow { @@ -241,7 +535,6 @@ impl Render for ReaderWindow { .map(|config| config.reader_font) .unwrap_or_else(|_| "serif".into()); let family = reader_family(&font); - let paper = cx.theme().popover; let ink = cx.theme().foreground; div() @@ -251,7 +544,7 @@ impl Render for ReaderWindow { .bg(cx.theme().background) .text_color(ink) .child(self.chrome(window, cx)) - .child(self.content(family, paper, window, cx)) + .child(self.content(family, window, cx)) } } @@ -303,7 +596,6 @@ impl ReaderWindow { fn content( &mut self, family: &str, - paper: gpui_kit::Hsla, window: &mut Window, cx: &mut Context, ) -> impl IntoElement { @@ -325,8 +617,23 @@ impl ReaderWindow { .flex() .flex_col() .gap(px(12.)) - .child(div().font_family(DISPLAY).text_size(px(24.)).child(self.title.clone())) - .child("PDF opens in your system viewer. EPUB, TXT, and comics stay inside Grimoire.") + .when_some(self.cover.clone(), |this, path| { + this.child( + img(path) + .max_h(px(360.)) + .max_w(px(240.)) + .object_fit(ObjectFit::Contain), + ) + }) + .child( + div() + .font_family(DISPLAY) + .text_size(px(24.)) + .child(self.title.clone()), + ) + .child( + "PDF opens in your system viewer. EPUB, TXT, and comics stay inside Grimoire.", + ) .child( Button::new("open-pdf") .primary() @@ -336,21 +643,37 @@ impl ReaderWindow { .into_any_element(); } if format == "CBZ" || format == "CBR" { - return self.comic_view(cx).into_any_element(); + return self.comic_view(window, cx).into_any_element(); } - self.text_view(family, paper, window, cx).into_any_element() + self.text_view(family, window, cx).into_any_element() } fn text_view( &mut self, family: &str, - paper: gpui_kit::Hsla, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let chapter = self.chapters.get(self.chapter).cloned(); - let count = self.chapters.len(); - let index = self.chapter; + self.ensure_pages(window); + let page = self.pages.get(self.page).cloned(); + let count = self.pages.len(); + let index = self.page; + let has_cover = self.cover.is_some(); + let on_cover = self.on_cover; + let at_start = step_place(on_cover, index, has_cover, count, false).is_none(); + let at_end = step_place(on_cover, index, has_cover, count, true).is_none(); + let shown = reading_slot(on_cover, index, has_cover) + 1; + let total = reading_len(count, has_cover).max(1); + let (paper, ink) = self + .colors + .surface(cx.theme().popover, cx.theme().foreground); + let margin = if self.colors == TextColors::Native { + cx.theme().background + } else { + paper + }; + let scale = self.zoom as f32 / 100.0; + let edge_width = page_edge_width(window.viewport_size().width.as_f32()); div() .flex_1() .min_h(px(0.)) @@ -364,83 +687,193 @@ impl ReaderWindow { .px(px(20.)) .py(px(8.)) .child( - Button::new("prev-chapter") + Button::new("prev-page") .ghost() .label("Previous") - .disabled(index == 0) - .on_click(cx.listener(|this, _, _, cx| { - if this.chapter > 0 { - this.chapter -= 1; - this.scroll.set_offset(gpui_kit::point(px(0.), px(0.))); - this.remember("chapter", this.chapter, cx); - cx.notify(); + .disabled(at_start) + .on_click(cx.listener(|this, _, window, cx| { + if this.turn(false) { + this.finish_turn(window, cx); } })), ) .child( - div().text_color(cx.theme().muted_foreground).child(format!( - "{} / {}", - index + 1, - count.max(1) - )), + div() + .text_color(cx.theme().muted_foreground) + .child(format!("{shown} / {total}")), ) .child( - Button::new("next-chapter") + Button::new("next-page") .ghost() .label("Next") - .disabled(count == 0 || index + 1 >= count) - .on_click(cx.listener(|this, _, _, cx| { - if this.chapter + 1 < this.chapters.len() { - this.chapter += 1; - this.scroll.set_offset(gpui_kit::point(px(0.), px(0.))); - this.remember("chapter", this.chapter, cx); - cx.notify(); + .disabled(at_end) + .on_click(cx.listener(|this, _, window, cx| { + if this.turn(true) { + this.finish_turn(window, cx); } })), ), ) + .child(self.reading_controls(cx)) .child( div() - .id("reader-scroll") + .id("reader-page") + .relative() .flex_1() - .overflow_y_scroll() - .track_scroll(&self.scroll) - .on_scroll_wheel(cx.listener(|this, _, _, cx| this.note_scroll(cx))) + .min_h(px(0.)) + .overflow_hidden() .px(px(28.)) .py(px(18.)) - .child( + .flex() + .flex_col() + .bg(margin) + .child(if on_cover { + self.cover_frame(paper).into_any_element() + } else { div() - .max_w(px(680.)) - .mx_auto() + .w_full() .p(px(22.)) .rounded(px(10.)) .bg(paper) + .text_color(ink) .font_family(family) - .text_size(px(18.)) - .line_height(px(30.)) - .child(match chapter { - Some(Chapter { title, body }) => div() + .text_size(px(18. * scale)) + .line_height(px(30. * scale)) + .child(match page { + Some(page) => div() .flex() .flex_col() - .gap(px(14.)) - .child( + .gap(px(14. * scale)) + .children(page.blocks.into_iter().map(move |block| { div() - .font_family(DISPLAY) - .text_size(px(26.)) - .child(title), - ) - .child(body) + .flex() + .flex_col() + .gap(px(14. * scale)) + .when_some(block.heading, |this, title| { + this.child( + div() + .font_family(DISPLAY) + .text_size(px(26. * scale)) + .child(title), + ) + }) + .child(block.text) + })) .into_any_element(), - None => div().child("No chapter text.").into_any_element(), - }), - ), + None => div().child("No pages in this book.").into_any_element(), + }) + .into_any_element() + }) + .child(self.page_edge(false, at_start, at_end, edge_width, ink, cx)) + .child(self.page_edge(true, at_start, at_end, edge_width, ink, cx)), ) } - fn comic_view(&mut self, cx: &mut Context) -> impl IntoElement { - let image = self.comic_image.clone(); + fn reading_controls(&mut self, cx: &mut Context) -> impl IntoElement { + let zoom = self.zoom; + let colors = self.colors; + div() + .flex() + .flex_wrap() + .items_center() + .gap(px(8.)) + .px(px(20.)) + .pb(px(4.)) + .child( + Button::new("zoom-out") + .ghost() + .label("A-") + .disabled(zoom <= ZOOM_MIN) + .on_click(cx.listener(|this, _, _, cx| { + this.set_zoom(this.zoom.saturating_sub(ZOOM_STEP), cx); + })), + ) + .child( + div() + .min_w(px(52.)) + .text_color(cx.theme().muted_foreground) + .child(format!("{zoom}%")), + ) + .child( + Button::new("zoom-in") + .ghost() + .label("A+") + .disabled(zoom >= ZOOM_MAX) + .on_click(cx.listener(|this, _, _, cx| { + this.set_zoom(this.zoom.saturating_add(ZOOM_STEP), cx); + })), + ) + .children(TextColors::CHOICES.iter().copied().map(|choice| { + let selected = choice == colors; + Button::new(format!("colors-{}", choice.id())) + .when(selected, |button| button.primary()) + .when(!selected, |button| button.outline()) + .label(choice.label()) + .on_click(cx.listener(move |this, _, _, cx| this.set_colors(choice, cx))) + })) + } + + fn cover_frame(&self, paper: Hsla) -> impl IntoElement { + let image = self.cover.clone().map(|path| { + img(path) + .max_w_full() + .max_h_full() + .object_fit(ObjectFit::Contain) + }); + div() + .flex_1() + .min_h(px(0.)) + .w_full() + .flex() + .items_center() + .justify_center() + .p(px(22.)) + .rounded(px(10.)) + .bg(paper) + .when_some(image, |this, image| this.child(image)) + } + + fn finish_turn(&mut self, window: &mut Window, cx: &mut Context) { + if self.on_cover { + self.remember_cover(cx); + cx.notify(); + } else if self.is_comic() { + self.load_comic_page(window, cx); + } else { + self.remember_page(cx); + cx.notify(); + } + } + + fn comic_view(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let images = self.comic_images.clone(); let index = self.comic_index; let count = self.comic_count; + let has_cover = self.cover.is_some(); + let on_cover = self.on_cover; + let spread = self.comic_spread; + let at_start = step_comic(on_cover, index, has_cover, count, spread, false).is_none(); + let at_end = step_comic(on_cover, index, has_cover, count, spread, true).is_none(); + let place = comic_place_label(on_cover, index, has_cover, count, spread); + let paper = self + .colors + .surface(cx.theme().popover, cx.theme().foreground) + .0; + let pages_across = if spread && !on_cover && images.len() > 1 { + 2 + } else { + 1 + }; + let page_width = comic_page_width(window.viewport_size(), pages_across); + let zoom = self.comic_zoom; + let sizes: Vec<(f32, f32)> = images + .iter() + .map(|frame| fitted_page_size(page_width, frame.width, frame.height, zoom)) + .collect(); + let content_width = sizes.iter().map(|(width, _)| *width).sum::() + + COMIC_SPREAD_GAP * sizes.len().saturating_sub(1) as f32; + let edge_width = page_edge_width(window.viewport_size().width.as_f32()); + let ink = cx.theme().foreground; div() .flex_1() .min_h(px(0.)) @@ -457,45 +890,554 @@ impl ReaderWindow { Button::new("prev-page") .ghost() .label("Previous") - .disabled(index == 0) + .disabled(at_start) .on_click(cx.listener(|this, _, window, cx| { - if this.comic_index > 0 { - this.comic_index -= 1; - this.load_comic_page(window, cx); + if this.turn(false) { + this.finish_turn(window, cx); } })), ) - .child(format!("{} / {}", index + 1, count.max(1))) + .child(place) .child( Button::new("next-page") .ghost() .label("Next") - .disabled(count == 0 || index + 1 >= count) + .disabled(at_end) .on_click(cx.listener(|this, _, window, cx| { - if this.comic_index + 1 < this.comic_count { - this.comic_index += 1; - this.load_comic_page(window, cx); + if this.turn(true) { + this.finish_turn(window, cx); } })), ), ) + .child(self.comic_controls(cx)) .child( div() - .id("comic-scroll") + .relative() .flex_1() - .overflow_y_scroll() + .min_h(px(0.)) + .w_full() .flex() - .justify_center() - .p(px(12.)) - .child(match image { - Some(image) => img(image).max_w(px(900.)).object_fit(ObjectFit::Contain).into_any_element(), - None => div().child("Loading page…").into_any_element(), - }), + .flex_col() + .child( + div() + .id("comic-scroll") + .flex_1() + .min_h(px(0.)) + .w_full() + .overflow_scroll() + .flex() + .flex_col() + .items_start() + .child(if on_cover { + self.cover_frame(paper).into_any_element() + } else if images.is_empty() { + div().p(px(12.)).child("Loading page…").into_any_element() + } else { + div() + .flex_shrink_0() + .w(px(content_width)) + .flex() + .flex_row() + .items_start() + .justify_start() + .gap(px(COMIC_SPREAD_GAP)) + .children(images.into_iter().zip(sizes).map( + |(frame, (width, height))| { + img(frame.image) + .flex_shrink_0() + .w(px(width)) + .h(px(height)) + .object_fit(ObjectFit::Contain) + }, + )) + .into_any_element() + }), + ) + .child(self.page_edge(false, at_start, at_end, edge_width, ink, cx)) + .child(self.page_edge(true, at_start, at_end, edge_width, ink, cx)), + ) + } + + fn comic_controls(&mut self, cx: &mut Context) -> impl IntoElement { + let zoom = self.comic_zoom; + let spread = self.comic_spread; + div() + .flex() + .flex_wrap() + .items_center() + .justify_center() + .gap(px(8.)) + .px(px(20.)) + .pb(px(4.)) + .child( + Button::new("comic-zoom-out") + .ghost() + .label("-") + .disabled(zoom <= COMIC_ZOOM_MIN) + .on_click(cx.listener(|this, _, _, cx| { + this.set_comic_zoom(this.comic_zoom.saturating_sub(COMIC_ZOOM_STEP), cx); + })), + ) + .child( + div() + .min_w(px(52.)) + .text_color(cx.theme().muted_foreground) + .child(format!("{zoom}%")), + ) + .child( + Button::new("comic-zoom-in") + .ghost() + .label("+") + .disabled(zoom >= COMIC_ZOOM_MAX) + .on_click(cx.listener(|this, _, _, cx| { + this.set_comic_zoom(this.comic_zoom.saturating_add(COMIC_ZOOM_STEP), cx); + })), + ) + .child( + Button::new("comic-single") + .when(!spread, |button| button.primary()) + .when(spread, |button| button.outline()) + .label("Single page") + .on_click(cx.listener(|this, _, window, cx| { + this.set_comic_spread(false, window, cx); + })), + ) + .child( + Button::new("comic-spread") + .when(spread, |button| button.primary()) + .when(!spread, |button| button.outline()) + .label("Two pages") + .on_click(cx.listener(|this, _, window, cx| { + this.set_comic_spread(true, window, cx); + })), ) } } -fn image_from_data_url(data_url: &str) -> Option> { +pub(crate) const ZOOM_MIN: u32 = 80; +pub(crate) const ZOOM_MAX: u32 = 200; +pub(crate) const ZOOM_STEP: u32 = 10; +pub(crate) const COMIC_ZOOM_MIN: u32 = 50; +pub(crate) const COMIC_ZOOM_MAX: u32 = 200; +pub(crate) const COMIC_ZOOM_STEP: u32 = 10; +const COMIC_SPREAD_GAP: f32 = 12.0; + +pub(crate) fn clamp_zoom(zoom: u32) -> u32 { + zoom.clamp(ZOOM_MIN, ZOOM_MAX) +} + +pub(crate) fn clamp_comic_zoom(zoom: u32) -> u32 { + zoom.clamp(COMIC_ZOOM_MIN, COMIC_ZOOM_MAX) +} + +pub(crate) fn comic_layout_spread(layout: &str) -> bool { + layout.eq_ignore_ascii_case("spread") +} + +pub(crate) fn comic_layout_id(spread: bool) -> &'static str { + if spread { + "spread" + } else { + "single" + } +} + +fn align_comic_spread(index: usize) -> usize { + index - (index % 2) +} + +const PAGE_EDGE_FRACTION: f32 = 0.28; +const PAGE_EDGE_MIN: f32 = 128.0; + +/// Width of each clickable side of the page. The two bands stay apart so a middle strip remains. +fn page_edge_width(viewport_width: f32) -> f32 { + let width = viewport_width.max(1.0); + let cap = (width * 0.42).max(1.0); + let band = width * PAGE_EDGE_FRACTION; + band.clamp(PAGE_EDGE_MIN.min(cap), cap) +} + +/// `Some(true)` is the right-hand chevron. A side at the end of the book stays hidden. +fn visible_page_edge(hover: Option, at_start: bool, at_end: bool) -> Option { + match hover { + Some(false) if !at_start => Some(false), + Some(true) if !at_end => Some(true), + _ => None, + } +} + +fn page_chevron(forward: bool, ink: Hsla) -> impl IntoElement { + div() + .w(px(88.)) + .h(px(88.)) + .rounded(px(44.)) + .flex() + .items_center() + .justify_center() + .bg(ink.opacity(0.2)) + .text_color(ink.opacity(0.82)) + .text_size(px(64.)) + .line_height(px(64.)) + .font_weight(FontWeight::BOLD) + .child(if forward { "❯" } else { "❮" }) +} + +/// Width of one page so the visible pages span the reader window. `pages` is 1 or 2. +fn comic_page_width(viewport: Size, pages: usize) -> f32 { + let pages = pages.clamp(1, 2); + let gap = if pages > 1 { COMIC_SPREAD_GAP } else { 0.0 }; + let width = viewport.width.as_f32().max(160.0); + ((width - gap) / pages as f32).max(80.0) +} + +/// Width fills the slot. Height follows the page aspect, so a tall page scrolls. +fn fitted_page_size(slot_w: f32, image_w: f32, image_h: f32, zoom: u32) -> (f32, f32) { + let image_w = if image_w > 0.0 { image_w } else { 2.0 }; + let image_h = if image_h > 0.0 { image_h } else { 3.0 }; + let width = slot_w * zoom as f32 / 100.0; + (width, width * (image_h / image_w)) +} + +fn reading_len(body_len: usize, has_cover: bool) -> usize { + body_len + usize::from(has_cover) +} + +fn reading_slot(on_cover: bool, body_index: usize, has_cover: bool) -> usize { + if on_cover { + 0 + } else { + body_index + usize::from(has_cover) + } +} + +fn slot_percent(on_cover: bool, body_index: usize, has_cover: bool, body_len: usize) -> f64 { + let total = reading_len(body_len, has_cover).max(1); + let index = reading_slot(on_cover, body_index, has_cover); + ((index as f64 + 1.0) / total as f64) * 100.0 +} + +/// The metadata cover sits in front of the body. `None` means that direction has no page. +fn step_place( + on_cover: bool, + body_index: usize, + has_cover: bool, + body_len: usize, + forward: bool, +) -> Option<(bool, usize)> { + if forward { + if on_cover { + if body_len == 0 { + None + } else { + Some((false, 0)) + } + } else if body_index + 1 < body_len { + Some((false, body_index + 1)) + } else { + None + } + } else if on_cover { + None + } else if body_index > 0 { + Some((false, body_index - 1)) + } else if has_cover { + Some((true, 0)) + } else { + None + } +} + +fn step_comic( + on_cover: bool, + index: usize, + has_cover: bool, + count: usize, + spread: bool, + forward: bool, +) -> Option<(bool, usize)> { + let step = if spread { 2 } else { 1 }; + if forward { + if on_cover { + if count == 0 { + None + } else { + Some((false, 0)) + } + } else { + let next = index.saturating_add(step); + if next < count { + Some((false, next)) + } else { + None + } + } + } else if on_cover { + None + } else if index == 0 { + if has_cover { + Some((true, 0)) + } else { + None + } + } else { + Some((false, index.saturating_sub(step))) + } +} + +fn comic_place_label( + on_cover: bool, + index: usize, + has_cover: bool, + count: usize, + spread: bool, +) -> String { + let total = reading_len(count, has_cover).max(1); + if on_cover || !spread || count == 0 { + let shown = reading_slot(on_cover, index, has_cover) + 1; + return format!("{shown} / {total}"); + } + let left = reading_slot(false, index, has_cover) + 1; + let right_index = (index + 1).min(count - 1); + let right = reading_slot(false, right_index, has_cover) + 1; + if right == left { + format!("{left} / {total}") + } else { + format!("{left}-{right} / {total}") + } +} + +fn page_capacity(size: Size, zoom: u32) -> (usize, usize) { + let zoom = clamp_zoom(zoom) as f32 / 100.0; + let font = 18.0 * zoom; + let line = 30.0 * zoom; + let text_width = (size.width.as_f32() - 28.0 * 2.0 - 22.0 * 2.0).max(240.0); + let chars = (text_width / (font * 0.55)).floor() as usize; + let text_height = (size.height.as_f32() - 48.0 - 52.0 - 40.0 - 36.0 - 44.0).max(line * 6.0); + let lines = (text_height / line).floor() as usize; + (lines.saturating_sub(1).max(4), chars.max(24)) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TextColors { + Native, + BlackAndWhite, + Inverted, + Paper, +} + +impl TextColors { + pub(crate) const CHOICES: [Self; 4] = [ + Self::Native, + Self::BlackAndWhite, + Self::Inverted, + Self::Paper, + ]; + + pub(crate) fn parse(id: &str) -> Self { + match id { + "ink" => Self::BlackAndWhite, + "inverted" => Self::Inverted, + "paper" => Self::Paper, + _ => Self::Native, + } + } + + pub(crate) fn id(self) -> &'static str { + match self { + Self::Native => "native", + Self::BlackAndWhite => "ink", + Self::Inverted => "inverted", + Self::Paper => "paper", + } + } + + pub(crate) fn label(self) -> &'static str { + match self { + Self::Native => "Native", + Self::BlackAndWhite => "Black and white", + Self::Inverted => "Inverted", + Self::Paper => "Paper", + } + } + + fn surface(self, theme_paper: Hsla, theme_ink: Hsla) -> (Hsla, Hsla) { + match self { + Self::Native => (theme_paper, theme_ink), + Self::BlackAndWhite => (solid(0xffffff), solid(0x000000)), + Self::Inverted => (solid(0x000000), solid(0xffffff)), + Self::Paper => (solid(0xf4efe4), solid(0x1c1814)), + } + } +} + +fn solid(hex: u32) -> Hsla { + rgb(hex).into() +} + +#[cfg(test)] +mod tests { + use super::{page_capacity, TextColors}; + use gpui_kit::{px, rgb, size, Hsla}; + + #[test] + fn wide_window_fits_more_text() { + let narrow = page_capacity(size(px(800.), px(700.)), 100); + let wide = page_capacity(size(px(1600.), px(1100.)), 100); + assert!(wide.0 > narrow.0); + assert!(wide.1 > narrow.1); + } + + #[test] + fn larger_type_fits_less_on_a_page() { + let size = size(px(1200.), px(900.)); + let normal = page_capacity(size, 100); + let large = page_capacity(size, 200); + assert!(large.0 < normal.0); + assert!(large.1 < normal.1); + } + + #[test] + fn text_colors_cover_the_four_reading_surfaces() { + let theme_paper: Hsla = rgb(0x112233).into(); + let theme_ink: Hsla = rgb(0xaabbcc).into(); + assert_eq!(TextColors::parse("native"), TextColors::Native); + assert_eq!(TextColors::parse("ink"), TextColors::BlackAndWhite); + assert_eq!(TextColors::parse("inverted"), TextColors::Inverted); + assert_eq!(TextColors::parse("paper"), TextColors::Paper); + assert_eq!(TextColors::parse("other"), TextColors::Native); + + let (paper, ink) = TextColors::Native.surface(theme_paper, theme_ink); + assert_eq!(paper, theme_paper); + assert_eq!(ink, theme_ink); + + let (paper, ink) = TextColors::BlackAndWhite.surface(theme_paper, theme_ink); + assert_eq!(paper, rgb(0xffffff).into()); + assert_eq!(ink, rgb(0x000000).into()); + + let (paper, ink) = TextColors::Inverted.surface(theme_paper, theme_ink); + assert_eq!(paper, rgb(0x000000).into()); + assert_eq!(ink, rgb(0xffffff).into()); + + let (paper, ink) = TextColors::Paper.surface(theme_paper, theme_ink); + assert_eq!(paper, rgb(0xf4efe4).into()); + assert_eq!(ink, rgb(0x1c1814).into()); + assert_ne!(paper, rgb(0xffffff).into()); + } + + #[test] + fn metadata_cover_is_the_first_page() { + assert_eq!(super::reading_len(10, true), 11); + assert_eq!(super::reading_len(10, false), 10); + assert_eq!(super::reading_slot(true, 4, true), 0); + assert_eq!(super::reading_slot(false, 0, true), 1); + assert_eq!(super::reading_slot(false, 4, true), 5); + assert_eq!(super::reading_slot(false, 0, false), 0); + + assert_eq!(super::step_place(true, 0, true, 10, false), None); + assert_eq!(super::step_place(true, 0, true, 10, true), Some((false, 0))); + assert_eq!( + super::step_place(false, 0, true, 10, false), + Some((true, 0)) + ); + assert_eq!(super::step_place(false, 0, false, 10, false), None); + assert_eq!(super::step_place(false, 9, true, 10, true), None); + assert!((super::slot_percent(true, 0, true, 9) - 10.0).abs() < 0.01); + } + + #[test] + fn comic_spread_turns_two_pages_and_keeps_the_cover_single() { + assert_eq!( + super::step_comic(true, 0, true, 5, true, true), + Some((false, 0)) + ); + assert_eq!( + super::step_comic(false, 0, true, 5, true, true), + Some((false, 2)) + ); + assert_eq!( + super::step_comic(false, 2, true, 5, true, true), + Some((false, 4)) + ); + assert_eq!(super::step_comic(false, 4, true, 5, true, true), None); + assert_eq!( + super::step_comic(false, 4, true, 5, true, false), + Some((false, 2)) + ); + assert_eq!( + super::step_comic(false, 0, true, 5, true, false), + Some((true, 0)) + ); + assert_eq!(super::step_comic(true, 0, true, 5, true, false), None); + assert_eq!(super::step_comic(false, 0, false, 5, false, false), None); + assert_eq!( + super::step_comic(false, 1, false, 5, false, true), + Some((false, 2)) + ); + assert_eq!(super::align_comic_spread(3), 2); + assert_eq!(super::align_comic_spread(4), 4); + assert_eq!(super::comic_place_label(false, 0, true, 5, true), "2-3 / 6"); + assert_eq!(super::comic_place_label(true, 0, true, 5, true), "1 / 6"); + assert_eq!(super::comic_place_label(false, 4, false, 5, true), "5 / 5"); + assert!(super::comic_layout_spread("spread")); + assert!(!super::comic_layout_spread("single")); + assert_eq!(super::comic_layout_id(true), "spread"); + assert_eq!(super::comic_layout_id(false), "single"); + assert_eq!(super::clamp_comic_zoom(10), super::COMIC_ZOOM_MIN); + assert_eq!(super::clamp_comic_zoom(400), super::COMIC_ZOOM_MAX); + } + + #[test] + fn comic_page_uses_the_full_window_width() { + let (width, height) = super::fitted_page_size(800.0, 1000.0, 1500.0, 100); + assert!((width - 800.0).abs() < 0.1); + assert!((height - 1200.0).abs() < 0.1); + + let (zoomed_w, zoomed_h) = super::fitted_page_size(800.0, 1000.0, 1500.0, 200); + assert!((zoomed_w - 1600.0).abs() < 0.1); + assert!((zoomed_h - 2400.0).abs() < 0.1); + + let maximized = size(px(1600.), px(1000.)); + let windowed = size(px(920.), px(760.)); + let max_w = super::comic_page_width(maximized, 1); + let win_w = super::comic_page_width(windowed, 1); + assert!((max_w - 1600.0).abs() < 0.1); + assert!((win_w - 920.0).abs() < 0.1); + let spread = super::comic_page_width(maximized, 2); + assert!((spread * 2.0 + super::COMIC_SPREAD_GAP - 1600.0).abs() < 0.1); + } + + #[test] + fn page_edges_leave_a_middle_and_hide_at_the_ends() { + let band = super::page_edge_width(920.0); + assert!(band >= 128.0); + assert!(band * 2.0 < 920.0); + let wide = super::page_edge_width(1600.0); + assert!((wide - 1600.0 * 0.28).abs() < 0.1); + assert!(wide * 2.0 < 1600.0); + + assert_eq!( + super::visible_page_edge(Some(true), false, false), + Some(true) + ); + assert_eq!( + super::visible_page_edge(Some(false), false, false), + Some(false) + ); + assert_eq!(super::visible_page_edge(Some(false), true, false), None); + assert_eq!(super::visible_page_edge(Some(true), false, true), None); + assert_eq!(super::visible_page_edge(None, false, false), None); + } +} + +#[derive(Clone)] +struct ComicFrame { + image: Arc, + width: f32, + height: f32, +} + +fn image_from_data_url(data_url: &str) -> Option { let encoded = data_url.split_once("base64,")?.1; let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded).ok()?; let format = image::guess_format(&bytes).ok()?; @@ -507,7 +1449,14 @@ fn image_from_data_url(data_url: &str) -> Option> { image::ImageFormat::Bmp => ImageFormat::Bmp, _ => return None, }; - Some(Arc::new(Image::from_bytes(gpui_format, bytes))) + let (width, height) = image::ImageReader::with_format(std::io::Cursor::new(&bytes), format) + .into_dimensions() + .unwrap_or((2, 3)); + Some(ComicFrame { + image: Arc::new(Image::from_bytes(gpui_format, bytes)), + width: width as f32, + height: height as f32, + }) } fn open_path(path: &std::path::Path) { diff --git a/app/src/ui/settings.rs b/app/src/ui/settings.rs index 040e9d3..7dbb5bc 100644 --- a/app/src/ui/settings.rs +++ b/app/src/ui/settings.rs @@ -3,9 +3,10 @@ use gpui_kit::component::{ActiveTheme as _, Disableable as _, WindowExt as _}; use gpui_kit::prelude::{ FluentBuilder as _, InteractiveElement as _, StatefulInteractiveElement as _, Styled as _, }; -use gpui_kit::{Context, FontWeight, IntoElement, ParentElement as _, Window, div, px}; +use gpui_kit::{div, px, Context, FontWeight, IntoElement, ParentElement as _, Window}; use super::backend; +use super::reader::{self, TextColors}; use super::shell::Shell; use super::theme::DISPLAY; @@ -20,11 +21,7 @@ const FONTS: &[(&str, &str, &str)] = &[ "Serif", "A reading serif, Iowan Old Style or Georgia.", ), - ( - "sans", - "Sans", - "A clean sans for long stretches of text.", - ), + ("sans", "Sans", "A clean sans for long stretches of text."), ( "opendyslexic", "OpenDyslexic", @@ -33,7 +30,11 @@ const FONTS: &[(&str, &str, &str)] = &[ ]; impl Shell { - pub fn settings_page(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + pub fn settings_page( + &mut self, + _window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { let path = self .library_path .clone() @@ -46,6 +47,10 @@ impl Shell { "" }; let font = self.reader_font.clone(); + let zoom = self.reader_zoom; + let colors = self.reader_colors.clone(); + let comic_zoom = self.comic_zoom; + let comic_spread = self.comic_spread; div() .id("settings-page") @@ -133,6 +138,150 @@ impl Shell { .child("The quick brown fox jumps over the lazy dog."), ) }) + .child( + settings_block( + "Default text size", + "New books open at this size. A- and A+ in the reader still change the book you have open, and that change is saved here.", + cx, + ) + .child( + div() + .mt(px(10.)) + .flex() + .items_center() + .gap(px(8.)) + .child( + Button::new("settings-zoom-out") + .outline() + .label("A-") + .disabled(zoom <= reader::ZOOM_MIN) + .on_click(cx.listener(|this, _, _, cx| { + let next = reader::clamp_zoom( + this.reader_zoom.saturating_sub(reader::ZOOM_STEP), + ); + this.reader_zoom = next; + let _ = backend::save_reader_zoom(next); + this.message = format!("Default text size set to {next}%."); + cx.notify(); + })), + ) + .child(format!("{zoom}%")) + .child( + Button::new("settings-zoom-in") + .outline() + .label("A+") + .disabled(zoom >= reader::ZOOM_MAX) + .on_click(cx.listener(|this, _, _, cx| { + let next = reader::clamp_zoom( + this.reader_zoom.saturating_add(reader::ZOOM_STEP), + ); + this.reader_zoom = next; + let _ = backend::save_reader_zoom(next); + this.message = format!("Default text size set to {next}%."); + cx.notify(); + })), + ), + ), + ) + .child({ + let mut block = settings_block( + "Default text colors", + "New books open with this text area. Choosing a color in the reader saves it here too.", + cx, + ); + block = block.child(div().mt(px(10.)).flex().flex_wrap().gap(px(8.)).children( + TextColors::CHOICES.iter().copied().map(|choice| { + let selected = colors == choice.id(); + Button::new(format!("settings-colors-{}", choice.id())) + .when(selected, |button| button.primary()) + .when(!selected, |button| button.outline()) + .label(choice.label()) + .on_click(cx.listener(move |this, _, _, cx| { + this.reader_colors = choice.id().to_string(); + let _ = backend::save_reader_colors(&this.reader_colors); + this.message = + format!("Default text colors set to {}.", choice.label()); + cx.notify(); + })) + }), + )); + block + }) + .child( + settings_block( + "Comic reader", + "Comics open at this size and page layout. Zoom and the page toggle in the reader save the choice here too.", + cx, + ) + .child( + div() + .mt(px(10.)) + .flex() + .items_center() + .gap(px(8.)) + .child( + Button::new("settings-comic-zoom-out") + .outline() + .label("-") + .disabled(comic_zoom <= reader::COMIC_ZOOM_MIN) + .on_click(cx.listener(|this, _, _, cx| { + let next = reader::clamp_comic_zoom( + this.comic_zoom.saturating_sub(reader::COMIC_ZOOM_STEP), + ); + this.comic_zoom = next; + let _ = backend::save_comic_zoom(next); + this.message = format!("Comic zoom set to {next}%."); + cx.notify(); + })), + ) + .child(format!("{comic_zoom}%")) + .child( + Button::new("settings-comic-zoom-in") + .outline() + .label("+") + .disabled(comic_zoom >= reader::COMIC_ZOOM_MAX) + .on_click(cx.listener(|this, _, _, cx| { + let next = reader::clamp_comic_zoom( + this.comic_zoom.saturating_add(reader::COMIC_ZOOM_STEP), + ); + this.comic_zoom = next; + let _ = backend::save_comic_zoom(next); + this.message = format!("Comic zoom set to {next}%."); + cx.notify(); + })), + ), + ) + .child( + div() + .mt(px(10.)) + .flex() + .gap(px(8.)) + .child( + Button::new("settings-comic-single") + .when(!comic_spread, |button| button.primary()) + .when(comic_spread, |button| button.outline()) + .label("Single page") + .on_click(cx.listener(|this, _, _, cx| { + this.comic_spread = false; + let _ = backend::save_comic_layout(reader::comic_layout_id(false)); + this.message = "Comics open one page at a time.".into(); + cx.notify(); + })), + ) + .child( + Button::new("settings-comic-spread") + .when(comic_spread, |button| button.primary()) + .when(!comic_spread, |button| button.outline()) + .label("Two pages") + .on_click(cx.listener(|this, _, _, cx| { + this.comic_spread = true; + let _ = backend::save_comic_layout(reader::comic_layout_id(true)); + this.message = "Comics open two pages at a time.".into(); + cx.notify(); + })), + ), + ), + ) } fn confirm_clear(&mut self, window: &mut Window, cx: &mut Context) { @@ -145,8 +294,7 @@ impl Shell { .on_ok({ let shell = shell.clone(); move |_, _, cx| { - shell - .downgrade().update_in(cx, |this, _, cx| { + shell.update(cx, |this, cx| { match backend::clear_library(&this.state) { Ok(status) => { this.apply_status(status); @@ -156,9 +304,8 @@ impl Shell { Err(error) => this.error = error.to_string(), } cx.notify(); - }) - .ok(); - true + }); + true } }) }); @@ -198,7 +345,7 @@ pub fn reader_family(id: &str) -> &'static str { #[cfg(test)] mod tests { - use super::{reader_family, DISPLAY, FONTS}; + use super::{reader, reader_family, TextColors, DISPLAY, FONTS}; #[test] fn settings_offers_the_reader_font_choices() { @@ -218,4 +365,23 @@ mod tests { fn unknown_reader_font_uses_the_reading_serif() { assert_eq!(reader_family("comic-sans"), DISPLAY); } + + #[test] + fn settings_uses_the_reader_color_and_zoom_ranges() { + let labels: Vec<_> = TextColors::CHOICES + .iter() + .map(|choice| choice.label()) + .collect(); + assert_eq!(labels, ["Native", "Black and white", "Inverted", "Paper"]); + assert_eq!(reader::ZOOM_MIN, 80); + assert_eq!(reader::ZOOM_MAX, 200); + assert_eq!(reader::clamp_zoom(10), 80); + assert_eq!(reader::clamp_zoom(400), 200); + assert_eq!(reader::COMIC_ZOOM_MIN, 50); + assert_eq!(reader::COMIC_ZOOM_MAX, 200); + assert_eq!(reader::clamp_comic_zoom(10), 50); + assert_eq!(reader::clamp_comic_zoom(400), 200); + assert!(reader::comic_layout_spread("spread")); + assert!(!reader::comic_layout_spread("single")); + } } diff --git a/app/src/ui/shell.rs b/app/src/ui/shell.rs index ecec8da..bc83197 100644 --- a/app/src/ui/shell.rs +++ b/app/src/ui/shell.rs @@ -2,17 +2,17 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::time::Duration; +use gpui_kit::base::InteractiveElementExt as _; use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::component::input::{InputEvent, InputState, TextareaState}; -use gpui_kit::base::InteractiveElementExt as _; use gpui_kit::component::menu::{DropdownMenu as _, PopupMenuItem}; use gpui_kit::component::{ActiveTheme as _, IconName, Selectable as _}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::AppContext as _; use gpui_kit::{ - Anchor, App, Context, Entity, ExternalPaths, FontWeight, Image, ImageFormat, - InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Render, Styled as _, - Subscription, Window, div, img, px, + div, img, px, relative, Anchor, App, Context, Entity, ExternalPaths, FontWeight, Image, + ImageFormat, InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Render, + Styled as _, Subscription, Window, }; use super::backend::{self, LibraryStatus, TokioRt}; @@ -41,6 +41,28 @@ pub enum FormatKind { Comic, } +#[derive(Clone, Debug)] +pub struct ImportProgress { + pub done: usize, + pub total: usize, + pub name: String, +} + +pub(crate) fn import_status_label(progress: &ImportProgress) -> String { + if progress.total == 0 { + return progress.name.clone(); + } + let current = (progress.done + 1).min(progress.total); + format!("Importing {current} of {}", progress.total) +} + +fn import_fraction(done: usize, total: usize) -> f32 { + if total == 0 { + return 0.0; + } + (done as f32 / total as f32).clamp(0.0, 1.0) +} + pub struct Shell { pub state: AppState, pub route: Route, @@ -48,8 +70,15 @@ pub struct Shell { pub device_id: Option, pub theme_pref: String, pub reader_font: String, + pub reader_zoom: u32, + pub reader_colors: String, + pub comic_zoom: u32, + pub comic_spread: bool, pub devices: Vec, pub notice: Option, + notice_gen: u64, + pub(crate) device_io: u32, + pub(crate) device_missing_polls: u8, pub booting: bool, pub boot_error: String, pub library_open: bool, @@ -61,6 +90,7 @@ pub struct Shell { pub search_gen: u64, pub loading: bool, pub busy: bool, + pub import_progress: Option, pub error: String, pub message: String, pub filters_open: bool, @@ -94,15 +124,15 @@ pub struct Shell { pub device_query: Entity, pub device_selected: HashSet, pub device_select_mode: bool, + pub(crate) device_prompt_open: bool, titlebar_drag: bool, _subs: Vec, } impl Shell { pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let search = cx.new(|cx| { - InputState::new(window, cx).placeholder("Search title, author, or tag") - }); + let search = + cx.new(|cx| InputState::new(window, cx).placeholder("Search title, author, or tag")); let bulk_tags = cx.new(|cx| InputState::new(window, cx).placeholder("Tags to add")); let bulk_series_input = cx.new(|cx| InputState::new(window, cx).placeholder("Series name (blank clears)")); @@ -115,7 +145,8 @@ impl Shell { let series_index_input = cx.new(|cx| InputState::new(window, cx)); let isbn_input = cx.new(|cx| InputState::new(window, cx)); let comment_input = cx.new(|cx| TextareaState::new(window, cx).placeholder("Notes")); - let device_query = cx.new(|cx| InputState::new(window, cx).placeholder("Filter device books")); + let device_query = + cx.new(|cx| InputState::new(window, cx).placeholder("Filter device books")); let search_sub = cx.subscribe_in(&search, window, |this, input, event, window, cx| { if matches!(event, InputEvent::Change) { @@ -127,6 +158,12 @@ impl Shell { let config = backend::config().unwrap_or_default(); let theme_pref = config.theme.clone(); let reader_font = config.reader_font.clone(); + let reader_zoom = super::reader::clamp_zoom(config.reader_zoom); + let reader_colors = super::reader::TextColors::parse(&config.reader_colors) + .id() + .to_string(); + let comic_zoom = super::reader::clamp_comic_zoom(config.comic_zoom); + let comic_spread = super::reader::comic_layout_spread(&config.comic_layout); let shell = Self { state: AppState::new(), @@ -135,8 +172,15 @@ impl Shell { device_id: None, theme_pref, reader_font, + reader_zoom, + reader_colors, + comic_zoom, + comic_spread, devices: Vec::new(), notice: None, + notice_gen: 0, + device_io: 0, + device_missing_polls: 0, booting: true, boot_error: String::new(), library_open: false, @@ -148,6 +192,7 @@ impl Shell { search_gen: 0, loading: true, busy: false, + import_progress: None, error: String::new(), message: String::new(), filters_open: true, @@ -181,6 +226,7 @@ impl Shell { device_query, device_selected: HashSet::new(), device_select_mode: false, + device_prompt_open: false, titlebar_drag: false, _subs: vec![search_sub], }; @@ -268,32 +314,76 @@ impl Shell { fn watch_devices(&self, window: &mut Window, cx: &mut Context) { let runtime = self.runtime(cx); cx.spawn_in(window, async move |this, cx| { - let mut seen: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + let mut prompted: HashSet = HashSet::new(); + let mut chimed: HashSet = HashSet::new(); + let mut primed = false; loop { let next = runtime.spawn(async { backend::readers().await }).await; - let _ = this.update(cx, |this, cx| { + let _ = this.update_in(cx, |this, window, cx| { if let Ok(Ok(devices)) = next { - let ids: Vec = devices.iter().map(|d| d.id.clone()).collect(); - if !seen.is_empty() { - for device in &devices { - if !seen.iter().any(|id| id == &device.id) { - this.notice = Some(format!("{} connected.", device.product)); - } - } + let ids: HashSet = + devices.iter().map(|device| device.id.clone()).collect(); + if primed { + let mut left = false; for id in &seen { - if !ids.iter().any(|next| next == id) { + if !ids.contains(id) { + left = true; let name = this .devices .iter() .find(|device| &device.id == id) .map(|device| device.product.clone()) .unwrap_or_else(|| "eReader".into()); - this.notice = Some(format!("{name} disconnected.")); + this.show_notice(format!("{name} disconnected."), window, cx); } } + if left { + super::chime::play_disconnect_chime(); + } + } + primed = true; + prompted.retain(|id| ids.contains(id)); + chimed.retain(|id| ids.contains(id)); + let offer: Vec = devices + .iter() + .filter(|device| !prompted.contains(&device.id)) + .cloned() + .collect(); + let offer_ids: Vec = + offer.iter().map(|device| device.id.clone()).collect(); + let fresh: Vec = offer_ids + .iter() + .filter(|id| !chimed.contains(*id)) + .cloned() + .collect(); + for id in &fresh { + chimed.insert(id.clone()); + } + let will_ask = !this.device_prompt_open + && super::devices::offer_manage(&offer_ids, this.device_id.as_deref()); + if !fresh.is_empty() && !will_ask { + super::chime::play_connect_chime(); } + this.note_open_device(&ids); seen = ids; this.devices = devices; + if this.device_prompt_open { + // Ask on a later pass, once the current question is closed. + } else if super::devices::offer_manage( + &offer_ids, + this.device_id.as_deref(), + ) { + for id in &offer_ids { + prompted.insert(id.clone()); + } + this.device_prompt_open = true; + this.ask_to_manage(offer, window, cx); + } else { + for id in offer_ids { + prompted.insert(id); + } + } cx.notify(); } }); @@ -306,6 +396,27 @@ impl Shell { .detach(); } + fn show_notice(&mut self, text: String, window: &mut Window, cx: &mut Context) { + self.notice_gen = self.notice_gen.wrapping_add(1); + let generation = self.notice_gen; + self.notice = Some(text); + let runtime = self.runtime(cx); + cx.spawn_in(window, async move |this, cx| { + let _ = runtime + .spawn(async { + tokio::time::sleep(Duration::from_secs(super::devices::NOTICE_SECS)).await; + }) + .await; + let _ = this.update_in(cx, |this, _window, cx| { + if this.notice_gen == generation { + this.notice = None; + cx.notify(); + } + }); + }) + .detach(); + } + pub fn apply_status(&mut self, status: LibraryStatus) { self.library_open = status.open; self.library_path = status.path; @@ -367,6 +478,9 @@ impl Render for Shell { this.import_paths(files, window, cx); })) .child(self.topbar(window, cx)) + .when_some(self.import_progress.clone(), |this, progress| { + this.child(import_progress_bar(&progress, cx)) + }) .child( div() .flex_1() @@ -470,9 +584,30 @@ impl Shell { } })), ) - .child(self.nav_button("nav-library", IconName::BookOpen, "Library", self.route == Route::Library && self.book_id.is_none(), Route::Library, cx)) - .child(self.nav_button("nav-devices", IconName::HardDrive, "Devices", self.route == Route::Devices, Route::Devices, cx)) - .child(self.nav_button("nav-settings", IconName::Settings, "Settings", self.route == Route::Settings, Route::Settings, cx)) + .child(self.nav_button( + "nav-library", + IconName::BookOpen, + "Library", + self.route == Route::Library && self.book_id.is_none(), + Route::Library, + cx, + )) + .child(self.nav_button( + "nav-devices", + IconName::HardDrive, + "Devices", + self.route == Route::Devices, + Route::Devices, + cx, + )) + .child(self.nav_button( + "nav-settings", + IconName::Settings, + "Settings", + self.route == Route::Settings, + Route::Settings, + cx, + )) .child(self.theme_menu(cx)) .child(div().w(px(4.)).text_color(muted)) } @@ -515,36 +650,39 @@ impl Shell { } else { 0 }; - div().relative().child( - Button::new(id) - .ghost() - .compact() - .icon(icon) - .selected(active) - .tooltip(tip) - .on_click(cx.listener(move |this, _, window, cx| { - this.book_id = None; - this.device_id = None; - this.route = route; - if route == Route::Devices { - this.refresh_devices(window, cx); - } - cx.notify(); - })), - ).when(count > 0, |this| { - this.child( - div() - .absolute() - .top(px(0.)) - .right(px(0.)) - .px(px(4.)) - .rounded(px(99.)) - .text_size(px(10.)) - .bg(cx.theme().primary) - .text_color(cx.theme().primary_foreground) - .child(count.to_string()), + div() + .relative() + .child( + Button::new(id) + .ghost() + .compact() + .icon(icon) + .selected(active) + .tooltip(tip) + .on_click(cx.listener(move |this, _, window, cx| { + this.book_id = None; + this.device_id = None; + this.route = route; + if route == Route::Devices { + this.refresh_devices(window, cx); + } + cx.notify(); + })), ) - }) + .when(count > 0, |this| { + this.child( + div() + .absolute() + .top(px(0.)) + .right(px(0.)) + .px(px(4.)) + .rounded(px(99.)) + .text_size(px(10.)) + .bg(cx.theme().primary) + .text_color(cx.theme().primary_foreground) + .child(count.to_string()), + ) + }) } fn body(&mut self, window: &mut Window, cx: &mut Context) -> gpui_kit::AnyElement { @@ -612,6 +750,55 @@ pub fn muted_page(text: &str, cx: &App) -> impl IntoElement { .child(text.to_string()) } +fn import_progress_bar(progress: &ImportProgress, cx: &App) -> impl IntoElement { + let muted = cx.theme().muted_foreground; + let show_name = progress.total > 0; + div() + .px(px(16.)) + .pt(px(8.)) + .pb(px(10.)) + .flex() + .flex_col() + .gap(px(6.)) + .bg(cx.theme().sidebar) + .border_b_1() + .border_color(cx.theme().border) + .child( + div() + .flex() + .items_center() + .gap(px(12.)) + .text_size(px(13.)) + .child(import_status_label(progress)) + .when(show_name, |this| { + this.child( + div() + .flex_1() + .min_w(px(0.)) + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .text_color(muted) + .child(progress.name.clone()), + ) + }), + ) + .child( + div() + .w_full() + .h(px(4.)) + .rounded(px(99.)) + .overflow_hidden() + .bg(cx.theme().muted) + .child( + div() + .h_full() + .w(relative(import_fraction(progress.done, progress.total))) + .bg(cx.theme().primary), + ), + ) +} + pub fn section_title(text: &str, count: Option) -> impl IntoElement { div() .flex() @@ -646,3 +833,39 @@ fn theme_choice( shell.update(cx, |this, cx| this.set_theme(pref, cx)); }) } + +#[cfg(test)] +mod tests { + use super::{import_fraction, import_status_label, ImportProgress}; + + #[test] + fn import_status_counts_the_book_in_progress() { + assert_eq!( + import_status_label(&ImportProgress { + done: 0, + total: 0, + name: "Scanning for books…".into(), + }), + "Scanning for books…" + ); + assert_eq!( + import_status_label(&ImportProgress { + done: 0, + total: 1, + name: "Tale.epub".into(), + }), + "Importing 1 of 1" + ); + assert_eq!( + import_status_label(&ImportProgress { + done: 2, + total: 10, + name: "Next.epub".into(), + }), + "Importing 3 of 10" + ); + assert_eq!(import_fraction(0, 0), 0.0); + assert_eq!(import_fraction(2, 10), 0.2); + assert_eq!(import_fraction(10, 10), 1.0); + } +} diff --git a/app/src/ui/theme.rs b/app/src/ui/theme.rs index f5a2d41..d92bb01 100644 --- a/app/src/ui/theme.rs +++ b/app/src/ui/theme.rs @@ -1,5 +1,5 @@ use gpui_kit::component::theme::{Theme, ThemeColor, ThemeMode}; -use gpui_kit::{App, Hsla, WindowAppearance, px, rgb}; +use gpui_kit::{px, rgb, App, Hsla, WindowAppearance}; use crate::config::{load_config, set_theme_preference}; @@ -43,12 +43,20 @@ pub fn apply(pref: &str, cx: &mut App) { cx.set_window_appearance(Some(WindowAppearance::Light)); } Theme::change( - if dark { ThemeMode::Dark } else { ThemeMode::Light }, + if dark { + ThemeMode::Dark + } else { + ThemeMode::Light + }, None, cx, ); Theme::update(cx, |theme| { - theme.colors = if dark { dark_palette() } else { light_palette() }; + theme.colors = if dark { + dark_palette() + } else { + light_palette() + }; theme.font_family = UI_FONT.into(); theme.font_size = px(15.); theme.radius = px(10.); diff --git a/static/grimoire_logo-wide-dark.svg b/static/grimoire_logo-wide-dark.svg new file mode 100644 index 0000000..a675dd2 --- /dev/null +++ b/static/grimoire_logo-wide-dark.svg @@ -0,0 +1,1097 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file