Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions Sources/HanReaderUI/Design/DesignTokens.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,6 @@ nonisolated enum ReaderMetrics {
static let detailHeadwordWidth = 110.0
/// Width of the library sidebar on a regular-width layout.
static let sidebarWidth = 260.0
/// The reading column stops widening here. Beyond roughly this width a
/// line holds too many characters to track back to the start of the next.
static let readingColumnMaxWidth = 720.0
/// Padding around the reading column.
static let readingColumnPadding = 24.0
/// Minimum hit target. Apple's guideline is 44pt, and a tapped word is
Expand Down
22 changes: 22 additions & 0 deletions Sources/HanReaderUI/Design/ReaderStyle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,19 @@ nonisolated struct ReaderStyle: Hashable, Sendable {
/// targets below the 44pt minimum.
static let compactFontSizeFloor = 18.0

/// How many characters a line may hold before the column stops widening.
///
/// Measured in **characters**, not points, because that is what line
/// length means for reading: 30 to 40 is the comfortable range for CJK,
/// and 40 is the top of it, so the column uses as much of a wide window
/// as it can without the eye losing the start of the next line.
///
/// A fixed point value cannot express this. The 720pt cap it replaces
/// gave 32 characters at 22pt — reasonable — and **15 characters at
/// 48pt**, which is a column two words wide for exactly the reader who
/// enlarged the text in order to read it more easily.
static let maximumCharactersPerLine = 40.0

// MARK: - Resolved values

let spacing: WordSpacing
Expand All @@ -104,6 +117,15 @@ nonisolated struct ReaderStyle: Hashable, Sendable {
let paragraphGap: Double
let blankLineHeight: Double

/// How wide the reading column may grow.
///
/// A Han glyph is one em wide — the square em is the point of the
/// script — so a character count converts to points by multiplying by
/// the font size.
var maximumColumnWidth: Double {
fontSize * Self.maximumCharactersPerLine
}

/// Whether a token's width grows to fit its reading.
///
/// Only in `.separated` mode. Reserving the width in `.continuous` mode
Expand Down
33 changes: 26 additions & 7 deletions Sources/HanReaderUI/Design/TokenEmphasis.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,32 @@ nonisolated struct TokenHighlight: Hashable, Sendable {
/// word identically, which is precisely why tapping a second instance of
/// an already-revealed word felt like a bug — nothing on screen changed.
/// - **A dotted underline** carries reveal state when
/// `differentiateWithoutColor` is on. An 8%-opacity accent fill is the
/// *only* signal the prototype had, and it is invisible to a reader who
/// `differentiateWithoutColor` is on. A tinted fill was the *only*
/// signal the prototype had, and a tint is invisible to a reader who
/// has asked the system not to rely on colour.
///
/// Increased contrast roughly doubles both fills. A 20% tint over a page
/// background is below the 3:1 non-text contrast ratio for a reader who
/// has asked for more.
/// ### The numbers, and why they are not theme-dependent
///
/// Measured as WCAG contrast ratios between the composited fill and the
/// background, for the system accent over white and over the macOS dark
/// window background:
///
/// | fill | on light | on dark |
/// |---|---|---|
/// | 0.08 — what shipped | 1.11 | 1.08 |
/// | 0.14 — revealed | 1.20 | 1.16 |
/// | 0.30 — selected | 1.49 | 1.43 |
///
/// The shipped 0.08 was too faint to read as a marker at all, which is
/// the defect. It is *not* a dark-mode defect: the two columns differ by
/// about 0.03, so one pair of values serves both themes and a
/// `colorScheme` parameter would be ceremony around a rounding error.
///
/// Revealed stays deliberately quiet. The reading printed above the word
/// is the primary signal that it is revealed; the fill only has to make
/// the set scannable, and a page where a reader has revealed two hundred
/// words should not turn into a wall of colour. Selection is the state
/// that must be unmistakable, and it also carries a border.
static func resolve(
_ emphasis: TokenEmphasis,
increasedContrast: Bool = false,
Expand All @@ -64,8 +83,8 @@ nonisolated struct TokenHighlight: Hashable, Sendable {
{
let fill: Double = switch emphasis {
case .plain: 0
case .revealed: increasedContrast ? 0.18 : 0.08
case .selected: increasedContrast ? 0.32 : 0.20
case .revealed: increasedContrast ? 0.24 : 0.14
case .selected: increasedContrast ? 0.44 : 0.30
}
return Self(
fillOpacity: fill,
Expand Down
17 changes: 16 additions & 1 deletion Sources/HanReaderUI/HanReaderRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ private struct LibraryAndReader: View {
@State private var importer: TextImporter
@State private var isFileImporterPresented = false
@State private var duplicateNotice = false
/// Restoring happens once per launch, not on every reload. A reload
/// follows every import and delete, and re-selecting there would fight
/// whatever the reader had just chosen.
@State private var hasRestoredLastText = false

/// Whichever failure is outstanding.
///
Expand Down Expand Up @@ -153,7 +157,18 @@ private struct LibraryAndReader: View {
)
}
}
.task { await library.load() }
.task {
await library.load()
// The library already recorded which text was open; it just was
// not read back, so every launch asked the reader to choose a
// text again with one text in the list.
if !hasRestoredLastText {
hasRestoredLastText = true
if selection == nil {
selection = library.mostRecentlyOpened?.id
}
}
}
// `.fileImporter` is identical on both platforms and handles the
// security-scoped URL itself, which is why there is no `NSOpenPanel`
// anywhere in this project and no platform branch here.
Expand Down
12 changes: 12 additions & 0 deletions Sources/HanReaderUI/Models/LibraryModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ final class LibraryModel {
self.services = services
}

/// The text the reader was last in, if there is one.
///
/// Only a text that has actually been opened counts. Falling back to the
/// most recently *imported* one would mean a fresh library opens a
/// document the reader has never seen, which is a guess rather than a
/// restore.
var mostRecentlyOpened: LibraryListItem? {
items
.filter { $0.lastOpenedAt != nil }
.max { ($0.lastOpenedAt ?? .distantPast) < ($1.lastOpenedAt ?? .distantPast) }
}

func load() async {
loadGeneration += 1
let generation = loadGeneration
Expand Down
2 changes: 1 addition & 1 deletion Sources/HanReaderUI/Reader/ReaderSurface.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ struct ReaderSurface: View {
}
}
.scrollTargetLayout()
.frame(maxWidth: ReaderMetrics.readingColumnMaxWidth, alignment: .leading)
.frame(maxWidth: style.maximumColumnWidth, alignment: .leading)
.padding(.horizontal, ReaderMetrics.readingColumnPadding)
// Room above the first line for a revealed reading on it.
//
Expand Down
42 changes: 42 additions & 0 deletions Tests/HanReaderUITests/LibraryModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,48 @@ struct LibraryModelTests {
#expect(item.characterCount == 4000)
}

// MARK: - Reopening

/// The library recorded which text was open and nothing read it back, so
/// every launch asked the reader to choose a text with one text in the
/// list.
@Test("The text last read is the one to reopen")
func restoresTheLastRead() async throws {
let services = try AppServices.inMemory()
let model = LibraryModel(services: services)
guard case let .imported(first) = await model.importText(title: "A", content: "一"),
case let .imported(second) = await model.importText(title: "B", content: "二")
else {
Issue.record("import failed")
return
}

// Opened out of import order, so this cannot pass by accident: the
// newer import is the older read.
try await services.library.markOpened(second, at: .now.addingTimeInterval(-60))
try await services.library.markOpened(first)
await model.load()

#expect(model.mostRecentlyOpened?.id == first)
}

/// A fresh library opens nothing. Falling back to the newest *import*
/// would open a document the reader has never seen, which is a guess
/// rather than a restore.
@Test("A library nothing has been read from reopens nothing")
func nothingToRestore() async throws {
let model = try makeModel()
_ = await model.importText(title: "A", content: "一")
#expect(model.mostRecentlyOpened == nil)
}

@Test("An empty library reopens nothing")
func emptyRestoresNothing() async throws {
let model = try makeModel()
await model.load()
#expect(model.mostRecentlyOpened == nil)
}

// MARK: - Titles

@Test("A title comes from the file name", arguments: [
Expand Down
21 changes: 21 additions & 0 deletions Tests/HanReaderUITests/ReaderStyleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,27 @@ struct ReaderStyleTests {
#expect(!ReaderStyle(fontSize: 22, spacing: .continuous).reservesRubyWidth)
}

/// The regression a fixed point cap caused, as a test. 720 points gave
/// 32 characters a line at 22pt and **15 at 48pt** — a column two words
/// wide for exactly the reader who enlarged the text to read it.
@Test("The column holds the same number of characters at every size")
func columnMeasuredInCharacters() {
for size in [14.0, 22.0, 36.0, 48.0] {
let style = ReaderStyle(fontSize: size)
let characters = style.maximumColumnWidth / style.fontSize
#expect(abs(characters - ReaderStyle.maximumCharactersPerLine) < 1e-9)
}
}

@Test("A larger reading size gets a wider column, not a narrower one")
func columnGrowsWithText() {
let small = ReaderStyle(fontSize: 14).maximumColumnWidth
let large = ReaderStyle(fontSize: 48).maximumColumnWidth
#expect(large > small)
// And the cap stays inside the range that is comfortable for CJK.
#expect((30.0 ... 40.0).contains(ReaderStyle.maximumCharactersPerLine))
}

@Test("The ruby band is tall enough for the glyphs it holds")
func rubyReservationExceedsRubyFontSize() {
// Pinyin carries diacritics above and descenders below, so a
Expand Down
20 changes: 20 additions & 0 deletions Tests/HanReaderUITests/TokenHighlightTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,26 @@ struct TokenHighlightTests {
#expect(increased.fillOpacity > normal.fillOpacity)
}

/// The fill shipped at 0.08, which composites to a 1.11:1 contrast ratio
/// against white and 1.08:1 against the macOS dark background — too
/// faint to read as a marker. The floor here stops it drifting back
/// without anyone noticing, since nothing else in the suite would fail.
@Test("A revealed word is actually visible")
func revealedIsVisible() {
#expect(TokenHighlight.resolve(.revealed).fillOpacity >= 0.12)
#expect(TokenHighlight.resolve(.selected).fillOpacity >= 0.25)
}

/// Quiet, though. The reading printed above the word is the primary
/// signal; a page with two hundred revealed words should not become a
/// wall of colour.
@Test("A revealed word stays quieter than the selected one")
func revealedStaysQuiet() {
let revealed = TokenHighlight.resolve(.revealed)
#expect(revealed.fillOpacity <= 0.20)
#expect(TokenHighlight.resolve(.selected).fillOpacity > revealed.fillOpacity * 1.5)
}

/// Reveal state must survive a reader who has asked the system not to
/// rely on colour. An 8%-opacity accent fill was the prototype's only
/// signal, and it is invisible to exactly that reader.
Expand Down
Loading