From 3d8cbccc4ead8fbf9d8756bf486b62bd8e096ad1 Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 22 Apr 2026 09:18:15 -0500 Subject: [PATCH] =?UTF-8?q?Fixes=20#25=20=E2=80=94=20Long-press=20lyric=20?= =?UTF-8?q?words=20for=20definition=20and=20tense?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Practice/Lyrics/LyricsReaderView.swift | 199 +++++++++++++++++- 1 file changed, 188 insertions(+), 11 deletions(-) diff --git a/Conjuga/Conjuga/Views/Practice/Lyrics/LyricsReaderView.swift b/Conjuga/Conjuga/Views/Practice/Lyrics/LyricsReaderView.swift index 6fe4be3..5c3608d 100644 --- a/Conjuga/Conjuga/Views/Practice/Lyrics/LyricsReaderView.swift +++ b/Conjuga/Conjuga/Views/Practice/Lyrics/LyricsReaderView.swift @@ -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.. 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 + } }