Fixes #25 — Long-press lyric words for definition and tense

Tokenize Spanish lyric lines into a flow layout of underlined, long-pressable
words. Long-press (0.35s) opens a sheet with base form, English, part of
speech, and a Tense · person row for verbs. Unknown words silently no-op.
English gloss lines remain untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-22 09:18:15 -05:00
parent cc6ec70ed9
commit 3d8cbccc4e

View File

@@ -4,6 +4,10 @@ import SharedModels
struct LyricsReaderView: View {
let song: SavedSong
@Environment(DictionaryService.self) private var dictionary
@State private var selectedWord: LyricsWordLookup?
@State private var lookupCache: [String: LyricsWordLookup] = [:]
var body: some View {
ScrollView {
VStack(spacing: 20) {
@@ -15,6 +19,10 @@ struct LyricsReaderView: View {
}
.navigationTitle(song.title)
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $selectedWord) { word in
LyricsWordDetailSheet(word: word)
.presentationDetents([.height(260)])
}
}
// MARK: - Header
@@ -56,15 +64,6 @@ struct LyricsReaderView: View {
let spanishLines = song.lyricsES.components(separatedBy: "\n")
let englishLines = song.lyricsEN.components(separatedBy: "\n")
let lineCount = max(spanishLines.count, englishLines.count)
let _ = {
print("[LyricsReader] ES lines: \(spanishLines.count), EN lines: \(englishLines.count), rendering: \(lineCount)")
for i in 0..<min(15, lineCount) {
let es = i < spanishLines.count ? spanishLines[i] : "(none)"
let en = i < englishLines.count ? englishLines[i] : "(none)"
print(" [\(i)] ES: \(es.isEmpty ? "(blank)" : es)")
print(" EN: \(en.isEmpty ? "(blank)" : en)")
}
}()
return VStack(alignment: .leading, spacing: 0) {
ForEach(0..<lineCount, id: \.self) { index in
@@ -78,8 +77,7 @@ struct LyricsReaderView: View {
} else {
VStack(alignment: .leading, spacing: 2) {
if !es.isEmpty {
Text(es)
.font(.body.weight(.medium))
spanishLine(es)
}
if !en.isEmpty {
Text(en)
@@ -94,4 +92,183 @@ struct LyricsReaderView: View {
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
private func spanishLine(_ line: String) -> some View {
let tokens = line.components(separatedBy: " ")
return LyricsFlowLayout(spacing: 0) {
ForEach(Array(tokens.enumerated()), id: \.offset) { _, token in
LyricsWordView(token: token, lookup: makeLookup(for: token)) { word in
selectedWord = word
}
}
}
}
// MARK: - Lookup
private func makeLookup(for rawToken: String) -> LyricsWordLookup? {
let cleaned = rawToken.lowercased()
.trimmingCharacters(in: .punctuationCharacters)
.trimmingCharacters(in: .whitespaces)
guard !cleaned.isEmpty else { return nil }
if let cached = lookupCache[cleaned] { return cached }
guard let entry = dictionary.lookup(cleaned) else { return nil }
let displayWord = rawToken
.trimmingCharacters(in: .punctuationCharacters)
.trimmingCharacters(in: .whitespaces)
let tenseDisplay = entry.tenseId.flatMap { TenseInfo.find($0)?.english }
let lookup = LyricsWordLookup(
word: displayWord.isEmpty ? entry.word : displayWord,
baseForm: entry.baseForm,
english: entry.english,
partOfSpeech: entry.partOfSpeech,
tenseDisplay: tenseDisplay,
person: entry.person
)
lookupCache[cleaned] = lookup
return lookup
}
}
// MARK: - Word Lookup Model
private struct LyricsWordLookup: Identifiable, Hashable {
let word: String
let baseForm: String
let english: String
let partOfSpeech: String
let tenseDisplay: String?
let person: String?
var id: String { word }
}
// MARK: - Word View
private struct LyricsWordView: View {
let token: String
let lookup: LyricsWordLookup?
let onLookup: (LyricsWordLookup) -> Void
var body: some View {
Text(token + " ")
.font(.body.weight(.medium))
.foregroundStyle(.primary)
.underline(lookup != nil, color: .teal.opacity(0.35))
.contentShape(Rectangle())
.onLongPressGesture(minimumDuration: 0.35) {
if let lookup {
onLookup(lookup)
}
}
}
}
// MARK: - Detail Sheet
private struct LyricsWordDetailSheet: View {
let word: LyricsWordLookup
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text(word.word)
.font(.title2.bold())
Spacer()
if !word.partOfSpeech.isEmpty {
Text(word.partOfSpeech)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.fill.tertiary, in: Capsule())
}
}
Divider()
VStack(alignment: .leading, spacing: 10) {
if !word.baseForm.isEmpty && word.baseForm.lowercased() != word.word.lowercased() {
detailRow(label: "Base form", value: word.baseForm, italic: true)
}
if !word.english.isEmpty {
detailRow(label: "English", value: word.english)
}
if let tenseDisplay = word.tenseDisplay {
let personSuffix = (word.person?.isEmpty == false) ? " · \(word.person!)" : ""
detailRow(label: "Tense", value: tenseDisplay + personSuffix)
}
}
Spacer(minLength: 0)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private func detailRow(label: String, value: String, italic: Bool = false) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("\(label):")
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(width: 86, alignment: .leading)
Text(value)
.font(.subheadline.weight(.semibold))
.italic(italic)
Spacer(minLength: 0)
}
}
}
// MARK: - Flow Layout
private struct LyricsFlowLayout: Layout {
var spacing: CGFloat = 0
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let rows = computeRows(proposal: proposal, subviews: subviews)
var height: CGFloat = 0
for row in rows { height += row.map { $0.height }.max() ?? 0 }
height += CGFloat(max(0, rows.count - 1)) * spacing
return CGSize(width: proposal.width ?? 0, height: height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let rows = computeRows(proposal: proposal, subviews: subviews)
var y = bounds.minY
var idx = 0
for row in rows {
var x = bounds.minX
let rh = row.map { $0.height }.max() ?? 0
for size in row {
subviews[idx].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
x += size.width
idx += 1
}
y += rh + spacing
}
}
private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[CGSize]] {
let mw = proposal.width ?? .infinity
var rows: [[CGSize]] = [[]]
var cw: CGFloat = 0
for sv in subviews {
let s = sv.sizeThatFits(.unspecified)
if cw + s.width > mw && !rows[rows.count - 1].isEmpty {
rows.append([])
cw = 0
}
rows[rows.count - 1].append(s)
cw += s.width
}
return rows
}
}