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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user