diff --git a/AGENTS.md b/AGENTS.md index e2e64e7d..a115afff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,3 +11,7 @@ - Use `docs(plan): ...` for planning-only changes; these are excluded from changelogs and version inference. - Use `!` or a `BREAKING CHANGE:` footer for breaking changes. - Pull request titles are validated because squash merges use the title as the commit message. Keep this list aligned with `.github/workflows/commits.yml` and `cliff.toml`. + +# Design system + +- Follow `docs/typography.md` when adding, replacing, or applying fonts in the application or website. diff --git a/assets/fonts/LICENSE-NotoSansJP.txt b/assets/fonts/LICENSE-NotoSansJP.txt new file mode 100644 index 00000000..1c9f4328 --- /dev/null +++ b/assets/fonts/LICENSE-NotoSansJP.txt @@ -0,0 +1,93 @@ +Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source' + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/LICENSE-NotoSerifJP.txt b/assets/fonts/LICENSE-NotoSerifJP.txt new file mode 100644 index 00000000..94d1bf7b --- /dev/null +++ b/assets/fonts/LICENSE-NotoSerifJP.txt @@ -0,0 +1,94 @@ +Copyright 2012 Google Inc. All Rights Reserved. + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/LICENSE-SourceSerif4.md b/assets/fonts/LICENSE-SourceSerif4.md new file mode 100644 index 00000000..5871e1f3 --- /dev/null +++ b/assets/fonts/LICENSE-SourceSerif4.md @@ -0,0 +1,93 @@ +Copyright 2014 - 2023 Adobe (http://www.adobe.com/), with Reserved Font Name ‘Source’. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/NotoSansJP-Variable.ttf b/assets/fonts/NotoSansJP-Variable.ttf new file mode 100644 index 00000000..cdd8f083 Binary files /dev/null and b/assets/fonts/NotoSansJP-Variable.ttf differ diff --git a/assets/fonts/NotoSerifJP-ShosaiMark-Regular.ttf b/assets/fonts/NotoSerifJP-ShosaiMark-Regular.ttf new file mode 100644 index 00000000..6a49848d Binary files /dev/null and b/assets/fonts/NotoSerifJP-ShosaiMark-Regular.ttf differ diff --git a/assets/fonts/README.md b/assets/fonts/README.md new file mode 100644 index 00000000..74e698b2 --- /dev/null +++ b/assets/fonts/README.md @@ -0,0 +1,12 @@ +# Bundled fonts + +These files implement the typography policy in `docs/typography.md`. Do not replace or regenerate them without updating this inventory and the corresponding license. + +| File | Source | Purpose | SHA-256 | +| --- | --- | --- | --- | +| `InterVariable.ttf` | [Inter upstream](https://github.com/rsms/inter), variable TTF from the existing project assets | Latin interface text | `4989b125924991b90d05b2d16e0e388c48f7d5bb8b30539bbf9c755278d0ccaf` | +| `NotoSansJP-Variable.ttf` | [Google Fonts `NotoSansJP[wght].ttf`](https://github.com/google/fonts/blob/main/ofl/notosansjp/NotoSansJP%5Bwght%5D.ttf) | Japanese interface text, weights 100–900 | `c2f3b4d463500a2ddcd3849cded1fceeb9fd6d1c32e6cbecd568453ba50fc68f` | +| `SourceSerif4Variable-Roman.ttf` | [Source Serif release branch](https://github.com/adobe-fonts/source-serif/blob/release/VAR/SourceSerif4Variable-Roman.ttf) | Latin editorial text, weights 200–900 | `14d360ee1b76655da9276628b229e11671bc1f5d1083636144db6677d452cf55` | +| `NotoSerifJP-ShosaiMark-Regular.ttf` | [Google Fonts CSS text subset](https://fonts.googleapis.com/css2?family=Noto+Serif+JP:wght@400&text=%E6%9B%B8), version 33 | Website wordmark glyph `書` only | `8ecace995946627dd90e74dddaed727e66304dff9f619adc2446de78b469a9a8` | + +All four families use the SIL Open Font License 1.1. Their licenses are stored alongside the binaries. `NotoSerifJP-ShosaiMark-Regular.ttf` is intentionally limited to U+66F8; use the full upstream Noto Serif JP family before adding other Japanese editorial text. diff --git a/assets/fonts/SourceSerif4Variable-Roman.ttf b/assets/fonts/SourceSerif4Variable-Roman.ttf new file mode 100644 index 00000000..83a2dd1f Binary files /dev/null and b/assets/fonts/SourceSerif4Variable-Roman.ttf differ diff --git a/crates/shosai-app/locales/en-US/main.ftl b/crates/shosai-app/locales/en-US/main.ftl index e1741ac6..1af06c37 100644 --- a/crates/shosai-app/locales/en-US/main.ftl +++ b/crates/shosai-app/locales/en-US/main.ftl @@ -1,11 +1,8 @@ -language-system = Language: System -language-english = Language: English -language-japanese = Language: 日本語 +language-system = System default +language-english = English +language-japanese = 日本語 open-file = Open File ebooks = Ebooks -import-library = Import to Library -link-library = Link from Original Location -import-directory = Import Directory failed-storage = Failed to initialize storage: { $error } failed-open = Failed to open { $format }: { $error } missing-book = This book could not be found at its previous location. @@ -63,12 +60,58 @@ rendering = Rendering... rendering-page = Rendering page { $page }... page-number = Page { $page } search-library-placeholder = Search by title or author... -scan-folder = Copy folder -add-book = + Copy to Library -link-book = Keep original +add-books = + Add books +adding-books = Adding books… +add-books-heading = Add books +add-books-description = Choose individual files or a folder. Shosai supports PDF, EPUB, and CBZ files. +choose-book-files = Choose files… +choose-book-folder = Choose a folder… +book-storage-heading = How should Shosai store these books? +selected-book-files = { $count } files selected +selected-book-folder = Folder selected: { $folder } +copy-into-shosai = Copy into Shosai — Recommended +copy-into-shosai-description = Keeps a private copy that remains available if the originals move or are deleted. Uses additional disk space. +use-current-location = Use files in their current location +use-current-location-description = Avoids duplicate storage, but books become unavailable if the files move or their drive is disconnected. +ready-to-add-books = Ready to add books +change-storage-choice = Change storage choice +add-selected-books = Add books +back = Back +books-import-partial = Books added or already present: { $added }. Files not imported: { $failed }. { $file }: { $error } +books-import-failed = No books were added. Files not imported: { $failed }. { $file }: { $error } library = Library library-subtitle = Your private reading room collection = COLLECTION +settings = Settings +settings-subtitle = Preferences for your reading room +general = General +language = Language +language-description = Choose the language used throughout Shosai. +managed-books-location = Managed book copies +managed-books-location-description = Copied books are stored here. Choosing a new parent creates a Shosai folder and moves existing copies. +open-folder = Open folder +change-location = Change location… +choose-managed-library-parent = Choose where to create the Shosai folder +when-adding-books = When adding books +when-adding-books-description = Choose whether Shosai asks about storage during every add. +add-behavior-ask = Ask where to store them +add-behavior-copy = Always copy into Shosai +add-behavior-current-location = Always use files in their current location +reading-defaults = Reading defaults +reading-defaults-description = Used when opening a new book. Existing open tabs keep their current settings. +default-reading-mode = Reading mode +default-theme = Theme +default-epub-font-size = Font size +default-epub-line-spacing = Line spacing +default-pdf-zoom = Opening zoom +unavailable = Unavailable +move-managed-library-heading = Move managed book copies? +move-managed-library-description = Shosai will verify every new copy before updating the library. Old copies are removed only after the move succeeds. +from-location = From +to-location = To +managed-library-move-summary = { $count } books · { $size } +move-library = Move library +moving-library = Moving library… all-books = All books all = All loading-library = Loading library... @@ -77,8 +120,7 @@ empty-search = No books match your search or filter. loading-library-heading = Loading your library no-matching-books = No matching books empty-library-heading = A quiet place for every book -add-first-book = + Copy your first book -scan-folder-long = Copy a folder +add-first-books = + Add your first books continue-reading = Continue reading search-results = Search results loading-more = Loading more… diff --git a/crates/shosai-app/locales/ja/main.ftl b/crates/shosai-app/locales/ja/main.ftl index 5708d0fe..5bec3b64 100644 --- a/crates/shosai-app/locales/ja/main.ftl +++ b/crates/shosai-app/locales/ja/main.ftl @@ -1,11 +1,8 @@ -language-system = 言語:システム -language-english = 言語:English -language-japanese = 言語:日本語 +language-system = システム設定 +language-english = English +language-japanese = 日本語 open-file = ファイルを開く ebooks = 電子書籍 -import-library = ライブラリに追加 -link-library = 元の場所にあるファイルをリンク -import-directory = フォルダーを追加 failed-storage = ストレージを初期化できませんでした:{ $error } failed-open = { $format }を開けませんでした:{ $error } missing-book = この本は以前の場所に見つかりませんでした。 @@ -63,12 +60,58 @@ rendering = 表示中... rendering-page = { $page }ページを表示中... page-number = { $page }ページ search-library-placeholder = タイトル・著者を検索... -scan-folder = フォルダーをコピー -add-book = + ライブラリにコピー -link-book = 元の場所に保持 +add-books = + 本を追加 +adding-books = 本を追加中… +add-books-heading = 本を追加 +add-books-description = ファイルまたはフォルダーを選んでください。PDF、EPUB、CBZファイルに対応しています。 +choose-book-files = ファイルを選択… +choose-book-folder = フォルダーを選択… +book-storage-heading = 本をどこに保存しますか? +selected-book-files = { $count }個のファイルを選択中 +selected-book-folder = 選択中のフォルダー:{ $folder } +copy-into-shosai = 書斎にコピー — おすすめ +copy-into-shosai-description = 元のファイルを移動または削除しても読めるよう、専用のコピーを保存します。追加のディスク容量を使用します。 +use-current-location = 現在の場所にあるファイルを使用 +use-current-location-description = ファイルを複製しませんが、移動した場合やドライブが接続されていない場合は読めなくなります。 +ready-to-add-books = 本を追加する準備ができました +change-storage-choice = 保存方法を変更 +add-selected-books = 本を追加 +back = 戻る +books-import-partial = { $added }冊を追加、または追加済みとして確認しました。{ $failed }個のファイルを読み込めませんでした。{ $file }: { $error } +books-import-failed = 本を追加できませんでした。{ $failed }個のファイルを読み込めませんでした。{ $file }: { $error } library = ライブラリ library-subtitle = 自分だけの読書室 collection = コレクション +settings = 設定 +settings-subtitle = 読書室の環境設定 +general = 一般 +language = 言語 +language-description = 書斎全体で使用する言語を選択します。 +managed-books-location = 管理している本のコピー +managed-books-location-description = コピーした本をここに保存します。新しい親フォルダーを選ぶと、その中にShosaiフォルダーを作成して既存のコピーを移動します。 +open-folder = フォルダーを開く +change-location = 保存場所を変更… +choose-managed-library-parent = Shosaiフォルダーを作成する場所を選択 +when-adding-books = 本を追加するとき +when-adding-books-description = 本を追加するたびに保存方法を確認するか選択します。 +add-behavior-ask = 保存場所を毎回確認 +add-behavior-copy = 常に書斎にコピー +add-behavior-current-location = 常に現在の場所にあるファイルを使用 +reading-defaults = 読書のデフォルト +reading-defaults-description = 新しい本を開くときに使用します。すでに開いているタブの設定は変わりません。 +default-reading-mode = 読書モード +default-theme = テーマ +default-epub-font-size = 文字サイズ +default-epub-line-spacing = 行間 +default-pdf-zoom = 開くときの表示 +unavailable = 利用できません +move-managed-library-heading = 管理している本のコピーを移動しますか? +move-managed-library-description = 新しいコピーをすべて検証してからライブラリを更新します。移動が完了するまで古いコピーは削除しません。 +from-location = 移動元 +to-location = 移動先 +managed-library-move-summary = { $count }冊 · { $size } +move-library = ライブラリを移動 +moving-library = ライブラリを移動中… all-books = すべての本 all = すべて loading-library = ライブラリを読み込み中... @@ -77,8 +120,7 @@ empty-search = 検索条件に一致する本はありません。 loading-library-heading = ライブラリを読み込み中 no-matching-books = 一致する本はありません empty-library-heading = すべての本に静かな居場所を -add-first-book = + 最初の本をコピー -scan-folder-long = フォルダーをコピー +add-first-books = + 最初の本を追加 continue-reading = 読書を続ける search-results = 検索結果 loading-more = さらに読み込み中… diff --git a/crates/shosai-app/src/app.rs b/crates/shosai-app/src/app.rs index dde9d1da..dd096380 100644 --- a/crates/shosai-app/src/app.rs +++ b/crates/shosai-app/src/app.rs @@ -5,8 +5,8 @@ use std::sync::Arc; use iced::advanced::widget::{Id as WidgetId, operation}; use iced::keyboard; use iced::widget::{ - button, center, column, container, grid, image, mouse_area, opaque, responsive, row, - scrollable, sensor, text, text_input, + button, center, column, container, grid, image, mouse_area, opaque, pick_list, responsive, row, + scrollable, sensor, text_input, }; use iced::{Element, Length, Point, Size, Subscription, Task, window}; use tokio::sync::{mpsc, oneshot}; @@ -15,7 +15,9 @@ use shosai_core::bookmarks::{Bookmark, BookmarkStore}; use shosai_core::cbz::CbzDoc; use shosai_core::document::{Document, RenderedPage}; use shosai_core::epub::EpubDoc; -use shosai_core::library::{Book, BookPage, Library}; +use shosai_core::library::{ + Book, BookPage, ImportReport, Library, ManagedPathChange, ManagedStorageSummary, +}; use shosai_core::pdf::PdfDoc; use shosai_core::reading_state::{FileReadingState, ReadingStateStore}; use shosai_core::search::SearchMatch; @@ -29,7 +31,7 @@ use crate::epub::{ use crate::i18n::{I18n, LanguagePreference}; use crate::pdf::ZoomMode; use crate::theme::ReaderTheme; -use crate::{theme as app_theme, widgets}; +use crate::{theme as app_theme, typography, widgets}; mod dispatch; mod epub_navigation; @@ -42,7 +44,39 @@ use epub_navigation::*; use epub_view::{cache_epub_image_handles, continuous_epub_content_view, epub_chapter_view}; pub use message::Message; +fn text<'a>(value: impl iced::widget::text::IntoFragment<'a>) -> iced::widget::Text<'a> { + let fragment = value.into_fragment(); + let font = typography::font_for_text(fragment.as_ref()); + iced::widget::text(fragment).font(font) +} + +fn editable_text_font(value: &str, placeholder: &str) -> iced::Font { + typography::font_for_text(if value.is_empty() { placeholder } else { value }) +} + +fn language_menu_font() -> iced::Font { + typography::NOTO_SANS_JP +} + const LANGUAGE_PREFERENCE_KEY: &str = "language"; +const ADD_BOOK_BEHAVIOR_KEY: &str = "library.add_behavior"; +const DEFAULT_READING_MODE_KEY: &str = "reader.default_mode"; +const DEFAULT_READER_THEME_KEY: &str = "reader.default_theme"; +const DEFAULT_EPUB_FONT_SIZE_KEY: &str = "reader.default_epub_font_size"; +const DEFAULT_EPUB_LINE_SPACING_KEY: &str = "reader.default_epub_line_spacing"; +const DEFAULT_PDF_ZOOM_KEY: &str = "reader.default_pdf_zoom"; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SelectOption { + value: T, + label: String, +} + +impl std::fmt::Display for SelectOption { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.label) + } +} // --------------------------------------------------------------------------- // Open document wrapper @@ -67,6 +101,7 @@ enum AppError { EpubEmpty, MissingBook, Library(String), + Import(String), } impl AppError { @@ -94,6 +129,7 @@ impl AppError { Self::Library(detail) => { i18n.text_with_args("library-error", [("error", detail.clone().into())]) } + Self::Import(detail) => detail.clone(), } } @@ -108,10 +144,34 @@ impl AppError { Self::EpubEmpty => "EPUB contains no readable content".to_string(), Self::MissingBook => "book file is missing".to_string(), Self::Library(detail) => format!("library operation failed: {detail}"), + Self::Import(detail) => format!("book import incomplete: {detail}"), } } } +fn import_report_error(report: &ImportReport, i18n: &I18n) -> Option { + let first = report.failures.first()?; + let file = first + .path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| first.path.display().to_string()); + let key = if report.books.is_empty() { + "books-import-failed" + } else { + "books-import-partial" + }; + Some(AppError::Import(i18n.text_with_args( + key, + [ + ("added", (report.books.len() as i64).into()), + ("failed", (report.failures.len() as i64).into()), + ("file", file.into()), + ("error", first.error.clone().into()), + ], + ))) +} + #[derive(Clone)] struct RasterImageHandle(image::Handle); @@ -144,6 +204,66 @@ impl std::fmt::Debug for EpubImageHandle { enum Screen { Library, Reader, + Settings, +} + +#[derive(Debug, Clone)] +enum AddBooksSource { + Files(Vec), + Folder(PathBuf), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum AddBookBehavior { + #[default] + Ask, + Copy, + CurrentLocation, +} + +impl AddBookBehavior { + fn from_stored(value: Option<&str>) -> Self { + match value { + Some("copy") => Self::Copy, + Some("current-location") => Self::CurrentLocation, + _ => Self::Ask, + } + } + + fn stored(self) -> &'static str { + match self { + Self::Ask => "ask", + Self::Copy => "copy", + Self::CurrentLocation => "current-location", + } + } +} + +#[derive(Debug, Clone, Copy)] +struct ReaderDefaults { + reading_mode: ReadingMode, + theme: ReaderTheme, + epub_font_size: f32, + epub_line_spacing: f32, + pdf_zoom: ZoomMode, +} + +impl Default for ReaderDefaults { + fn default() -> Self { + Self { + reading_mode: ReadingMode::Paginated, + theme: ReaderTheme::Light, + epub_font_size: 16.0, + epub_line_spacing: 1.6, + pdf_zoom: ZoomMode::FitPage, + } + } +} + +#[derive(Debug, Clone)] +struct LibraryMovePlan { + destination: PathBuf, + summary: ManagedStorageSummary, } const LIBRARY_PAGE_SIZE: u32 = 40; @@ -338,7 +458,7 @@ impl operation::Operation<(usize, usize, f32, f32)> for ContinuousItemOperation } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -enum ReadingMode { +pub(crate) enum ReadingMode { #[default] Paginated, Continuous, @@ -363,6 +483,22 @@ impl EpubLayoutKey { } } +impl ReadingMode { + fn from_stored(value: Option<&str>) -> Self { + match value { + Some("continuous") => Self::Continuous, + _ => Self::Paginated, + } + } + + fn stored(self) -> &'static str { + match self { + Self::Paginated => "paginated", + Self::Continuous => "continuous", + } + } +} + #[derive(Debug, Clone)] struct ReaderTab { id: u64, @@ -419,6 +555,9 @@ pub struct InitializedState { store: ReadingStateStore, window_geometry: Option<(Size, Point)>, language_preference: LanguagePreference, + managed_books_dir: PathBuf, + add_book_behavior: AddBookBehavior, + reader_defaults: ReaderDefaults, } #[derive(Debug)] @@ -432,6 +571,7 @@ struct ReadingStateSave { enum ReadingStateWriterMessage { Save(ReadingStateSave), Language(LanguagePreference), + Preference(&'static str, String), Flush(oneshot::Sender<()>), } @@ -527,7 +667,18 @@ pub struct State { book_menu: Option, pending_remove_book: Option, removing_book: Option, + add_books_open: bool, + add_books_source: Option, + add_books_override: bool, + adding_books: bool, library_error: Option, + + // -- Settings state -- + add_book_behavior: AddBookBehavior, + reader_defaults: ReaderDefaults, + pending_library_move: Option, + moving_library: bool, + settings_error: Option, storage_initializing: bool, storage_error: Option, pending_open: Option, @@ -626,7 +777,16 @@ pub fn boot() -> (State, Task) { book_menu: None, pending_remove_book: None, removing_book: None, + add_books_open: false, + add_books_source: None, + add_books_override: false, + adding_books: false, library_error: None, + add_book_behavior: AddBookBehavior::default(), + reader_defaults: ReaderDefaults::default(), + pending_library_move: None, + moving_library: false, + settings_error: None, storage_initializing: true, storage_error: None, pending_open: performance_file, @@ -668,6 +828,42 @@ pub fn boot() -> (State, Task) { .await .as_deref(), ); + let managed_books_dir = store + .get_pref_async(shosai_core::library::MANAGED_LIBRARY_DIR_PREFERENCE) + .await + .map(PathBuf::from) + .unwrap_or_else(|| store.managed_books_dir()); + let add_book_behavior = AddBookBehavior::from_stored( + store.get_pref_async(ADD_BOOK_BEHAVIOR_KEY).await.as_deref(), + ); + let reader_defaults = ReaderDefaults { + reading_mode: ReadingMode::from_stored( + store + .get_pref_async(DEFAULT_READING_MODE_KEY) + .await + .as_deref(), + ), + theme: ReaderTheme::from_stored( + store + .get_pref_async(DEFAULT_READER_THEME_KEY) + .await + .as_deref(), + ), + epub_font_size: stored_f32( + store.get_pref_async(DEFAULT_EPUB_FONT_SIZE_KEY).await, + 16.0, + 8.0..=48.0, + ), + epub_line_spacing: stored_f32( + store.get_pref_async(DEFAULT_EPUB_LINE_SPACING_KEY).await, + 1.6, + 1.0..=2.4, + ), + pdf_zoom: match store.get_pref_async(DEFAULT_PDF_ZOOM_KEY).await.as_deref() { + Some("fit-width") => ZoomMode::FitWidth, + _ => ZoomMode::FitPage, + }, + }; eprintln!( "startup: database and preferences initialized in {} ms", started.elapsed().as_millis() @@ -676,6 +872,9 @@ pub fn boot() -> (State, Task) { store, window_geometry: geometry, language_preference, + managed_books_dir, + add_book_behavior, + reader_defaults, }) }, Message::Initialized, @@ -683,6 +882,13 @@ pub fn boot() -> (State, Task) { (state, initialize) } +fn stored_f32(value: Option, fallback: f32, range: std::ops::RangeInclusive) -> f32 { + value + .and_then(|value| value.parse::().ok()) + .filter(|value| range.contains(value)) + .unwrap_or(fallback) +} + // --------------------------------------------------------------------------- // Update // --------------------------------------------------------------------------- @@ -759,7 +965,8 @@ fn library_load_sensor_key(state: &State) -> Option<(u64, usize)> { } fn library_activity_active(state: &State) -> bool { - state.screen == Screen::Library && state.library_loading && state.library_offset == 0 + state.screen == Screen::Library + && (state.adding_books || (state.library_loading && state.library_offset == 0)) } fn capture_reader_tab(state: &State) -> Option { @@ -863,6 +1070,32 @@ fn save_active_tab(state: &mut State) { } } +fn apply_managed_path_changes(state: &mut State, changes: &[ManagedPathChange]) { + for change in changes { + if state.book_id == Some(change.book_id) { + state.file_path = Some(change.new_path.clone()); + for bookmark in &mut state.bookmarks { + bookmark.file_path = change.new_path.to_string_lossy().into_owned(); + } + } + for tab in &mut state.tabs { + if tab.book_id == Some(change.book_id) { + tab.file_path = change.new_path.clone(); + for bookmark in &mut tab.bookmarks { + bookmark.file_path = change.new_path.to_string_lossy().into_owned(); + } + } + } + if let Some(book) = state + .library_books + .iter_mut() + .find(|book| book.id == change.book_id) + { + book.file_path = change.new_path.to_string_lossy().into_owned(); + } + } +} + fn select_tab(state: &mut State, index: usize) -> Task { if index >= state.tabs.len() { return Task::none(); @@ -1004,6 +1237,7 @@ fn open_document(state: &mut State, path: PathBuf, book_id: Option) -> Task state.open_error = None; state.missing_book_id = None; install_document(state, path, book_id, document); + apply_reader_defaults(state); let task = refresh_content(state); if let Some(tab) = capture_reader_tab(state) { state.tabs.push(tab); @@ -1013,6 +1247,16 @@ fn open_document(state: &mut State, path: PathBuf, book_id: Option) -> Task task } +fn apply_reader_defaults(state: &mut State) { + state.reading_mode = state.reader_defaults.reading_mode; + state.theme = state.reader_defaults.theme; + state.font_size = state.reader_defaults.epub_font_size; + state.line_spacing = state.reader_defaults.epub_line_spacing; + if matches!(state.document, Some(OpenDocument::Pdf(_))) { + state.zoom = state.reader_defaults.pdf_zoom; + } +} + fn load_document(path: &PathBuf) -> Result { let ext = path .extension() @@ -1109,6 +1353,9 @@ fn install_document( } fn handle_key_event(state: &State, event: keyboard::Event) -> Task { + if state.moving_library { + return Task::none(); + } if let keyboard::Event::KeyPressed { key, modifiers, .. } = event { if let keyboard::Key::Character(c) = key.as_ref() { if c == "o" && modifiers.command() { @@ -2151,6 +2398,16 @@ fn save_reading_state(state: &State) { } } +fn save_preference(state: &State, key: &'static str, value: impl Into) { + if let Some(saves) = &state.reading_state_saves + && saves + .send(ReadingStateWriterMessage::Preference(key, value.into())) + .is_err() + { + eprintln!("warning: state writer stopped unexpectedly"); + } +} + fn start_reading_state_writer( store: ReadingStateStore, ) -> mpsc::UnboundedSender { @@ -2159,12 +2416,16 @@ fn start_reading_state_writer( while let Some(first) = receiver.recv().await { let mut pending = HashMap::new(); let mut language = None; + let mut preferences = HashMap::new(); let mut flushes = Vec::new(); match first { ReadingStateWriterMessage::Save(save) => { pending.insert((save.book_id, save.path), save.reading); } ReadingStateWriterMessage::Language(preference) => language = Some(preference), + ReadingStateWriterMessage::Preference(key, value) => { + preferences.insert(key, value); + } ReadingStateWriterMessage::Flush(flush) => flushes.push(flush), } while let Ok(message) = receiver.try_recv() { @@ -2175,6 +2436,9 @@ fn start_reading_state_writer( ReadingStateWriterMessage::Language(preference) => { language = Some(preference); } + ReadingStateWriterMessage::Preference(key, value) => { + preferences.insert(key, value); + } ReadingStateWriterMessage::Flush(flush) => flushes.push(flush), } } @@ -2196,6 +2460,11 @@ fn start_reading_state_writer( { eprintln!("warning: failed to save language preference: {error}"); } + for (key, value) in preferences { + if let Err(error) = store.set_pref_async(key, &value).await { + eprintln!("warning: failed to save preference {key}: {error}"); + } + } for flush in flushes { let _ = flush.send(()); } @@ -2369,6 +2638,7 @@ pub fn view(state: &State) -> Element<'_, Message> { match state.screen { Screen::Library => library_view(state), Screen::Reader => reader_view(state), + Screen::Settings => settings_view(state), } } @@ -2436,6 +2706,7 @@ fn reader_layout(state: &State, compact: bool) -> Element<'_, Message> { alert = alert.push(widgets::primary_button( state.i18n.text("locate-file"), (!removing).then_some(Message::LocateBook(book_id)), + state.i18n.ui_font(), )); alert = alert.push(widgets::secondary_button( state.i18n.text(if removing { @@ -2444,6 +2715,7 @@ fn reader_layout(state: &State, compact: bool) -> Element<'_, Message> { "remove-from-library" }), (!removing).then_some(Message::RemoveBook(book_id)), + state.i18n.ui_font(), )); } layout = layout.push( @@ -2670,7 +2942,11 @@ fn reader_header(state: &State, compact: bool) -> Element<'_, Message> { container( row![ - widgets::secondary_button(state.i18n.text("back-library"), Some(Message::ShowLibrary),), + widgets::secondary_button( + state.i18n.text("back-library"), + Some(Message::ShowLibrary), + state.i18n.ui_font(), + ), container(text(title).size(if compact { 15 } else { 17 })) .width(Length::Fill) .center_x(Length::Fill), @@ -2810,6 +3086,7 @@ fn reader_more_panel(state: &State, compact: bool) -> Element<'_, Message> { }; let location = row![ text_input(&state.i18n.text("page"), &state.page_input) + .font(state.i18n.ui_font()) .on_input(Message::PageInputChanged) .on_submit(Message::GoToPage) .padding([7, 8]) @@ -2836,14 +3113,6 @@ fn reader_more_panel(state: &State, compact: bool) -> Element<'_, Message> { state.current_page_bookmarked, ), reader_control_button(state.i18n.text("open-book"), Some(Message::OpenFile), false), - reader_control_button( - state.i18n.selector_label(), - state - .reading_state_saves - .as_ref() - .map(|_| Message::CycleLanguage), - false, - ), ] .spacing(5) .align_y(iced::Alignment::Center); @@ -2997,6 +3266,10 @@ fn bookmarks_panel(state: &State, width: Length) -> Element<'_, Message> { &state.i18n.text("add-note-placeholder"), &state.editing_note_text, ) + .font(editable_text_font( + &state.editing_note_text, + &state.i18n.text("add-note-placeholder"), + )) .on_input(Message::EditNoteChanged) .on_submit(Message::SaveNote) .size(12) @@ -3050,6 +3323,7 @@ fn bookmarks_panel(state: &State, width: Length) -> Element<'_, Message> { panel = panel.push(widgets::secondary_button( state.i18n.text("export-markdown"), Some(Message::ExportBookmarks), + state.i18n.ui_font(), )); } @@ -3061,15 +3335,14 @@ fn bookmarks_panel(state: &State, width: Length) -> Element<'_, Message> { } fn search_bar(state: &State, compact: bool) -> Element<'_, Message> { - let input = text_input( - &state.i18n.text("search-document-placeholder"), - &state.search_query, - ) - .id(search_input_id()) - .on_input(Message::SearchQueryChanged) - .on_submit(Message::SearchNext) - .padding([8, 10]) - .width(Length::Fill); + let placeholder = state.i18n.text("search-document-placeholder"); + let input = text_input(&placeholder, &state.search_query) + .font(editable_text_font(&state.search_query, &placeholder)) + .id(search_input_id()) + .on_input(Message::SearchQueryChanged) + .on_submit(Message::SearchNext) + .padding([8, 10]) + .width(Length::Fill); let result_info = if state.search_results.is_empty() { if state.search_query.is_empty() { @@ -3372,69 +3645,50 @@ fn library_layout(state: &State, available_width: f32) -> Element<'_, Message> { content }; - let Some(book) = state + if let Some(book) = state .pending_remove_book .and_then(|book_id| state.library_books.iter().find(|book| book.id == book_id)) - else { - return content; - }; - - let backdrop = mouse_area( - container(iced::widget::Space::new()) - .width(Length::Fill) - .height(Length::Fill) - .style(app_theme::modal_backdrop), - ) - .on_press(Message::CancelRemoveBook); - - iced::widget::Stack::new() - .push(content) - .push(backdrop) - .push(center(opaque(remove_book_modal(state, book))).padding(20)) - .width(Length::Fill) - .height(Length::Fill) - .into() + { + return modal_overlay( + content, + remove_book_modal(state, book), + Message::CancelRemoveBook, + ); + } + if state.add_books_open { + return modal_overlay(content, add_books_modal(state), Message::CancelAddBooks); + } + content } fn library_header(state: &State, compact: bool) -> Element<'_, Message> { - let search_input = text_input( - &state.i18n.text("search-library-placeholder"), - &state.library_search, - ) - .id(library_search_input_id()) - .on_input(Message::LibrarySearchChanged) - .padding([10, 12]) - .width(Length::Fill); + let placeholder = state.i18n.text("search-library-placeholder"); + let search_input = text_input(&placeholder, &state.library_search) + .font(editable_text_font(&state.library_search, &placeholder)) + .id(library_search_input_id()) + .on_input(Message::LibrarySearchChanged) + .padding([10, 12]) + .width(Length::Fill); let search = container(search_input).width(Length::Fill).max_width(380); - let import_message = state.library.is_some().then_some(Message::ImportFile); - let link_message = state.library.is_some().then_some(Message::LinkFile); - let folder_message = state.library.is_some().then_some(Message::ImportDirectory); - let language_message = state - .reading_state_saves - .as_ref() - .map(|_| Message::CycleLanguage); + let add_message = (state.library.is_some() && !state.adding_books && !state.moving_library) + .then_some(Message::OpenAddBooks); + let add_label = if state.adding_books { + state.i18n.text("adding-books") + } else { + state.i18n.text("add-books") + }; let content: Element<'_, Message> = if compact { column![ - row![ - column![ - text(state.i18n.text("library")).size(26), - text(state.i18n.text("library-subtitle")) - .size(12) - .color(app_theme::TEXT_MUTED), - ] - .spacing(2), - iced::widget::Space::new().width(Length::Fill), - widgets::secondary_button(state.i18n.selector_label(), language_message), + column![ + text(state.i18n.text("library")).size(26), + text(state.i18n.text("library-subtitle")) + .size(12) + .color(app_theme::TEXT_MUTED), ] - .align_y(iced::Alignment::Center), + .spacing(2), search, - row![ - widgets::primary_button(state.i18n.text("add-book"), import_message), - widgets::secondary_button(state.i18n.text("link-book"), link_message), - widgets::secondary_button(state.i18n.text("scan-folder"), folder_message), - ] - .spacing(8), + widgets::primary_button(add_label, add_message, state.i18n.ui_font()), ] .spacing(12) .into() @@ -3449,14 +3703,7 @@ fn library_header(state: &State, compact: bool) -> Element<'_, Message> { .spacing(2), iced::widget::Space::new().width(Length::Fill), search, - row![ - widgets::secondary_button(state.i18n.selector_label(), language_message), - widgets::secondary_button(state.i18n.text("scan-folder"), folder_message), - widgets::secondary_button(state.i18n.text("link-book"), link_message), - widgets::primary_button(state.i18n.text("add-book"), import_message), - ] - .spacing(8) - .align_y(iced::Alignment::Center), + widgets::primary_button(add_label, add_message, state.i18n.ui_font()), ] .spacing(16) .align_y(iced::Alignment::Center) @@ -3488,29 +3735,438 @@ fn library_header(state: &State, compact: bool) -> Element<'_, Message> { .into() } +fn language_selector(state: &State) -> Element<'static, Message> { + let options = [ + SelectOption { + value: LanguagePreference::System, + label: state.i18n.text("language-system"), + }, + SelectOption { + value: LanguagePreference::English, + label: state.i18n.text("language-english"), + }, + SelectOption { + value: LanguagePreference::Japanese, + label: state.i18n.text("language-japanese"), + }, + ]; + let selected = options + .iter() + .find(|option| option.value == state.i18n.preference()) + .cloned(); + + pick_list(options, selected, |option| { + Message::SelectLanguage(option.value) + }) + .font(language_menu_font()) + .text_size(14) + .padding([9, 12]) + .into() +} + +fn settings_view(state: &State) -> Element<'_, Message> { + container(responsive(move |size| settings_layout(state, size.width))) + .width(Length::Fill) + .height(Length::Fill) + .style(app_theme::app_background) + .into() +} + +fn settings_layout(state: &State, available_width: f32) -> Element<'_, Message> { + let compact = available_width < 760.0; + let heading = page_header( + state.i18n.text("settings"), + state.i18n.text("settings-subtitle"), + ); + let settings = settings_content(state, compact); + + let content: Element<'_, Message> = if compact { + column![heading, mobile_library_filters(state), settings] + .width(Length::Fill) + .height(Length::Fill) + .into() + } else { + column![ + heading, + row![library_sidebar(state), settings] + .width(Length::Fill) + .height(Length::Fill), + ] + .width(Length::Fill) + .height(Length::Fill) + .into() + }; + if state.pending_library_move.is_some() { + modal_overlay( + content, + managed_library_move_modal(state), + Message::CancelManagedLibraryMove, + ) + } else { + content + } +} + +fn page_header(title: String, subtitle: String) -> Element<'static, Message> { + container( + column![ + text(title).size(26), + text(subtitle).size(12).color(app_theme::TEXT_MUTED), + ] + .spacing(2), + ) + .padding([16, 20]) + .width(Length::Fill) + .style(|_theme| container::Style { + background: Some(iced::Background::Color(app_theme::SURFACE)), + shadow: iced::Shadow { + color: iced::Color::from_rgba8(0x21, 0x20, 0x1E, 0.08), + offset: iced::Vector::new(0.0, 1.0), + blur_radius: 6.0, + }, + ..container::Style::default() + }) + .into() +} + +fn settings_content(state: &State, compact: bool) -> Element<'_, Message> { + let general = container( + column![ + text(state.i18n.text("language")).size(16), + text(state.i18n.text("language-description")) + .size(13) + .color(app_theme::TEXT_MUTED), + language_selector(state), + ] + .spacing(10), + ) + .padding(20) + .width(Length::Fill) + .style(app_theme::modal); + + let managed_path = state + .library + .as_ref() + .map(|library| library.managed_dir().display().to_string()) + .unwrap_or_else(|| state.i18n.text("unavailable")); + let library_available = state.library.is_some() + && !state.moving_library + && !state.adding_books + && state.removing_book.is_none(); + let library_settings = container( + column![ + text(state.i18n.text("managed-books-location")).size(16), + text(state.i18n.text("managed-books-location-description")) + .size(13) + .color(app_theme::TEXT_MUTED), + container(text(managed_path).size(12)) + .padding([9, 12]) + .width(Length::Fill) + .style(app_theme::skeleton), + row![ + widgets::secondary_button( + state.i18n.text("open-folder"), + library_available.then_some(Message::OpenManagedLibraryFolder), + state.i18n.ui_font(), + ), + widgets::secondary_button( + state.i18n.text("change-location"), + library_available.then_some(Message::ChooseManagedLibraryParent), + state.i18n.ui_font(), + ), + ] + .spacing(8), + iced::widget::Space::new().height(8), + text(state.i18n.text("when-adding-books")).size(16), + text(state.i18n.text("when-adding-books-description")) + .size(13) + .color(app_theme::TEXT_MUTED), + add_book_behavior_selector(state), + ] + .spacing(10), + ) + .padding(20) + .width(Length::Fill) + .style(app_theme::modal); + + let reading_mode = row![ + reader_control_button( + state.i18n.text("paginated"), + Some(Message::SelectDefaultReadingMode(ReadingMode::Paginated)), + state.reader_defaults.reading_mode == ReadingMode::Paginated, + ), + reader_control_button( + state.i18n.text("continuous"), + Some(Message::SelectDefaultReadingMode(ReadingMode::Continuous)), + state.reader_defaults.reading_mode == ReadingMode::Continuous, + ), + ] + .spacing(5); + let font_size = row![ + widgets::secondary_button( + "−", + Some(Message::DefaultEpubFontSizeDown), + state.i18n.ui_font(), + ), + container(text(format!( + "{} px", + state.reader_defaults.epub_font_size as u32 + ))) + .padding([9, 12]), + widgets::secondary_button( + "+", + Some(Message::DefaultEpubFontSizeUp), + state.i18n.ui_font(), + ), + ] + .spacing(5) + .align_y(iced::Alignment::Center); + let reading_settings = container( + column![ + setting_control( + state.i18n.text("default-reading-mode"), + reading_mode.into(), + compact, + ), + setting_control( + state.i18n.text("default-theme"), + reader_theme_selector(state), + compact, + ), + settings_format_divider("EPUB"), + setting_control( + state.i18n.text("default-epub-font-size"), + font_size.into(), + compact, + ), + setting_control( + state.i18n.text("default-epub-line-spacing"), + line_spacing_selector(state), + compact, + ), + settings_format_divider("PDF"), + setting_control( + state.i18n.text("default-pdf-zoom"), + pdf_zoom_selector(state), + compact, + ), + ] + .spacing(14), + ) + .padding(20) + .width(Length::Fill) + .style(app_theme::modal); + + let mut content = column![ + text(state.i18n.text("general")).size(18), + general, + text(state.i18n.text("library")).size(18), + library_settings, + text(state.i18n.text("reading-defaults")).size(18), + text(state.i18n.text("reading-defaults-description")) + .size(13) + .color(app_theme::TEXT_MUTED), + reading_settings, + ] + .spacing(14); + if let Some(error) = &state.settings_error { + content = content.push( + container(text(error.clone()).size(12)) + .padding([8, 12]) + .width(Length::Fill) + .style(app_theme::reader_alert), + ); + } + + scrollable( + container(content) + .padding([24, 28]) + .width(Length::Fill) + .max_width(760), + ) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +fn settings_format_divider(label: &'static str) -> Element<'static, Message> { + row![ + text(label).size(12).color(app_theme::TEXT_MUTED), + container(iced::widget::Space::new()) + .height(1) + .width(Length::Fill) + .style(|_theme| container::Style { + background: Some(iced::Background::Color(app_theme::BORDER)), + ..container::Style::default() + }), + ] + .spacing(10) + .align_y(iced::Alignment::Center) + .into() +} + +fn setting_control( + label: String, + control: Element<'static, Message>, + compact: bool, +) -> Element<'static, Message> { + if compact { + column![text(label).size(14), control].spacing(8).into() + } else { + row![ + text(label).size(14), + iced::widget::Space::new().width(Length::Fill), + control, + ] + .align_y(iced::Alignment::Center) + .into() + } +} + +fn add_book_behavior_selector(state: &State) -> Element<'static, Message> { + let options = [ + SelectOption { + value: AddBookBehavior::Ask, + label: state.i18n.text("add-behavior-ask"), + }, + SelectOption { + value: AddBookBehavior::Copy, + label: state.i18n.text("add-behavior-copy"), + }, + SelectOption { + value: AddBookBehavior::CurrentLocation, + label: state.i18n.text("add-behavior-current-location"), + }, + ]; + let selected = options + .iter() + .find(|option| option.value == state.add_book_behavior) + .cloned(); + pick_list(options, selected, |option| { + Message::SelectAddBookBehavior(option.value) + }) + .font(state.i18n.ui_font()) + .text_size(14) + .padding([9, 12]) + .into() +} + +fn reader_theme_selector(state: &State) -> Element<'static, Message> { + let options = [ + SelectOption { + value: ReaderTheme::Light, + label: state.i18n.text("light"), + }, + SelectOption { + value: ReaderTheme::Dark, + label: state.i18n.text("dark"), + }, + SelectOption { + value: ReaderTheme::Sepia, + label: state.i18n.text("sepia"), + }, + ]; + let selected = options + .iter() + .find(|option| option.value == state.reader_defaults.theme) + .cloned(); + pick_list(options, selected, |option| { + Message::SelectDefaultReaderTheme(option.value) + }) + .font(state.i18n.ui_font()) + .text_size(14) + .padding([9, 12]) + .into() +} + +fn line_spacing_selector(state: &State) -> Element<'static, Message> { + let options = [1.2_f32, 1.4, 1.6, 1.8, 2.0].map(|value| SelectOption { + value, + label: format!("{value:.1}"), + }); + let selected = options + .iter() + .find(|option| option.value == state.reader_defaults.epub_line_spacing) + .cloned() + .or_else(|| { + Some(SelectOption { + value: state.reader_defaults.epub_line_spacing, + label: format!("{:.1}", state.reader_defaults.epub_line_spacing), + }) + }); + pick_list(options, selected, |option| { + Message::SelectDefaultEpubLineSpacing(option.value) + }) + .font(state.i18n.ui_font()) + .text_size(14) + .padding([9, 12]) + .into() +} + +fn pdf_zoom_selector(state: &State) -> Element<'static, Message> { + let options = [ + SelectOption { + value: false, + label: state.i18n.text("fit-page"), + }, + SelectOption { + value: true, + label: state.i18n.text("fit-width"), + }, + ]; + let fit_width = state.reader_defaults.pdf_zoom == ZoomMode::FitWidth; + let selected = options + .iter() + .find(|option| option.value == fit_width) + .cloned(); + pick_list(options, selected, |option| { + Message::SelectDefaultPdfFitWidth(option.value) + }) + .font(state.i18n.ui_font()) + .text_size(14) + .padding([9, 12]) + .into() +} + fn library_sidebar(state: &State) -> Element<'_, Message> { let content = column![ - text(state.i18n.text("collection")) - .size(11) - .color(app_theme::TEXT_MUTED), - widgets::navigation_button( - state.i18n.text("all-books"), - state.library_filter.is_none(), - Message::LibraryFilterChanged(None), - ), - widgets::navigation_button( - "EPUB", - state.library_filter == Some(shosai_core::library::BookFormat::Epub), - Message::LibraryFilterChanged(Some(shosai_core::library::BookFormat::Epub)), - ), + column![ + text(state.i18n.text("collection")) + .size(11) + .color(app_theme::TEXT_MUTED), + widgets::navigation_button( + state.i18n.text("all-books"), + state.screen == Screen::Library && state.library_filter.is_none(), + Message::LibraryFilterChanged(None), + state.i18n.ui_font(), + ), + widgets::navigation_button( + "EPUB", + state.screen == Screen::Library + && state.library_filter == Some(shosai_core::library::BookFormat::Epub), + Message::LibraryFilterChanged(Some(shosai_core::library::BookFormat::Epub)), + state.i18n.ui_font(), + ), + widgets::navigation_button( + "PDF", + state.screen == Screen::Library + && state.library_filter == Some(shosai_core::library::BookFormat::Pdf), + Message::LibraryFilterChanged(Some(shosai_core::library::BookFormat::Pdf)), + state.i18n.ui_font(), + ), + ] + .spacing(6), + iced::widget::Space::new().height(Length::Fill), widgets::navigation_button( - "PDF", - state.library_filter == Some(shosai_core::library::BookFormat::Pdf), - Message::LibraryFilterChanged(Some(shosai_core::library::BookFormat::Pdf)), + state.i18n.text("settings"), + state.screen == Screen::Settings, + Message::ShowSettings, + state.i18n.ui_font(), ), ] - .spacing(6) - .padding([22, 14]); + .spacing(12) + .padding([22, 14]) + .height(Length::Fill); container(content) .width(184) @@ -3524,18 +4180,29 @@ fn mobile_library_filters(state: &State) -> Element<'_, Message> { row![ widgets::navigation_button( state.i18n.text("all"), - state.library_filter.is_none(), + state.screen == Screen::Library && state.library_filter.is_none(), Message::LibraryFilterChanged(None), + state.i18n.ui_font(), ), widgets::navigation_button( "EPUB", - state.library_filter == Some(shosai_core::library::BookFormat::Epub), + state.screen == Screen::Library + && state.library_filter == Some(shosai_core::library::BookFormat::Epub), Message::LibraryFilterChanged(Some(shosai_core::library::BookFormat::Epub)), + state.i18n.ui_font(), ), widgets::navigation_button( "PDF", - state.library_filter == Some(shosai_core::library::BookFormat::Pdf), + state.screen == Screen::Library + && state.library_filter == Some(shosai_core::library::BookFormat::Pdf), Message::LibraryFilterChanged(Some(shosai_core::library::BookFormat::Pdf)), + state.i18n.ui_font(), + ), + widgets::navigation_button( + state.i18n.text("settings"), + state.screen == Screen::Settings, + Message::ShowSettings, + state.i18n.ui_font(), ), ] .spacing(4), @@ -3580,23 +4247,18 @@ fn library_collection(state: &State) -> Element<'_, Message> { .spacing(14) .align_x(iced::Center); if !state.library_loading && !constrained && state.storage_error.is_none() { - empty = empty.push( - row![ - widgets::primary_button( - state.i18n.text("add-first-book"), - state.library.is_some().then_some(Message::ImportFile), - ), - widgets::secondary_button( - state.i18n.text("link-book"), - state.library.is_some().then_some(Message::LinkFile), - ), - widgets::secondary_button( - state.i18n.text("scan-folder-long"), - state.library.is_some().then_some(Message::ImportDirectory), - ), - ] - .spacing(8), - ); + let message = (state.library.is_some() && !state.adding_books && !state.moving_library) + .then_some(Message::OpenAddBooks); + let label = if state.adding_books { + state.i18n.text("adding-books") + } else { + state.i18n.text("add-first-books") + }; + empty = empty.push(widgets::primary_button( + label, + message, + state.i18n.ui_font(), + )); } return center(empty) @@ -3661,6 +4323,245 @@ fn library_collection(state: &State) -> Element<'_, Message> { .into() } +fn modal_overlay<'a>( + content: Element<'a, Message>, + modal: Element<'a, Message>, + dismiss: Message, +) -> Element<'a, Message> { + let backdrop = mouse_area( + container(iced::widget::Space::new()) + .width(Length::Fill) + .height(Length::Fill) + .style(app_theme::modal_backdrop), + ) + .on_press(dismiss); + + iced::widget::Stack::new() + .push(content) + .push(backdrop) + .push(center(opaque(modal)).padding(20)) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +fn managed_library_move_modal(state: &State) -> Element<'_, Message> { + let plan = state + .pending_library_move + .as_ref() + .expect("move modal requires a plan"); + let current = state + .library + .as_ref() + .map(|library| library.managed_dir().display().to_string()) + .unwrap_or_default(); + let summary = state.i18n.text_with_args( + "managed-library-move-summary", + [ + ("count", (plan.summary.book_count as i64).into()), + ("size", format_bytes(plan.summary.total_bytes).into()), + ], + ); + let move_label = if state.moving_library { + state.i18n.text("moving-library") + } else { + state.i18n.text("move-library") + }; + let actions = row![ + iced::widget::Space::new().width(Length::Fill), + widgets::secondary_button( + state.i18n.text("cancel"), + (!state.moving_library).then_some(Message::CancelManagedLibraryMove), + state.i18n.ui_font(), + ), + widgets::primary_button( + move_label, + (!state.moving_library).then_some(Message::ConfirmManagedLibraryMove), + state.i18n.ui_font(), + ), + ] + .spacing(8); + let error: Element<'_, Message> = if let Some(error) = &state.settings_error { + container(text(error.clone()).size(12)) + .padding([8, 10]) + .width(Length::Fill) + .style(app_theme::reader_alert) + .into() + } else { + iced::widget::Space::new().height(0).into() + }; + + container( + column![ + text(state.i18n.text("move-managed-library-heading")).size(20), + text(state.i18n.text("move-managed-library-description")) + .size(13) + .color(app_theme::TEXT_MUTED), + text(state.i18n.text("from-location")).size(12), + container(text(current).size(12)) + .padding([8, 10]) + .width(Length::Fill) + .style(app_theme::skeleton), + text(state.i18n.text("to-location")).size(12), + container(text(plan.destination.display().to_string()).size(12)) + .padding([8, 10]) + .width(Length::Fill) + .style(app_theme::skeleton), + text(summary).size(13), + error, + actions, + ] + .spacing(12), + ) + .padding(24) + .width(Length::Fill) + .max_width(520) + .style(app_theme::modal) + .into() +} + +fn format_bytes(bytes: u64) -> String { + const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"]; + let mut value = bytes as f64; + let mut unit = 0; + while value >= 1024.0 && unit < UNITS.len() - 1 { + value /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes} {}", UNITS[unit]) + } else { + format!("{value:.1} {}", UNITS[unit]) + } +} + +fn add_books_modal(state: &State) -> Element<'_, Message> { + let cancel = button(text(state.i18n.text("cancel"))) + .on_press(Message::CancelAddBooks) + .padding([8, 14]) + .style(app_theme::book_card_action); + + let content: Element<'_, Message> = if let Some(source) = &state.add_books_source { + let selection = match source { + AddBooksSource::Files(paths) => state.i18n.text_with_args( + "selected-book-files", + [("count", (paths.len() as i64).into())], + ), + AddBooksSource::Folder(path) => { + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + state + .i18n + .text_with_args("selected-book-folder", [("folder", name.into())]) + } + }; + if state.add_book_behavior == AddBookBehavior::Ask || state.add_books_override { + column![ + text(state.i18n.text("book-storage-heading")).size(20), + text(selection).size(13).color(app_theme::TEXT_MUTED), + widgets::primary_button( + state.i18n.text("copy-into-shosai"), + Some(Message::AddSelectedBooks { copy: true }), + state.i18n.ui_font(), + ) + .width(Length::Fill), + text(state.i18n.text("copy-into-shosai-description")) + .size(12) + .color(app_theme::TEXT_MUTED), + widgets::secondary_button( + state.i18n.text("use-current-location"), + Some(Message::AddSelectedBooks { copy: false }), + state.i18n.ui_font(), + ) + .width(Length::Fill), + text(state.i18n.text("use-current-location-description")) + .size(12) + .color(app_theme::TEXT_MUTED), + row![ + button(text(state.i18n.text("back"))) + .on_press(Message::ClearAddBooksSelection) + .padding([8, 14]) + .style(app_theme::book_card_action), + iced::widget::Space::new().width(Length::Fill), + cancel, + ] + .align_y(iced::Alignment::Center), + ] + .spacing(12) + .into() + } else { + let copy = state.add_book_behavior == AddBookBehavior::Copy; + let behavior = if copy { + state.i18n.text("copy-into-shosai") + } else { + state.i18n.text("use-current-location") + }; + let description = if copy { + state.i18n.text("copy-into-shosai-description") + } else { + state.i18n.text("use-current-location-description") + }; + column![ + text(state.i18n.text("ready-to-add-books")).size(20), + text(selection).size(13).color(app_theme::TEXT_MUTED), + text(behavior).size(15), + text(description).size(12).color(app_theme::TEXT_MUTED), + button(text(state.i18n.text("change-storage-choice"))) + .on_press(Message::ChangeAddBooksStorage) + .padding([8, 0]) + .style(app_theme::book_card_action), + widgets::primary_button( + state.i18n.text("add-selected-books"), + Some(Message::AddSelectedBooks { copy }), + state.i18n.ui_font(), + ) + .width(Length::Fill), + row![ + button(text(state.i18n.text("back"))) + .on_press(Message::ClearAddBooksSelection) + .padding([8, 14]) + .style(app_theme::book_card_action), + iced::widget::Space::new().width(Length::Fill), + cancel, + ], + ] + .spacing(12) + .into() + } + } else { + column![ + text(state.i18n.text("add-books-heading")).size(20), + text(state.i18n.text("add-books-description")) + .size(13) + .color(app_theme::TEXT_MUTED), + widgets::primary_button( + state.i18n.text("choose-book-files"), + Some(Message::ChooseBookFiles), + state.i18n.ui_font(), + ) + .width(Length::Fill), + widgets::secondary_button( + state.i18n.text("choose-book-folder"), + Some(Message::ChooseBookFolder), + state.i18n.ui_font(), + ) + .width(Length::Fill), + row![iced::widget::Space::new().width(Length::Fill), cancel], + ] + .spacing(14) + .into() + }; + + container(content) + .padding(24) + .width(Length::Fill) + .max_width(480) + .style(app_theme::modal) + .into() +} + fn remove_book_modal<'a>(state: &'a State, book: &'a Book) -> Element<'a, Message> { let description_key = if book.storage_kind == shosai_core::library::StorageKind::Managed { "remove-managed-book-description" @@ -5660,6 +6561,218 @@ mod tests { assert_eq!(state.book_menu, None); } + #[test] + fn settings_navigation_works_in_both_layouts() { + let (mut state, _) = boot(); + + let _ = update(&mut state, Message::ShowSettings); + + assert_eq!(state.screen, Screen::Settings); + drop(settings_layout(&state, 900.0)); + drop(settings_layout(&state, 600.0)); + + let _ = update(&mut state, Message::LibraryFilterChanged(None)); + + assert_eq!(state.screen, Screen::Library); + } + + #[test] + fn editable_and_cross_script_controls_use_bundled_glyph_coverage() { + assert_eq!( + editable_text_font("日本語", "Search"), + typography::NOTO_SANS_JP + ); + assert_eq!(editable_text_font("", "検索"), typography::NOTO_SANS_JP); + assert_eq!(language_menu_font(), typography::NOTO_SANS_JP); + } + + #[test] + fn partial_import_completion_keeps_successes_visible_and_surfaces_failures() { + let (mut state, _) = boot(); + state.adding_books = true; + let report = ImportReport { + books: vec![test_book(42)], + failures: vec![shosai_core::library::ImportFailure { + path: PathBuf::from("corrupt.epub"), + error: "invalid archive".to_string(), + }], + }; + + let task = update(&mut state, Message::BooksAdded(report)); + + assert_eq!(task.units(), 0); + assert!(!state.adding_books); + let Some(AppError::Import(error)) = state.library_error else { + panic!("partial import should surface its failures"); + }; + assert_eq!( + error.replace(['\u{2068}', '\u{2069}'], ""), + "Books added or already present: 1. Files not imported: 1. corrupt.epub: invalid archive" + ); + } + + #[tokio::test] + async fn add_books_flow_separates_source_selection_from_storage() { + let directory = tempfile::tempdir().unwrap(); + let store = ReadingStateStore::open_at_async(&directory.path().join("state.db")) + .await + .unwrap(); + let (mut state, _) = boot(); + state.library = Some(Library::new( + store.pool().clone(), + store.managed_books_dir(), + )); + + let _ = update(&mut state, Message::OpenAddBooks); + assert!(state.add_books_open); + assert!(state.add_books_source.is_none()); + drop(add_books_modal(&state)); + + let paths = vec![PathBuf::from("one.epub"), PathBuf::from("two.pdf")]; + let _ = update(&mut state, Message::AddBookFilesSelected(paths)); + assert!(matches!( + state.add_books_source, + Some(AddBooksSource::Files(ref paths)) if paths.len() == 2 + )); + state.add_book_behavior = AddBookBehavior::Copy; + drop(add_books_modal(&state)); + let _ = update(&mut state, Message::ChangeAddBooksStorage); + assert!(state.add_books_override); + drop(add_books_modal(&state)); + + let _ = update(&mut state, Message::ClearAddBooksSelection); + assert!(state.add_books_source.is_none()); + assert!(!state.add_books_override); + + let _ = update( + &mut state, + Message::AddBookFolderSelected(Some(PathBuf::from("books"))), + ); + assert!(matches!( + state.add_books_source, + Some(AddBooksSource::Folder(ref path)) if path == &PathBuf::from("books") + )); + + let _ = update(&mut state, Message::CancelAddBooks); + assert!(!state.add_books_open); + assert!(state.add_books_source.is_none()); + } + + #[tokio::test] + async fn settings_preferences_are_persisted_and_do_not_change_open_tabs() { + let directory = tempfile::tempdir().unwrap(); + let store = ReadingStateStore::open_at_async(&directory.path().join("state.db")) + .await + .unwrap(); + let epub = EpubDoc::from_bytes( + include_bytes!("../../shosai-core/tests/fixtures/sample.epub").to_vec(), + ) + .unwrap(); + let mut state = state_with_document(OpenDocument::Epub(Arc::new(epub))); + state.reading_state_saves = Some(start_reading_state_writer(store.clone())); + let open_mode = state.reading_mode; + let open_theme = state.theme; + + let _ = update( + &mut state, + Message::SelectAddBookBehavior(AddBookBehavior::Copy), + ); + let _ = update( + &mut state, + Message::SelectDefaultReadingMode(ReadingMode::Continuous), + ); + let _ = update( + &mut state, + Message::SelectDefaultReaderTheme(ReaderTheme::Sepia), + ); + let _ = update(&mut state, Message::DefaultEpubFontSizeUp); + let _ = update(&mut state, Message::SelectDefaultEpubLineSpacing(1.8)); + let _ = update(&mut state, Message::SelectDefaultPdfFitWidth(true)); + let (flushed, wait) = oneshot::channel(); + state + .reading_state_saves + .as_ref() + .unwrap() + .send(ReadingStateWriterMessage::Flush(flushed)) + .unwrap(); + wait.await.unwrap(); + + assert_eq!(state.reading_mode, open_mode); + assert_eq!(state.theme, open_theme); + assert_eq!( + store.get_pref_async(ADD_BOOK_BEHAVIOR_KEY).await.as_deref(), + Some("copy") + ); + assert_eq!( + store + .get_pref_async(DEFAULT_READING_MODE_KEY) + .await + .as_deref(), + Some("continuous") + ); + assert_eq!( + store + .get_pref_async(DEFAULT_READER_THEME_KEY) + .await + .as_deref(), + Some("sepia") + ); + assert_eq!( + store + .get_pref_async(DEFAULT_EPUB_FONT_SIZE_KEY) + .await + .as_deref(), + Some("18") + ); + assert_eq!( + store + .get_pref_async(DEFAULT_EPUB_LINE_SPACING_KEY) + .await + .as_deref(), + Some("1.8") + ); + assert_eq!( + store.get_pref_async(DEFAULT_PDF_ZOOM_KEY).await.as_deref(), + Some("fit-width") + ); + } + + #[test] + fn reader_defaults_seed_new_tabs() { + let directory = tempfile::tempdir().unwrap(); + let epub_path = directory.path().join("book.epub"); + let pdf_path = directory.path().join("book.pdf"); + std::fs::copy( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../shosai-core/tests/fixtures/sample.epub"), + &epub_path, + ) + .unwrap(); + std::fs::copy( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../shosai-core/tests/fixtures/sample.pdf"), + &pdf_path, + ) + .unwrap(); + let (mut state, _) = boot(); + state.reader_defaults = ReaderDefaults { + reading_mode: ReadingMode::Continuous, + theme: ReaderTheme::Sepia, + epub_font_size: 20.0, + epub_line_spacing: 1.8, + pdf_zoom: ZoomMode::FitWidth, + }; + + let _ = open_document(&mut state, epub_path, None); + assert_eq!(state.reading_mode, ReadingMode::Continuous); + assert_eq!(state.theme, ReaderTheme::Sepia); + assert_eq!(state.font_size, 20.0); + assert_eq!(state.line_spacing, 1.8); + + let _ = open_document(&mut state, pdf_path, None); + assert_eq!(state.zoom, ZoomMode::FitWidth); + } + #[test] fn removal_confirmation_describes_managed_and_referenced_storage() { let (mut state, _) = boot(); @@ -6081,9 +7194,10 @@ mod tests { let mut state = state_with_document(OpenDocument::Epub(Arc::new(epub))); state.storage_error = Some(AppError::Storage("storage failed".to_string())); - let task = update(&mut state, Message::ImportFile); + let task = update(&mut state, Message::OpenAddBooks); assert_eq!(task.units(), 0); + assert!(!state.add_books_open); assert!(state.library.is_none()); assert!(matches!( state.storage_error, @@ -7363,7 +8477,10 @@ mod tests { fn language_selection_is_ignored_until_storage_finishes_initializing() { let (mut state, _) = boot(); - let _ = update(&mut state, Message::CycleLanguage); + let _ = update( + &mut state, + Message::SelectLanguage(LanguagePreference::Japanese), + ); assert_eq!(state.i18n.preference(), LanguagePreference::System); } diff --git a/crates/shosai-app/src/app/dispatch.rs b/crates/shosai-app/src/app/dispatch.rs index aeea0068..7324a78f 100644 --- a/crates/shosai-app/src/app/dispatch.rs +++ b/crates/shosai-app/src/app/dispatch.rs @@ -7,13 +7,18 @@ pub fn update(state: &mut State, message: Message) -> Task { store, window_geometry: geometry, language_preference, + managed_books_dir, + add_book_behavior, + reader_defaults, } = initialized; state.i18n.set_preference(language_preference); let pool = store.pool().clone(); - state.library = Some(Library::new(pool.clone(), store.managed_books_dir())); + state.library = Some(Library::new(pool.clone(), managed_books_dir)); state.bookmark_store = Some(BookmarkStore::new(pool)); state.reading_state_saves = Some(start_reading_state_writer(store.clone())); state.reading_state = Some(store); + state.add_book_behavior = add_book_behavior; + state.reader_defaults = reader_defaults; state.storage_initializing = false; let geometry = (!state.performance.is_automated()) .then_some(geometry) @@ -379,6 +384,9 @@ pub fn update(state: &mut State, message: Message) -> Task { // Library Message::ShowLibrary => { + if state.moving_library { + return Task::none(); + } invalidate_continuous_layout(state); state.screen = Screen::Library; state.show_reader_settings = false; @@ -386,6 +394,14 @@ pub fn update(state: &mut State, message: Message) -> Task { return Task::done(Message::RefreshLibrary); } + Message::ShowSettings => { + state.screen = Screen::Settings; + state.book_menu = None; + state.pending_remove_book = None; + state.add_books_open = false; + state.add_books_source = None; + } + Message::RefreshLibrary => { return reset_library(state); } @@ -429,85 +445,137 @@ pub fn update(state: &mut State, message: Message) -> Task { state.library_loading = false; } - Message::ImportFile => { - let Some(lib) = state.library.clone() else { + Message::OpenAddBooks => { + if state.library.is_some() && !state.adding_books && !state.moving_library { + state.book_menu = None; + state.pending_remove_book = None; + state.add_books_source = None; + state.add_books_override = false; + state.add_books_open = true; + } + } + + Message::CancelAddBooks => { + state.add_books_open = false; + state.add_books_source = None; + state.add_books_override = false; + } + + Message::ChooseBookFiles => { + if !state.add_books_open || state.library.is_none() { return Task::none(); - }; + } let ebooks = state.i18n.text("ebooks"); - let import_library = state.i18n.text("import-library"); + let choose_files = state.i18n.text("choose-book-files"); return Task::perform( async move { - let file = rfd::AsyncFileDialog::new() + rfd::AsyncFileDialog::new() .add_filter(&ebooks, &["pdf", "epub", "cbz"]) - .set_title(&import_library) - .pick_file() - .await; - match file { - Some(file) => lib - .import_managed_file(file.path()) - .await - .map_err(|error| format!("{error:#}")), - None => return Ok(None), - } - .map(Some) + .set_title(&choose_files) + .pick_files() + .await + .unwrap_or_default() + .into_iter() + .map(|file| file.path().to_path_buf()) + .collect() }, - |result| match result { - Ok(Some(book)) => Message::ManagedBookImported(Ok(book)), - Ok(None) => Message::RefreshLibrary, - Err(error) => Message::ManagedBookImported(Err(error)), + Message::AddBookFilesSelected, + ); + } + + Message::ChooseBookFolder => { + if !state.add_books_open || state.library.is_none() { + return Task::none(); + } + let choose_folder = state.i18n.text("choose-book-folder"); + return Task::perform( + async move { + rfd::AsyncFileDialog::new() + .set_title(&choose_folder) + .pick_folder() + .await + .map(|folder| folder.path().to_path_buf()) }, + Message::AddBookFolderSelected, ); } - Message::LinkFile => { - let Some(lib) = state.library.clone() else { + Message::AddBookFilesSelected(paths) => { + if state.add_books_open && !paths.is_empty() { + state.add_books_source = Some(AddBooksSource::Files(paths)); + } + } + + Message::AddBookFolderSelected(path) => { + if state.add_books_open + && let Some(path) = path + { + state.add_books_source = Some(AddBooksSource::Folder(path)); + } + } + + Message::ClearAddBooksSelection => { + if state.add_books_open { + state.add_books_source = None; + state.add_books_override = false; + } + } + + Message::ChangeAddBooksStorage => { + if state.add_books_open && state.add_books_source.is_some() { + state.add_books_override = true; + } + } + + Message::AddSelectedBooks { copy } => { + if state.adding_books { + return Task::none(); + } + let (Some(lib), Some(source)) = (state.library.clone(), state.add_books_source.take()) + else { return Task::none(); }; - let ebooks = state.i18n.text("ebooks"); - let import_library = state.i18n.text("link-library"); + state.add_books_open = false; + state.adding_books = true; + state.library_activity_progress = 0.0; + state.library_error = None; return Task::perform( async move { - let file = rfd::AsyncFileDialog::new() - .add_filter(&ebooks, &["pdf", "epub", "cbz"]) - .set_title(&import_library) - .pick_file() - .await; - match file { - Some(file) => lib - .import_file(file.path()) - .await - .map(Some) - .map_err(|error| format!("{error:#}")), - None => Ok(None), + match source { + AddBooksSource::Files(paths) => { + if copy { + lib.import_files(&paths).await + } else { + lib.link_files(&paths).await + } + } + AddBooksSource::Folder(path) => { + if copy { + lib.import_directory(&path).await + } else { + lib.link_directory(&path).await + } + } } }, - |result| match result { - Ok(Some(book)) => Message::ManagedBookImported(Ok(book)), - Ok(None) => Message::RefreshLibrary, - Err(error) => Message::ManagedBookImported(Err(error)), - }, + Message::BooksAdded, ); } - Message::ImportDirectory => { - if let Some(lib) = state.library.clone() { - let import_directory = state.i18n.text("import-directory"); - return Task::perform( - async move { - let dir = rfd::AsyncFileDialog::new() - .set_title(&import_directory) - .pick_folder() - .await; - if let Some(d) = dir { - let _ = lib.import_directory(d.path()).await; - } - }, - |_| Message::RefreshLibrary, - ); + Message::BooksAdded(report) => { + state.adding_books = false; + let error = import_report_error(&report, &state.i18n); + let refresh = reset_library(state); + if let Some(error) = error { + state.library_error = Some(error); } + return refresh; } Message::OpenLibraryBook(book_id, file_path) => { + if state.moving_library { + return Task::none(); + } state.book_menu = None; state.pending_remove_book = None; let path = PathBuf::from(file_path); @@ -520,17 +588,6 @@ pub fn update(state: &mut State, message: Message) -> Task { return open_document(state, path, Some(book_id)); } - Message::ManagedBookImported(result) => match result { - Ok(book) => { - let task = Task::done(Message::OpenLibraryBook(book.id, book.file_path)); - return Task::batch([reset_library(state), task]); - } - Err(error) => { - state.open_error = Some(AppError::Library(error)); - state.screen = Screen::Reader; - } - }, - Message::LocateBook(book_id) => { if state.library.is_none() || state.removing_book == Some(book_id) { return Task::none(); @@ -589,7 +646,7 @@ pub fn update(state: &mut State, message: Message) -> Task { } Message::RequestRemoveBook(id) => { - if state.removing_book.is_none() { + if state.removing_book.is_none() && !state.moving_library { state.book_menu = None; state.pending_remove_book = Some(id); state.library_error = None; @@ -601,7 +658,8 @@ pub fn update(state: &mut State, message: Message) -> Task { } Message::RemoveBook(id) => { - if state.removing_book.is_some() + if state.moving_library + || state.removing_book.is_some() || (state.pending_remove_book != Some(id) && state.missing_book_id != Some(id)) { return Task::none(); @@ -701,6 +759,7 @@ pub fn update(state: &mut State, message: Message) -> Task { } Message::LibraryFilterChanged(filter) => { + state.screen = Screen::Library; state.library_filter = filter; return reset_library(state); } @@ -712,11 +771,10 @@ pub fn update(state: &mut State, message: Message) -> Task { } } - Message::CycleLanguage => { + Message::SelectLanguage(preference) => { let Some(saves) = &state.reading_state_saves else { return Task::none(); }; - let preference = state.i18n.preference().next(); state.i18n.set_preference(preference); if saves .send(ReadingStateWriterMessage::Language(preference)) @@ -726,6 +784,194 @@ pub fn update(state: &mut State, message: Message) -> Task { } } + Message::SelectAddBookBehavior(behavior) => { + state.add_book_behavior = behavior; + save_preference(state, ADD_BOOK_BEHAVIOR_KEY, behavior.stored()); + } + + Message::SelectDefaultReadingMode(mode) => { + state.reader_defaults.reading_mode = mode; + save_preference(state, DEFAULT_READING_MODE_KEY, mode.stored()); + } + + Message::SelectDefaultReaderTheme(theme) => { + state.reader_defaults.theme = theme; + save_preference(state, DEFAULT_READER_THEME_KEY, theme.stored()); + } + + Message::DefaultEpubFontSizeUp => { + state.reader_defaults.epub_font_size = + (state.reader_defaults.epub_font_size + 2.0).min(48.0); + save_preference( + state, + DEFAULT_EPUB_FONT_SIZE_KEY, + state.reader_defaults.epub_font_size.to_string(), + ); + } + + Message::DefaultEpubFontSizeDown => { + state.reader_defaults.epub_font_size = + (state.reader_defaults.epub_font_size - 2.0).max(8.0); + save_preference( + state, + DEFAULT_EPUB_FONT_SIZE_KEY, + state.reader_defaults.epub_font_size.to_string(), + ); + } + + Message::SelectDefaultEpubLineSpacing(line_spacing) => { + state.reader_defaults.epub_line_spacing = line_spacing.clamp(1.0, 2.4); + save_preference( + state, + DEFAULT_EPUB_LINE_SPACING_KEY, + state.reader_defaults.epub_line_spacing.to_string(), + ); + } + + Message::SelectDefaultPdfFitWidth(fit_width) => { + state.reader_defaults.pdf_zoom = if fit_width { + ZoomMode::FitWidth + } else { + ZoomMode::FitPage + }; + save_preference( + state, + DEFAULT_PDF_ZOOM_KEY, + if fit_width { "fit-width" } else { "fit-page" }, + ); + } + + Message::OpenManagedLibraryFolder => { + let Some(library) = &state.library else { + return Task::none(); + }; + let path = library.managed_dir().to_path_buf(); + if let Err(error) = std::fs::create_dir_all(&path) + .and_then(|_| open::that(&path).map_err(std::io::Error::other)) + { + state.settings_error = Some(error.to_string()); + } + } + + Message::ChooseManagedLibraryParent => { + if state.library.is_none() + || state.moving_library + || state.adding_books + || state.removing_book.is_some() + { + return Task::none(); + } + let title = state.i18n.text("choose-managed-library-parent"); + return Task::perform( + async move { + rfd::AsyncFileDialog::new() + .set_title(&title) + .pick_folder() + .await + .map(|folder| folder.path().to_path_buf()) + }, + Message::ManagedLibraryParentSelected, + ); + } + + Message::ManagedLibraryParentSelected(parent) => { + let (Some(parent), Some(library)) = (parent, state.library.clone()) else { + return Task::none(); + }; + let destination = parent.join("Shosai"); + if library.managed_dir() == destination { + return Task::none(); + } + state.settings_error = None; + return Task::perform( + async move { + library + .managed_storage_summary() + .await + .map_err(|error| format!("{error:#}")) + }, + move |result| Message::ManagedLibraryMovePlanned { + destination: destination.clone(), + result, + }, + ); + } + + Message::ManagedLibraryMovePlanned { + destination, + result, + } => match result { + Ok(summary) => { + state.pending_library_move = Some(LibraryMovePlan { + destination, + summary, + }); + } + Err(error) => state.settings_error = Some(error), + }, + + Message::CancelManagedLibraryMove => { + if !state.moving_library { + state.pending_library_move = None; + } + } + + Message::ConfirmManagedLibraryMove => { + if state.moving_library || state.adding_books || state.removing_book.is_some() { + return Task::none(); + } + let (Some(plan), Some(library)) = + (state.pending_library_move.as_ref(), state.library.clone()) + else { + return Task::none(); + }; + let destination = plan.destination.clone(); + let relocation_destination = destination.clone(); + let saves = state.reading_state_saves.clone(); + state.moving_library = true; + state.settings_error = None; + return Task::perform( + async move { + if let Some(saves) = saves { + let (flushed, wait) = oneshot::channel(); + if saves + .send(ReadingStateWriterMessage::Flush(flushed)) + .is_ok() + { + let _ = wait.await; + } + } + library + .relocate_managed_books(&relocation_destination) + .await + .map_err(|error| format!("{error:#}")) + }, + move |result| Message::ManagedLibraryMoved { + destination: destination.clone(), + result, + }, + ); + } + + Message::ManagedLibraryMoved { + destination, + result, + } => { + state.moving_library = false; + match result { + Ok(changes) => { + apply_managed_path_changes(state, &changes); + let destination = destination.canonicalize().unwrap_or(destination); + if let Some(library) = &state.library { + state.library = Some(library.with_managed_dir(destination)); + } + state.pending_library_move = None; + return reset_library(state); + } + Err(error) => state.settings_error = Some(error), + } + } + // Bookmarks Message::ToggleBookmark => { if let (Some(path), Some(store)) = (&state.file_path, &state.bookmark_store) { diff --git a/crates/shosai-app/src/app/message.rs b/crates/shosai-app/src/app/message.rs index 2f68add4..36b19194 100644 --- a/crates/shosai-app/src/app/message.rs +++ b/crates/shosai-app/src/app/message.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use iced::{keyboard, window}; use shosai_core::bookmarks::Bookmark; use shosai_core::document::RenderedPage; -use shosai_core::library::{Book, BookPage}; +use shosai_core::library::{Book, BookPage, ImportReport}; use shosai_core::search::SearchMatch; use super::{ContinuousRequest, EpubLayoutKey, EpubPage, InitializedState, PageCacheKey}; @@ -72,6 +72,7 @@ pub enum Message { // Library ShowLibrary, + ShowSettings, RefreshLibrary, LoadMoreLibrary, LibraryIndexLoaded { @@ -84,11 +85,19 @@ pub enum Message { next_offset: usize, page: BookPage, }, - ImportFile, - LinkFile, - ImportDirectory, + OpenAddBooks, + CancelAddBooks, + ChooseBookFiles, + ChooseBookFolder, + AddBookFilesSelected(Vec), + AddBookFolderSelected(Option), + ClearAddBooksSelection, + ChangeAddBooksStorage, + AddSelectedBooks { + copy: bool, + }, + BooksAdded(ImportReport), OpenLibraryBook(i64, String), - ManagedBookImported(Result), LocateBook(i64), RelinkBookSelected(i64, Option), BookRelinked(Result), @@ -104,7 +113,29 @@ pub enum Message { LibrarySearchChanged(String), LibraryFilterChanged(Option), LibraryActivityTick, - CycleLanguage, + SelectLanguage(crate::i18n::LanguagePreference), + + // Settings + SelectAddBookBehavior(super::AddBookBehavior), + SelectDefaultReadingMode(super::ReadingMode), + SelectDefaultReaderTheme(crate::theme::ReaderTheme), + DefaultEpubFontSizeUp, + DefaultEpubFontSizeDown, + SelectDefaultEpubLineSpacing(f32), + SelectDefaultPdfFitWidth(bool), + OpenManagedLibraryFolder, + ChooseManagedLibraryParent, + ManagedLibraryParentSelected(Option), + ManagedLibraryMovePlanned { + destination: PathBuf, + result: Result, + }, + CancelManagedLibraryMove, + ConfirmManagedLibraryMove, + ManagedLibraryMoved { + destination: PathBuf, + result: Result, String>, + }, // Bookmarks ToggleBookmark, diff --git a/crates/shosai-app/src/i18n.rs b/crates/shosai-app/src/i18n.rs index 8efa016b..5a54ccd3 100644 --- a/crates/shosai-app/src/i18n.rs +++ b/crates/shosai-app/src/i18n.rs @@ -5,6 +5,8 @@ use fluent_templates::fluent_bundle::FluentValue; use fluent_templates::{Loader, static_loader}; use unic_langid::{LanguageIdentifier, langid}; +use crate::typography; + const ENGLISH: LanguageIdentifier = langid!("en-US"); const JAPANESE: LanguageIdentifier = langid!("ja"); @@ -39,14 +41,6 @@ impl LanguagePreference { Self::Japanese => "ja", } } - - pub fn next(self) -> Self { - match self { - Self::System => Self::English, - Self::English => Self::Japanese, - Self::Japanese => Self::System, - } - } } #[derive(Debug)] @@ -76,6 +70,14 @@ impl I18n { *self = Self::new(preference); } + pub fn ui_font(&self) -> iced::Font { + if self.language == JAPANESE { + typography::NOTO_SANS_JP + } else { + typography::INTER + } + } + pub fn text(&self, key: &str) -> String { LOCALES.lookup(&self.language, key) } @@ -91,15 +93,6 @@ impl I18n { .collect::>(); LOCALES.lookup_with_args(&self.language, key, &args) } - - pub fn selector_label(&self) -> String { - let key = match self.preference { - LanguagePreference::System => "language-system", - LanguagePreference::English => "language-english", - LanguagePreference::Japanese => "language-japanese", - }; - self.text(key) - } } fn system_language() -> LanguageIdentifier { @@ -141,6 +134,18 @@ mod tests { } } + #[test] + fn interface_font_tracks_the_resolved_language() { + assert_eq!( + I18n::new(LanguagePreference::English).ui_font(), + typography::INTER + ); + assert_eq!( + I18n::new(LanguagePreference::Japanese).ui_font(), + typography::NOTO_SANS_JP + ); + } + #[test] fn japanese_catalog_formats_arguments() { let i18n = I18n::new(LanguagePreference::Japanese); diff --git a/crates/shosai-app/src/main.rs b/crates/shosai-app/src/main.rs index 3a41ec7d..9d1b26fb 100644 --- a/crates/shosai-app/src/main.rs +++ b/crates/shosai-app/src/main.rs @@ -3,6 +3,7 @@ mod epub; mod i18n; mod pdf; mod theme; +mod typography; mod widgets; const APPLICATION_ICON: &[u8] = if option_env!("SHOSAI_DEV_BUILD").is_some() { @@ -63,7 +64,10 @@ fn main() -> iced::Result { .title(app::title) .theme(theme::application()) .subscription(app::subscription) + .font(typography::INTER_BYTES) + .font(typography::NOTO_SANS_JP_BYTES) .font(epub::math_layout::MATH_FONT_BYTES) + .default_font(typography::INTER) .window(iced::window::Settings { icon: window_icon(), size: window_size, diff --git a/crates/shosai-app/src/theme.rs b/crates/shosai-app/src/theme.rs index ab17f0a9..efdbc274 100644 --- a/crates/shosai-app/src/theme.rs +++ b/crates/shosai-app/src/theme.rs @@ -35,6 +35,22 @@ pub struct ReaderPalette { } impl ReaderTheme { + pub fn from_stored(value: Option<&str>) -> Self { + match value { + Some("dark") => Self::Dark, + Some("sepia") => Self::Sepia, + _ => Self::Light, + } + } + + pub fn stored(self) -> &'static str { + match self { + Self::Light => "light", + Self::Dark => "dark", + Self::Sepia => "sepia", + } + } + pub fn palette(self) -> ReaderPalette { ReaderPalette { background: self.background(), diff --git a/crates/shosai-app/src/typography.rs b/crates/shosai-app/src/typography.rs new file mode 100644 index 00000000..1bec186c --- /dev/null +++ b/crates/shosai-app/src/typography.rs @@ -0,0 +1,63 @@ +use iced::Font; + +pub const INTER_BYTES: &[u8] = include_bytes!("../../../assets/fonts/InterVariable.ttf"); +pub const NOTO_SANS_JP_BYTES: &[u8] = + include_bytes!("../../../assets/fonts/NotoSansJP-Variable.ttf"); + +pub const INTER: Font = Font::with_name("Inter Variable"); +pub const NOTO_SANS_JP: Font = Font::with_name("Noto Sans JP"); + +pub fn font_for_text(value: &str) -> Font { + if value.chars().any(is_japanese_character) { + NOTO_SANS_JP + } else { + INTER + } +} + +fn is_japanese_character(character: char) -> bool { + matches!( + character, + '\u{3000}'..='\u{30ff}' + | '\u{31f0}'..='\u{31ff}' + | '\u{3400}'..='\u{9fff}' + | '\u{f900}'..='\u{faff}' + | '\u{ff66}'..='\u{ff9f}' + | '\u{20000}'..='\u{2fa1f}' + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bundled_ui_fonts_have_expected_families_and_japanese_coverage() { + let inter = ttf_parser::Face::parse(INTER_BYTES, 0).unwrap(); + let noto_sans_jp = ttf_parser::Face::parse(NOTO_SANS_JP_BYTES, 0).unwrap(); + + assert!(family_names(&inter).any(|name| name == "Inter Variable")); + assert!(family_names(¬o_sans_jp).any(|name| name == "Noto Sans JP")); + assert!(noto_sans_jp.glyph_index('書').is_some()); + assert!(noto_sans_jp.glyph_index('語').is_some()); + } + + #[test] + fn interface_text_selects_a_bundled_font_by_script() { + assert_eq!(font_for_text("Settings"), INTER); + assert_eq!(font_for_text("EPUB 16 px"), INTER); + assert_eq!(font_for_text("設定"), NOTO_SANS_JP); + assert_eq!(font_for_text("Shosai フォルダー"), NOTO_SANS_JP); + } + + fn family_names<'a>(face: &'a ttf_parser::Face<'_>) -> impl Iterator + 'a { + face.names().into_iter().filter_map(|name| { + matches!( + name.name_id, + ttf_parser::name_id::FAMILY | ttf_parser::name_id::TYPOGRAPHIC_FAMILY + ) + .then(|| name.to_string()) + .flatten() + }) + } +} diff --git a/crates/shosai-app/src/widgets.rs b/crates/shosai-app/src/widgets.rs index 4fcfc66d..b6a84ead 100644 --- a/crates/shosai-app/src/widgets.rs +++ b/crates/shosai-app/src/widgets.rs @@ -1,13 +1,14 @@ use iced::widget::{Button, ProgressBar, button, container, progress_bar, row, text}; -use iced::{Element, Length}; +use iced::{Element, Font, Length}; use crate::theme; pub fn primary_button<'a, Message: Clone + 'a>( label: impl Into, message: Option, + font: Font, ) -> Button<'a, Message> { - button(text(label.into()).size(14)) + button(text(label.into()).size(14).font(font)) .on_press_maybe(message) .padding([9, 14]) .style(theme::primary_button) @@ -16,8 +17,9 @@ pub fn primary_button<'a, Message: Clone + 'a>( pub fn secondary_button<'a, Message: Clone + 'a>( label: impl Into, message: Option, + font: Font, ) -> Button<'a, Message> { - button(text(label.into()).size(14)) + button(text(label.into()).size(14).font(font)) .on_press_maybe(message) .padding([9, 14]) .style(theme::secondary_button) @@ -27,8 +29,9 @@ pub fn navigation_button<'a, Message: Clone + 'a>( label: impl Into, selected: bool, message: Message, + font: Font, ) -> Button<'a, Message> { - button(text(label.into()).size(14)) + button(text(label.into()).size(14).font(font)) .on_press(message) .padding([9, 12]) .width(Length::Fill) diff --git a/crates/shosai-core/src/library.rs b/crates/shosai-core/src/library.rs index d5218b32..0c0bb556 100644 --- a/crates/shosai-core/src/library.rs +++ b/crates/shosai-core/src/library.rs @@ -17,6 +17,8 @@ use crate::document::Document; use crate::epub::EpubDoc; use crate::pdf::PdfDoc; +pub const MANAGED_LIBRARY_DIR_PREFERENCE: &str = "library.managed_books_dir"; + /// Supported book format. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BookFormat { @@ -102,6 +104,43 @@ pub struct BookPage { pub has_more: bool, } +#[derive(Debug, Clone)] +pub struct ImportFailure { + pub path: PathBuf, + pub error: String, +} + +#[derive(Debug, Clone, Default)] +pub struct ImportReport { + pub books: Vec, + pub failures: Vec, +} + +impl ImportReport { + fn record(&mut self, path: PathBuf, result: Result) { + match result { + Ok(book) => self.books.push(book), + Err(error) => self.failures.push(ImportFailure { + path, + error: format!("{error:#}"), + }), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ManagedStorageSummary { + pub book_count: u64, + pub total_bytes: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManagedPathChange { + pub book_id: i64, + pub old_path: PathBuf, + pub new_path: PathBuf, +} + struct FileFingerprint { hash: String, size: u64, @@ -127,6 +166,157 @@ impl Library { Self { pool, managed_dir } } + pub fn managed_dir(&self) -> &Path { + &self.managed_dir + } + + pub fn with_managed_dir(&self, managed_dir: PathBuf) -> Self { + Self::new(self.pool.clone(), managed_dir) + } + + pub async fn managed_storage_summary(&self) -> Result { + let row = sqlx::query( + "SELECT COUNT(*) AS book_count, COALESCE(SUM(file_size), 0) AS total_bytes + FROM books WHERE storage_kind = 'managed'", + ) + .fetch_one(&self.pool) + .await + .context("failed to summarize managed books")?; + Ok(ManagedStorageSummary { + book_count: row.get::("book_count") as u64, + total_bytes: row.get::("total_bytes") as u64, + }) + } + + /// Move all private book copies to a new managed directory. + /// + /// New copies are fully staged before paths are updated transactionally. Old copies are only + /// removed after the database commit, so interruption may leave harmless duplicates but never + /// database rows pointing at incomplete files. + pub async fn relocate_managed_books(&self, new_dir: &Path) -> Result> { + let new_dir = new_dir.to_path_buf(); + let create_dir = new_dir.clone(); + tokio::task::spawn_blocking(move || std::fs::create_dir_all(&create_dir)) + .await + .context("managed library directory task failed")? + .with_context(|| format!("failed to create {}", new_dir.display()))?; + let new_dir = canonical_path(&new_dir); + + let rows = sqlx::query( + "SELECT id, file_path, content_hash FROM books + WHERE storage_kind = 'managed' ORDER BY id", + ) + .fetch_all(&self.pool) + .await + .context("failed to list managed books for relocation")?; + let mut changes = Vec::with_capacity(rows.len()); + let mut created_destinations = Vec::new(); + + for row in rows { + let book_id = row.get::("id"); + let old_path = PathBuf::from(row.get::("file_path")); + let expected_hash = row.get::, _>("content_hash"); + let extension = old_path + .extension() + .map(|extension| extension.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + let source = old_path.clone(); + let destination_dir = new_dir.clone(); + let relocation = tokio::task::spawn_blocking(move || -> Result<(PathBuf, bool)> { + let fingerprint = file_fingerprint(&source)?; + if expected_hash + .as_ref() + .is_some_and(|expected| expected != &fingerprint.hash) + { + bail!("managed book failed verification: {}", source.display()); + } + let destination = + destination_dir.join(format!("{}.{}", fingerprint.hash, extension)); + let existed = destination.exists(); + let staged = stage_managed_file(&source, &destination_dir)?; + publish_managed_file(&staged.path, &destination, &fingerprint.hash)?; + Ok((canonical_path(&destination), !existed)) + }) + .await + .context("managed book relocation task failed"); + let (new_path, created) = match relocation { + Ok(Ok(relocation)) => relocation, + Ok(Err(error)) | Err(error) => { + for path in created_destinations { + let _ = std::fs::remove_file(path); + } + return Err(error); + } + }; + if created { + created_destinations.push(new_path.clone()); + } + changes.push(ManagedPathChange { + book_id, + old_path, + new_path, + }); + } + + let database_result = async { + let mut transaction = self.pool.begin().await?; + for change in &changes { + let old_path = change.old_path.to_string_lossy(); + let new_path = change.new_path.to_string_lossy(); + sqlx::query("UPDATE books SET file_path = ? WHERE id = ?") + .bind(new_path.as_ref()) + .bind(change.book_id) + .execute(&mut *transaction) + .await + .context("failed to update managed book path")?; + reconcile_identity( + &mut transaction, + change.book_id, + old_path.as_ref(), + new_path.as_ref(), + ) + .await?; + } + sqlx::query( + "INSERT INTO preferences (key, value, updated_at) + VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at", + ) + .bind(MANAGED_LIBRARY_DIR_PREFERENCE) + .bind(new_dir.to_string_lossy().as_ref()) + .execute(&mut *transaction) + .await + .context("failed to save managed library location")?; + transaction + .commit() + .await + .context("failed to commit managed library relocation")?; + Ok::<(), anyhow::Error>(()) + } + .await; + if let Err(error) = database_result { + for path in created_destinations { + let _ = std::fs::remove_file(path); + } + return Err(error); + } + + for change in &changes { + if change.old_path != change.new_path + && let Err(error) = std::fs::remove_file(&change.old_path) + { + eprintln!( + "warning: failed to remove old managed book {}: {error}", + change.old_path.display() + ); + } + } + if self.managed_dir != new_dir { + let _ = std::fs::remove_dir(&self.managed_dir); + } + Ok(changes) + } + /// Import a single book file into the library. /// /// Extracts metadata and cover image from the file. If the file @@ -315,17 +505,69 @@ impl Library { .context("book not found after relink") } - /// Import all supported files from a directory (recursively). - pub async fn import_directory(&self, dir: &Path) -> Result> { - let mut books = Vec::new(); + /// Copy a list of book files into Shosai, continuing after individual failures. + pub async fn import_files(&self, paths: &[PathBuf]) -> ImportReport { + self.add_files(paths, true).await + } + + /// Link a list of book files in place, continuing after individual failures. + pub async fn link_files(&self, paths: &[PathBuf]) -> ImportReport { + self.add_files(paths, false).await + } + + async fn add_files(&self, paths: &[PathBuf], managed: bool) -> ImportReport { + let mut report = ImportReport::default(); + for path in paths { + let result = if managed { + self.import_managed_file(path).await + } else { + self.import_file(path).await + }; + report.record(path.clone(), result); + } + report + } + + /// Import all supported files from a directory recursively. + pub async fn import_directory(&self, dir: &Path) -> ImportReport { + self.add_directory(dir, true).await + } + + /// Add all supported files from a directory without copying them recursively. + pub async fn link_directory(&self, dir: &Path) -> ImportReport { + self.add_directory(dir, false).await + } + + async fn add_directory(&self, dir: &Path, managed: bool) -> ImportReport { + let mut report = ImportReport::default(); let mut dirs = vec![dir.to_path_buf()]; while let Some(current) = dirs.pop() { - let entries = std::fs::read_dir(¤t) - .with_context(|| format!("failed to read directory {}", current.display()))?; + let entries = match std::fs::read_dir(¤t) { + Ok(entries) => entries, + Err(error) => { + report.failures.push(ImportFailure { + path: current.clone(), + error: format!("failed to read directory {}: {error}", current.display()), + }); + continue; + } + }; for entry in entries { - let entry = entry?; + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + report.failures.push(ImportFailure { + path: current.clone(), + error: format!( + "failed to read an entry in {}: {error}", + current.display() + ), + }); + continue; + } + }; let path = entry.path(); if path.is_dir() { @@ -339,17 +581,17 @@ impl Library { .unwrap_or_default(); if BookFormat::from_extension(&ext).is_some() { - match self.import_managed_file(&path).await { - Ok(book) => books.push(book), - Err(e) => { - eprintln!("warning: failed to import {}: {e}", path.display()); - } - } + let result = if managed { + self.import_managed_file(&path).await + } else { + self.import_file(&path).await + }; + report.record(path, result); } } } - Ok(books) + report } /// List all books, ordered by most recently read first, then by date added. @@ -888,7 +1130,9 @@ fn stage_managed_file(source: &Path, managed_dir: &Path) -> Result .create_new(true) .open(&path) .context("failed to create managed book staging file")?; - let result = std::io::copy(&mut input, &mut output).and_then(|_| output.flush()); + let result = std::io::copy(&mut input, &mut output) + .and_then(|_| output.flush()) + .and_then(|_| output.sync_all()); if let Err(error) = result { let _ = std::fs::remove_file(&path); return Err(error).context("failed to stage managed book"); @@ -897,6 +1141,13 @@ fn stage_managed_file(source: &Path, managed_dir: &Path) -> Result } fn publish_managed_file(stage: &Path, destination: &Path, expected_hash: &str) -> Result<()> { + if file_fingerprint(stage)?.hash != expected_hash { + bail!( + "managed book staging verification failed: {}", + stage.display() + ); + } + if destination.exists() && file_fingerprint(destination)?.hash == expected_hash { std::fs::remove_file(stage).context("failed to discard duplicate managed book")?; return Ok(()); @@ -912,8 +1163,10 @@ fn publish_managed_file(stage: &Path, destination: &Path, expected_hash: &str) - match std::fs::rename(stage, destination) { Ok(()) => { + sync_parent_directory(destination)?; if let Some(quarantine) = quarantine { let _ = std::fs::remove_file(quarantine); + sync_parent_directory(destination)?; } Ok(()) } @@ -939,6 +1192,19 @@ fn publish_managed_file(stage: &Path, destination: &Path, expected_hash: &str) - } } +#[cfg(unix)] +fn sync_parent_directory(path: &Path) -> Result<()> { + let parent = path.parent().unwrap_or(Path::new(".")); + std::fs::File::open(parent) + .and_then(|directory| directory.sync_all()) + .with_context(|| format!("failed to sync managed book directory {}", parent.display())) +} + +#[cfg(not(unix))] +fn sync_parent_directory(_path: &Path) -> Result<()> { + Ok(()) +} + pub(crate) async fn backfill_missing_fingerprints(pool: &SqlitePool) -> Result<()> { let rows = sqlx::query("SELECT id, file_path FROM books WHERE content_hash IS NULL") .fetch_all(pool) @@ -1066,3 +1332,29 @@ fn encode_cover_png(width: u32, height: u32, rgba: &[u8]) -> Option> { .ok()?; Some(buf) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn publishing_rejects_a_stage_that_does_not_match_its_expected_hash() { + let directory = tempfile::tempdir().unwrap(); + let expected = directory.path().join("expected.epub"); + let stage = directory.path().join("stage.tmp"); + let destination = directory.path().join("managed.epub"); + std::fs::write(&expected, b"expected bytes").unwrap(); + std::fs::write(&stage, b"corrupt bytes").unwrap(); + std::fs::write(&destination, b"existing destination").unwrap(); + let expected_hash = file_fingerprint(&expected).unwrap().hash; + + let result = publish_managed_file(&stage, &destination, &expected_hash); + + assert!(result.is_err()); + assert_eq!( + std::fs::read(&destination).unwrap(), + b"existing destination" + ); + assert!(stage.exists()); + } +} diff --git a/crates/shosai-core/tests/library_tests.rs b/crates/shosai-core/tests/library_tests.rs index 6d1377b9..0693046d 100644 --- a/crates/shosai-core/tests/library_tests.rs +++ b/crates/shosai-core/tests/library_tests.rs @@ -1,5 +1,5 @@ use shosai_core::bookmarks::BookmarkStore; -use shosai_core::library::{BookFormat, Library, StorageKind}; +use shosai_core::library::{BookFormat, Library, MANAGED_LIBRARY_DIR_PREFERENCE, StorageKind}; use shosai_core::reading_state::{FileReadingState, ReadingStateStore}; use std::path::PathBuf; use tempfile::TempDir; @@ -226,8 +226,76 @@ async fn test_import_directory() { // Also a non-book file that should be skipped. std::fs::write(import_dir.join("notes.txt"), "some notes").unwrap(); - let books = lib.import_directory(&import_dir).await.unwrap(); - assert_eq!(books.len(), 2); + let report = lib.import_directory(&import_dir).await; + assert_eq!(report.books.len(), 2); + assert!(report.failures.is_empty()); +} + +#[tokio::test] +async fn directory_import_reports_when_every_supported_file_fails() { + let (lib, _, dir) = temp_library().await; + let import_dir = dir.path().join("imports"); + std::fs::create_dir_all(&import_dir).unwrap(); + std::fs::write(import_dir.join("corrupt.epub"), b"not an epub").unwrap(); + + let report = lib.import_directory(&import_dir).await; + + assert!(report.books.is_empty()); + assert_eq!(report.failures.len(), 1); + assert_eq!(report.failures[0].path, import_dir.join("corrupt.epub")); + assert!(lib.list_all().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn directory_import_keeps_successes_and_reports_other_failures() { + let (lib, _, dir) = temp_library().await; + let import_dir = dir.path().join("imports"); + std::fs::create_dir_all(&import_dir).unwrap(); + std::fs::copy(fixture_path("sample.epub"), import_dir.join("valid.epub")).unwrap(); + std::fs::write(import_dir.join("corrupt.epub"), b"not an epub").unwrap(); + + let report = lib.import_directory(&import_dir).await; + + assert_eq!(report.books.len(), 1); + assert_eq!(report.failures.len(), 1); + assert_eq!(lib.list_all().await.unwrap().len(), 1); +} + +#[tokio::test] +async fn file_batch_continues_after_a_failure_and_reports_it() { + let (lib, _, dir) = temp_library().await; + let first = dir.path().join("first.pdf"); + let corrupt = dir.path().join("corrupt.epub"); + let last = dir.path().join("last.cbz"); + std::fs::copy(fixture_path("sample.pdf"), &first).unwrap(); + std::fs::write(&corrupt, b"not an epub").unwrap(); + std::fs::copy(fixture_path("sample.cbz"), &last).unwrap(); + + let report = lib.import_files(&[first, corrupt.clone(), last]).await; + + assert_eq!(report.books.len(), 2); + assert_eq!(report.failures.len(), 1); + assert_eq!(report.failures[0].path, corrupt); + assert_eq!(lib.list_all().await.unwrap().len(), 2); +} + +#[tokio::test] +async fn linked_directory_keeps_books_in_their_original_locations() { + let (lib, _, dir) = temp_library().await; + let import_dir = dir.path().join("imports"); + std::fs::create_dir_all(&import_dir).unwrap(); + let source = import_dir.join("book.epub"); + std::fs::copy(fixture_path("sample.epub"), &source).unwrap(); + + let report = lib.link_directory(&import_dir).await; + + assert_eq!(report.books.len(), 1); + assert!(report.failures.is_empty()); + assert_eq!(report.books[0].storage_kind, StorageKind::Referenced); + assert_eq!( + PathBuf::from(&report.books[0].file_path), + source.canonicalize().unwrap() + ); } #[tokio::test] @@ -248,6 +316,59 @@ async fn managed_import_survives_the_source_being_removed() { assert!(book.content_hash.is_some()); } +#[tokio::test] +async fn relocating_managed_books_preserves_identity_state_and_bookmarks() { + let (lib, store, dir) = temp_library().await; + let source = dir.path().join("source.epub"); + std::fs::copy(fixture_path("sample.epub"), &source).unwrap(); + let book = lib.import_managed_file(&source).await.unwrap(); + let old_path = PathBuf::from(&book.file_path); + store + .set_for_book_async( + book.id, + &old_path, + &FileReadingState { + page: 3, + location_offset: Some(42), + zoom: 1.0, + }, + ) + .await + .unwrap(); + let bookmarks = BookmarkStore::new(store.pool().clone()); + bookmarks + .toggle_for_book_at_async(book.id, &old_path, 2, Some(7), None) + .await + .unwrap(); + let destination = dir.path().join("external").join("Shosai"); + + let changes = lib.relocate_managed_books(&destination).await.unwrap(); + + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].book_id, book.id); + assert!(!old_path.exists()); + assert!(changes[0].new_path.exists()); + let relocated = lib.get(book.id).await.unwrap().unwrap(); + assert_eq!(PathBuf::from(relocated.file_path), changes[0].new_path); + assert_eq!(store.get_for_book_async(book.id).await.unwrap().page, 3); + let bookmark = bookmarks + .list_for_book_async(book.id) + .await + .unwrap() + .remove(0); + assert_eq!(PathBuf::from(bookmark.file_path), changes[0].new_path); + assert_eq!( + store.get_pref_async(MANAGED_LIBRARY_DIR_PREFERENCE).await, + Some( + destination + .canonicalize() + .unwrap() + .to_string_lossy() + .into_owned() + ) + ); +} + #[tokio::test] async fn managed_import_uses_the_source_filename_for_fallback_metadata() { let (lib, _, dir) = temp_library().await; diff --git a/docs/typography.md b/docs/typography.md new file mode 100644 index 00000000..125a3786 --- /dev/null +++ b/docs/typography.md @@ -0,0 +1,41 @@ +# Typography + +Shōsai uses a small, deliberate type system shared by the desktop application and website. Do not select fonts ad hoc or rely on whichever system fallback happens to be installed. + +## Families and roles + +| Role | Latin | Japanese | Use | +| --- | --- | --- | --- | +| Interface sans | Inter | Noto Sans JP | Application chrome, controls, settings, navigation, labels, metadata, and website body copy | +| Editorial serif | Source Serif 4 | Noto Serif JP | Brand wordmark, marketing headings, and other intentionally editorial surfaces | +| Book content | Document font or reader preference | Document font or reader preference | EPUB content only; PDF pages retain their embedded appearance | + +The interface and editorial roles are distinct. Do not use the serif pair for ordinary controls, and do not force application fonts onto book content. + +## Application rules + +- Bundle every font required by a supported interface language. Supported UI must not depend on system font discovery or fallback. +- Use Inter for English UI and Noto Sans JP for Japanese UI. Mixed-script and user-provided metadata must select a bundled face that covers the displayed text. +- Use the named typography constants rather than `Font::DEFAULT` or literal family names. +- Use real available weights. The normal UI range is 400–700; do not synthesize bold or italic faces. +- A language change must update immediately. Treat a transition above 100 ms on a release build as a performance regression. +- Reader typography is a separate concern. EPUB embedded fonts and reader font preferences take precedence over UI fonts. + +## Website rules + +- Self-host the same families; do not depend on visitors having them installed. +- Use `--font-ui` and `--font-editorial` rather than literal stacks in individual selectors. +- Keep font files under `assets/fonts` as the source of truth and expose them to Hugo through its static mount. +- Subsetting is allowed for a static, known character set, but the subset, source, generation method, and covered characters must be documented. Regenerate it when relevant content changes. +- Preserve `font-display: swap` and avoid loading Japanese fonts on pages that do not render Japanese glyphs. + +## Adding or upgrading a font + +1. Confirm that its license permits application embedding and web self-hosting. +2. Add the upstream license and record the source revision or release, URL, and SHA-256 checksum in `assets/fonts/README.md`. +3. Prefer upstream variable TTF files for the native app. Use WOFF2 or a documented subset for web delivery when the full font would materially increase page weight. +4. Verify family names and required glyph coverage from the actual binary. +5. Review English, Japanese, and mixed-script text at normal, compact, and high-DPI layouts. +6. Run the application tests, website build, and a language-switch latency check. + +Font changes are design-system changes. They require review of both products even when only one currently uses the affected role. diff --git a/website/README.md b/website/README.md index 737a6f98..708df5a2 100644 --- a/website/README.md +++ b/website/README.md @@ -26,9 +26,9 @@ Create a Pages project connected to this GitHub repository with these settings: | --- | --- | | Production branch | `main` | | Framework preset | `Hugo` | -| Build command | `hugo --minify` | -| Build output directory | `public` | -| Root directory | `website` | +| Build command | `hugo --source website --minify` | +| Build output directory | `website/public` | +| Root directory | *(repository root)* | | Node.js version | Not required | Cloudflare Pages provides Hugo when using the Hugo framework preset. Once the custom domain is known, update `baseURL` in `hugo.toml` to its canonical `https://` URL. @@ -39,7 +39,7 @@ To avoid deploying the site for application-only changes, go to **Settings** > * | Setting | Value | | --- | --- | -| Include paths | `website/*` | +| Include paths | `website/*`, `assets/fonts/*` | | Exclude paths | *(empty)* | -Cloudflare Pages then creates a deployment only when a changed path is in `website/`. Root-level files are intentionally excluded; include them here if the website later depends on one. +The website mounts the shared font assets from `assets/fonts`, so changes to either the Hugo source or bundled fonts must trigger a deployment. diff --git a/website/hugo.toml b/website/hugo.toml index 59fadf04..1e2f4252 100644 --- a/website/hugo.toml +++ b/website/hugo.toml @@ -5,3 +5,11 @@ disableKinds = ["taxonomy", "term"] [params] description = "A focused, native desktop reader for PDF, EPUB, and CBZ files." + +[module] + [[module.mounts]] + source = "static" + target = "static" + [[module.mounts]] + source = "../assets/fonts" + target = "static/fonts" diff --git a/website/static/css/site.css b/website/static/css/site.css index 7a74065e..94f5bbd4 100644 --- a/website/static/css/site.css +++ b/website/static/css/site.css @@ -1,3 +1,36 @@ +@font-face { + font-family: "Inter Variable"; + src: url("/fonts/InterVariable.ttf") format("truetype"); + font-style: normal; + font-weight: 100 900; + font-display: swap; +} + +@font-face { + font-family: "Noto Sans JP"; + src: url("/fonts/NotoSansJP-Variable.ttf") format("truetype"); + font-style: normal; + font-weight: 100 900; + font-display: swap; +} + +@font-face { + font-family: "Source Serif 4 Variable"; + src: url("/fonts/SourceSerif4Variable-Roman.ttf") format("truetype"); + font-style: normal; + font-weight: 200 900; + font-display: swap; +} + +@font-face { + font-family: "Noto Serif JP"; + src: url("/fonts/NotoSerifJP-ShosaiMark-Regular.ttf") format("truetype"); + font-style: normal; + font-weight: 400; + font-display: swap; + unicode-range: U+66F8; +} + :root { --ink: #20251f; --muted: #6c7068; @@ -5,7 +38,9 @@ --paper-deep: #e9e4d8; --accent: #275a47; --line: #d4d0c5; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-ui: "Inter Variable", "Noto Sans JP", sans-serif; + --font-editorial: "Source Serif 4 Variable", "Noto Serif JP", serif; + font-family: var(--font-ui); color: var(--ink); background: var(--paper); } @@ -17,8 +52,8 @@ a { color: inherit; text-decoration: none; } .site-header, main, footer { max-width: 1200px; margin: 0 auto; padding-left: 32px; padding-right: 32px; } .site-header { display: flex; justify-content: space-between; align-items: center; min-height: 88px; border-bottom: 1px solid var(--line); } -.wordmark { display: inline-flex; align-items: center; gap: 9px; font-family: Georgia, serif; font-size: 1.3rem; font-weight: bold; } -.mark, .preview-mark { display: grid; place-items: center; width: 30px; height: 30px; background: var(--accent); color: #f9f8f1; border-radius: 50%; font-family: Georgia, serif; font-size: 1rem; } +.wordmark { display: inline-flex; align-items: center; gap: 9px; font-family: var(--font-editorial); font-size: 1.3rem; font-weight: bold; } +.mark, .preview-mark { display: grid; place-items: center; width: 30px; height: 30px; background: var(--accent); color: #f9f8f1; border-radius: 50%; font-family: var(--font-editorial); font-size: 1rem; } nav { display: flex; align-items: center; gap: 26px; font-size: .9rem; } nav a:not(.nav-download) { color: var(--muted); } .nav-download { padding: 10px 14px; border: 1px solid var(--ink); border-radius: 3px; } @@ -26,7 +61,7 @@ nav a:not(.nav-download) { color: var(--muted); } .hero { padding: 118px 0 92px; max-width: 760px; } .eyebrow { margin: 0 0 18px; color: var(--accent); font-size: .72rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; } h1, h2, h3, p { margin-top: 0; } -h1, h2 { font-family: Georgia, "Times New Roman", serif; font-weight: 400; letter-spacing: -.045em; } +h1, h2 { font-family: var(--font-editorial); font-weight: 400; letter-spacing: -.045em; } h1 { margin-bottom: 24px; font-size: clamp(3.7rem, 8vw, 6.7rem); line-height: .93; } .hero-copy { max-width: 590px; color: #535950; font-size: 1.16rem; line-height: 1.65; } .hero-actions { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 34px; } @@ -37,13 +72,13 @@ h1 { margin-bottom: 24px; font-size: clamp(3.7rem, 8vw, 6.7rem); line-height: .9 .reader-preview { overflow: hidden; border: 1px solid #bac1b3; border-radius: 6px; box-shadow: 0 24px 50px rgba(38, 46, 35, .13); background: #f2f1eb; } .preview-toolbar { display: flex; justify-content: space-between; align-items: center; height: 50px; padding: 0 18px; border-bottom: 1px solid #d2d5cb; color: #555d54; font-size: .76rem; } -.preview-brand { display: flex; align-items: center; gap: 7px; color: #293a2f; font-family: Georgia, serif; font-size: .9rem; font-weight: bold; }.preview-mark { width: 21px; height: 21px; font-size: .7rem; } +.preview-brand { display: flex; align-items: center; gap: 7px; color: #293a2f; font-family: var(--font-editorial); font-size: .9rem; font-weight: bold; }.preview-mark { width: 21px; height: 21px; font-size: .7rem; } .preview-content { display: grid; grid-template-columns: 155px minmax(360px, 1fr) 155px; min-height: 430px; } .preview-library, .preview-notes { padding: 24px 16px; background: #e5e8df; color: #697168; font-size: .7rem; }.preview-library { border-right: 1px solid #d2d5cb; }.preview-notes { display: flex; flex-direction: column; gap: 11px; border-left: 1px solid #d2d5cb; }.preview-notes strong { color: var(--accent); font-size: 1.3rem; }.preview-notes hr { width: 100%; border: 0; border-top: 1px solid #ced3c8; } .library-label { display: block; margin-bottom: 20px; font-weight: 700; }.book { display: block; width: 55px; height: 77px; margin: 0 auto 13px; border-radius: 2px; background: #759174; box-shadow: 3px 3px 4px #0001; }.book.warm { background: #bd8569; }.book.dark { background: #3b5043; }.book.selected { outline: 3px solid #486b53; outline-offset: 3px; } -.preview-page { max-width: 560px; margin: 0 auto; padding: 66px 14% 30px; background: #fbfaf6; font-family: Georgia, serif; color: #30362d; }.preview-page .chapter { color: #6d836f; font-family: Inter, sans-serif; font-size: .68rem; font-weight: bold; letter-spacing: .12em; text-transform: uppercase; }.preview-page h2 { margin-bottom: 27px; font-size: 2rem; line-height: 1.05; }.preview-page p:not(.chapter) { font-size: .95rem; line-height: 1.9; }.page-progress { height: 3px; margin-top: 34px; background: #e1e1d8; }.page-progress span { display: block; width: 30%; height: 100%; background: #557860; } +.preview-page { max-width: 560px; margin: 0 auto; padding: 66px 14% 30px; background: #fbfaf6; font-family: var(--font-editorial); color: #30362d; }.preview-page .chapter { color: #6d836f; font-family: var(--font-ui); font-size: .68rem; font-weight: bold; letter-spacing: .12em; text-transform: uppercase; }.preview-page h2 { margin-bottom: 27px; font-size: 2rem; line-height: 1.05; }.preview-page p:not(.chapter) { font-size: .95rem; line-height: 1.9; }.page-progress { height: 3px; margin-top: 34px; background: #e1e1d8; }.page-progress span { display: block; width: 30%; height: 100%; background: #557860; } -.features { padding: 145px 0; }.section-intro { max-width: 640px; }.section-intro h2, .formats h2, .open-source h2 { font-size: clamp(2.6rem, 5vw, 4.3rem); line-height: .98; }.feature-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 48px; margin-top: 70px; }.feature-grid article { border-top: 1px solid var(--ink); padding-top: 18px; }.feature-number { color: var(--accent); font-size: .75rem; font-weight: bold; }.feature-grid h3 { margin: 36px 0 12px; font-family: Georgia, serif; font-size: 1.65rem; font-weight: 400; }.feature-grid p, .formats > p, .open-source p { color: var(--muted); font-size: .96rem; line-height: 1.7; } +.features { padding: 145px 0; }.section-intro { max-width: 640px; }.section-intro h2, .formats h2, .open-source h2 { font-size: clamp(2.6rem, 5vw, 4.3rem); line-height: .98; }.feature-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 48px; margin-top: 70px; }.feature-grid article { border-top: 1px solid var(--ink); padding-top: 18px; }.feature-number { color: var(--accent); font-size: .75rem; font-weight: bold; }.feature-grid h3 { margin: 36px 0 12px; font-family: var(--font-editorial); font-size: 1.65rem; font-weight: 400; }.feature-grid p, .formats > p, .open-source p { color: var(--muted); font-size: .96rem; line-height: 1.7; } .formats { padding: 100px 10%; text-align: center; background: var(--accent); color: #f7f4ed; }.formats .eyebrow { color: #cbd8c6; }.formats h2 { margin-bottom: 17px; }.formats > p { max-width: 540px; margin: 0 auto; color: #e1e8dc; }.format-list { display: flex; justify-content: center; gap: 10px; margin-top: 37px; }.format-list span { padding: 8px 12px; border: 1px solid #ffffff66; font-size: .75rem; font-weight: bold; letter-spacing: .06em; } .open-source { display: grid; grid-template-columns: 1fr 1fr; gap: 100px; padding: 145px 10%; background: var(--paper-deep); }.text-link { display: inline-block; margin-top: 16px; border-bottom: 1px solid var(--accent); color: var(--accent); font-weight: bold; } footer { display: flex; justify-content: space-between; align-items: center; min-height: 104px; color: var(--muted); font-size: .8rem; } footer > a:last-child { color: var(--ink); }