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
2 changes: 1 addition & 1 deletion .agents/skills/repo-conventions/SKILL.md

Large diffs are not rendered by default.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ nonisolated struct MangaPageTranslation: Codable, Hashable, Sendable {
// language, which keeps their rendering horizontal.
targetLanguage = try container.decodeIfPresent(String.self, forKey: .targetLanguage) ?? ""
// Rows cached before the field existed fall back to text-tight geometry:
// its outgrown ellipse keeps corner text covered for either box kind.
// the rounded rectangle hugs the box for either kind, and the small-inset
// text area still lands inside the bubble.
boxKind = try container.decodeIfPresent(MangaTextBoxKind.self, forKey: .boxKind) ?? .text
createdAt = try container.decode(Date.self, forKey: .createdAt)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,21 @@ nonisolated struct MangaTranslationPreferences: Codable, Equatable, RawRepresent
var includeMetadata: Bool
var showTranslation: Bool
var textDirection: MangaTranslationTextDirection
var bubbleShape: MangaBubbleShape

init(
selectedProviderID: String = "",
sourceLanguage: String = MangaTranslationLanguage.defaultSource,
targetLanguage: String = "",
includeMetadata: Bool = false,
showTranslation: Bool = true,
textDirection: MangaTranslationTextDirection = .auto,
bubbleShape: MangaBubbleShape = .ellipse
textDirection: MangaTranslationTextDirection = .auto
) {
self.selectedProviderID = selectedProviderID
self.sourceLanguage = sourceLanguage
self.targetLanguage = targetLanguage
self.includeMetadata = includeMetadata
self.showTranslation = showTranslation
self.textDirection = textDirection
self.bubbleShape = bubbleShape
}

init?(rawValue: String) {
Expand All @@ -48,8 +45,7 @@ nonisolated struct MangaTranslationPreferences: Codable, Equatable, RawRepresent
includeMetadata: try container.decodeIfPresent(Bool.self, forKey: .includeMetadata) ?? false,
showTranslation: try container.decodeIfPresent(Bool.self, forKey: .showTranslation) ?? true,
textDirection: try container.decodeIfPresent(
MangaTranslationTextDirection.self, forKey: .textDirection) ?? .auto,
bubbleShape: try container.decodeIfPresent(MangaBubbleShape.self, forKey: .bubbleShape) ?? .ellipse
MangaTranslationTextDirection.self, forKey: .textDirection) ?? .auto
)
}

Expand All @@ -61,7 +57,6 @@ nonisolated struct MangaTranslationPreferences: Codable, Equatable, RawRepresent
try container.encode(includeMetadata, forKey: .includeMetadata)
try container.encode(showTranslation, forKey: .showTranslation)
try container.encode(textDirection, forKey: .textDirection)
try container.encode(bubbleShape, forKey: .bubbleShape)
}

nonisolated var rawValue: String {
Expand All @@ -78,6 +73,5 @@ nonisolated struct MangaTranslationPreferences: Codable, Equatable, RawRepresent
case includeMetadata
case showTranslation
case textDirection
case bubbleShape
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,33 +19,30 @@ enum MangaBubbleBackground {
min(32, max(12, min(size.width, size.height) * 0.4))
}

/// Text-tight boxes wrap the glyphs, so the ellipse must outgrow the box to
/// still cover its corners (√2 to touch them); whole-bubble boxes already
/// bound the bubble, and the rounded rectangle hugs the box for either kind.
static func backgroundFrame(
forBoxSize size: CGSize, boxKind: MangaTextBoxKind, shape: MangaBubbleShape
) -> CGRect {
let coreScale: CGFloat
switch (shape, boxKind) {
case (.ellipse, .text): coreScale = 1.5
case (.ellipse, .bubble), (.roundedRect, _): coreScale = 1
}
/// Text-tight boxes wrap the glyphs, so the blob must grow past them by the
/// feather; whole-bubble boxes bound the bubble itself, so the blob and its
/// feather stay inside the box — the bubble's own border line then survives
/// instead of being painted over and blurred beyond it.
static func backgroundFrame(forBoxSize size: CGSize, boxKind: MangaTextBoxKind) -> CGRect {
let feather = feather(forBoxSize: size)
let coreWidth = size.width * coreScale
let coreHeight = size.height * coreScale
return CGRect(
x: (size.width - coreWidth) / 2 - feather,
y: (size.height - coreHeight) / 2 - feather,
width: coreWidth + feather * 2,
height: coreHeight + feather * 2
)
switch boxKind {
case .text:
return CGRect(
x: -feather,
y: -feather,
width: size.width + feather * 2,
height: size.height + feather * 2
)
case .bubble:
return CGRect(x: 0, y: 0, width: size.width, height: size.height)
}
}

static func textFrame(forBoxSize size: CGSize, boxKind: MangaTextBoxKind, shape: MangaBubbleShape) -> CGRect {
if shape == .ellipse && boxKind == .bubble {
// the inscribed rect (1/√2) touches the ellipse rim; whole-bubble boxes
// already include the bubble's padding, so the text area shrinks further
// to keep glyphs on the opaque core with a manga-like white margin
/// Whole-bubble boxes already include the bubble's own padding, so the text
/// area shrinks to a centered fraction of the box; text-tight boxes wrap the
/// glyphs and only need a small inset.
static func textFrame(forBoxSize size: CGSize, boxKind: MangaTextBoxKind) -> CGRect {
if boxKind == .bubble {
let scale: CGFloat = 0.5
return CGRect(
x: size.width * (1 - scale) / 2,
Expand All @@ -60,10 +57,13 @@ enum MangaBubbleBackground {
)
}

static func blobImage(size: CGSize, feather: CGFloat, shape: MangaBubbleShape, color: CGColor) -> CGImage? {
static func blobImage(size: CGSize, feather: CGFloat, color: CGColor) -> CGImage? {
// The opaque core must survive even on tiny boxes, so the feather can
// never eat half the box.
let feather = min(feather, (min(size.width, size.height) - 1) / 2)
let width = Int(size.width.rounded())
let height = Int(size.height.rounded())
guard width > 0, height > 0, feather * 2 < size.width, feather * 2 < size.height else { return nil }
guard width > 0, height > 0, feather > 0 else { return nil }
guard
let context = CGContext(
data: nil,
Expand All @@ -82,15 +82,10 @@ enum MangaBubbleBackground {
height: size.height - feather * 2
)
context.setFillColor(color)
switch shape {
case .ellipse:
context.addPath(CGPath(ellipseIn: coreRect, transform: nil))
case .roundedRect:
let radius = cornerRadius(forBoxSize: coreRect.size)
context.addPath(
CGPath(roundedRect: coreRect, cornerWidth: radius, cornerHeight: radius, transform: nil)
)
}
let radius = cornerRadius(forBoxSize: coreRect.size)
context.addPath(
CGPath(roundedRect: coreRect, cornerWidth: radius, cornerHeight: radius, transform: nil)
)
context.fillPath()
guard let solid = context.makeImage() else { return nil }
// sigma ≈ feather/2.2 keeps the fade inside the feather band (α < 2% at the edge)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,10 @@ import Foundation
var feather: CGFloat = 10 {
didSet { if feather != oldValue { setNeedsLayout() } }
}
var shape: MangaBubbleShape = .ellipse {
didSet { if shape != oldValue { setNeedsLayout() } }
}

private var renderedSize: CGSize?
private var renderedColor: UIColor?
private var renderedFeather: CGFloat = 0
private var renderedShape: MangaBubbleShape?

override init(frame: CGRect) {
super.init(frame: frame)
Expand All @@ -41,16 +37,13 @@ import Foundation
renderedSize = nil
return
}
guard
size != renderedSize || fillColor != renderedColor || feather != renderedFeather
|| shape != renderedShape
guard size != renderedSize || fillColor != renderedColor || feather != renderedFeather
else { return }
renderedSize = size
renderedColor = fillColor
renderedFeather = feather
renderedShape = shape
layer.contents = MangaBubbleBackground.blobImage(
size: size, feather: feather, shape: shape, color: fillColor.cgColor)
size: size, feather: feather, color: fillColor.cgColor)
}
}
#elseif os(macOS)
Expand All @@ -63,14 +56,10 @@ import Foundation
var feather: CGFloat = 10 {
didSet { if feather != oldValue { needsLayout = true } }
}
var shape: MangaBubbleShape = .ellipse {
didSet { if shape != oldValue { needsLayout = true } }
}

private var renderedSize: CGSize?
private var renderedColor: NSColor?
private var renderedFeather: CGFloat = 0
private var renderedShape: MangaBubbleShape?

override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
Expand All @@ -92,16 +81,13 @@ import Foundation
renderedSize = nil
return
}
guard
size != renderedSize || fillColor != renderedColor || feather != renderedFeather
|| shape != renderedShape
guard size != renderedSize || fillColor != renderedColor || feather != renderedFeather
else { return }
renderedSize = size
renderedColor = fillColor
renderedFeather = feather
renderedShape = shape
layer?.contents = MangaBubbleBackground.blobImage(
size: size, feather: feather, shape: shape, color: fillColor.cgColor)
size: size, feather: feather, color: fillColor.cgColor)
}
}
#endif
28 changes: 10 additions & 18 deletions KMReader/Features/MangaTranslation/Views/MangaOverlayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import SwiftUI
private var visible = true
private var background: ReaderBackground = .system
private var direction: MangaTranslationTextDirection = .auto
private var shape: MangaBubbleShape = .ellipse
private var sourceImageID: ObjectIdentifier?
private var regionColors: [CGColor] = []

Expand All @@ -28,14 +27,13 @@ import SwiftUI

func update(
result: MangaPageTranslation?, visible: Bool, background: ReaderBackground,
direction: MangaTranslationTextDirection, shape: MangaBubbleShape, image: PlatformImage?
direction: MangaTranslationTextDirection, image: PlatformImage?
) {
self.visible = visible
let backgroundChanged = self.background != background
self.background = background
let directionChanged = self.direction != direction
self.direction = direction
self.shape = shape
let imageID = image.map { ObjectIdentifier($0) }
let imageChanged = imageID != sourceImageID
sourceImageID = imageID
Expand Down Expand Up @@ -122,13 +120,12 @@ import SwiftUI
$0 is MangaBubbleBackgroundView
}) as? MangaBubbleBackgroundView {
bubbleBackground.frame = MangaBubbleBackground.backgroundFrame(
forBoxSize: container.bounds.size, boxKind: result.boxKind, shape: shape)
forBoxSize: container.bounds.size, boxKind: result.boxKind)
bubbleBackground.feather = feather
bubbleBackground.shape = shape
}

let textFrame = MangaBubbleBackground.textFrame(
forBoxSize: container.bounds.size, boxKind: result.boxKind, shape: shape)
forBoxSize: container.bounds.size, boxKind: result.boxKind)
guard
let child = container.subviews.first(where: {
$0 is UILabel || $0 is MangaVerticalTextLabel
Expand All @@ -151,14 +148,13 @@ import SwiftUI
private func fitFont(for bubble: UILabel) {
guard bubble.bounds.width > 0, bubble.bounds.height > 0 else { return }
var fontSize = min(17, max(12, bubble.bounds.height * 0.35))
for _ in 0..<4 {
for _ in 0..<5 {
bubble.font = .systemFont(ofSize: fontSize, weight: .medium)
let neededSize = bubble.sizeThatFits(
CGSize(width: bubble.bounds.width, height: .greatestFiniteMagnitude)
)
if neededSize.height <= bubble.bounds.height + 0.5 { break }
let scale = max(0.7, bubble.bounds.height / max(neededSize.height, 1))
let nextSize = max(8, fontSize * scale)
let nextSize = max(8, fontSize * 0.85)
if nextSize == fontSize { break }
fontSize = nextSize
}
Expand All @@ -175,7 +171,6 @@ import SwiftUI
private var visible = true
private var background: ReaderBackground = .system
private var direction: MangaTranslationTextDirection = .auto
private var shape: MangaBubbleShape = .ellipse
private var sourceImageID: ObjectIdentifier?
private var regionColors: [CGColor] = []

Expand All @@ -195,14 +190,13 @@ import SwiftUI

func update(
result: MangaPageTranslation?, visible: Bool, background: ReaderBackground,
direction: MangaTranslationTextDirection, shape: MangaBubbleShape, image: PlatformImage?
direction: MangaTranslationTextDirection, image: PlatformImage?
) {
self.visible = visible
let backgroundChanged = self.background != background
self.background = background
let directionChanged = self.direction != direction
self.direction = direction
self.shape = shape
let imageID = image.map { ObjectIdentifier($0) }
let imageChanged = imageID != sourceImageID
sourceImageID = imageID
Expand Down Expand Up @@ -292,13 +286,12 @@ import SwiftUI
$0 is MangaBubbleBackgroundView
}) as? MangaBubbleBackgroundView {
bubbleBackground.frame = MangaBubbleBackground.backgroundFrame(
forBoxSize: container.bounds.size, boxKind: result.boxKind, shape: shape)
forBoxSize: container.bounds.size, boxKind: result.boxKind)
bubbleBackground.feather = feather
bubbleBackground.shape = shape
}

let textFrame = MangaBubbleBackground.textFrame(
forBoxSize: container.bounds.size, boxKind: result.boxKind, shape: shape)
forBoxSize: container.bounds.size, boxKind: result.boxKind)
guard
let child = container.subviews.first(where: {
$0 is NSTextField || $0 is MangaVerticalTextLabel
Expand All @@ -321,14 +314,13 @@ import SwiftUI
private func fitFont(for bubble: NSTextField) {
guard bubble.bounds.width > 0, bubble.bounds.height > 0 else { return }
var fontSize = min(17, max(12, bubble.bounds.height * 0.35))
for _ in 0..<4 {
for _ in 0..<5 {
bubble.font = .systemFont(ofSize: fontSize, weight: .medium)
let neededSize = bubble.sizeThatFits(
CGSize(width: bubble.bounds.width, height: .greatestFiniteMagnitude)
)
if neededSize.height <= bubble.bounds.height + 0.5 { break }
let scale = max(0.7, bubble.bounds.height / max(neededSize.height, 1))
let nextSize = max(8, fontSize * scale)
let nextSize = max(8, fontSize * 0.85)
if nextSize == fontSize { break }
fontSize = nextSize
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,6 @@ struct MangaTranslationSettingsView: View {
Text(String(localized: "Horizontal")).tag(MangaTranslationTextDirection.horizontal)
Text(String(localized: "Vertical")).tag(MangaTranslationTextDirection.vertical)
}
Picker(String(localized: "Bubble Shape"), selection: $preferences.bubbleShape) {
Text(String(localized: "Ellipse")).tag(MangaBubbleShape.ellipse)
Text(String(localized: "Rounded Rectangle")).tag(MangaBubbleShape.roundedRect)
}
}

Section(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,6 @@
visible: viewModel.mangaTranslationOverlayVisible(for: data.pageID, splitMode: data.splitMode),
background: background,
direction: AppConfig.mangaTranslation.textDirection,
shape: AppConfig.mangaTranslation.bubbleShape,
image: pageSourceImage
)
#if os(iOS)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,6 @@
for: data.pageID, splitMode: data.splitMode) ?? false,
background: background,
direction: AppConfig.mangaTranslation.textDirection,
shape: AppConfig.mangaTranslation.bubbleShape,
image: pageSourceImage
)
updateTranslationButton()
Expand Down
Loading