Refactor word replacements with inline UI

This commit is contained in:
Beingpax 2025-12-27 20:19:33 +05:45
parent a363745f36
commit 01c12cdd2d
2 changed files with 246 additions and 329 deletions

View File

@ -109,6 +109,10 @@ struct VocabularyView: View {
UserDefaults.standard.set(sortMode.rawValue, forKey: "vocabularySortMode")
}
private var shouldShowAddButton: Bool {
!newWord.isEmpty
}
var body: some View {
VStack(alignment: .leading, spacing: 20) {
GroupBox {
@ -129,16 +133,19 @@ struct VocabularyView: View {
.font(.system(size: 13))
.onSubmit { addWords() }
Button(action: addWords) {
Image(systemName: "plus.circle.fill")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.blue)
.font(.system(size: 16, weight: .semibold))
if shouldShowAddButton {
Button(action: addWords) {
Image(systemName: "plus.circle.fill")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.blue)
.font(.system(size: 16, weight: .semibold))
}
.buttonStyle(.borderless)
.disabled(newWord.isEmpty)
.help("Add word")
}
.buttonStyle(.borderless)
.disabled(newWord.isEmpty)
.help("Add word")
}
.animation(.easeInOut(duration: 0.2), value: shouldShowAddButton)
if !vocabularyManager.items.isEmpty {
VStack(alignment: .leading, spacing: 12) {
@ -214,7 +221,7 @@ struct VocabularyView: View {
struct VocabularyWordView: View {
let item: VocabularyWord
let onDelete: () -> Void
@State private var isHovered = false
@State private var isDeleteHovered = false
var body: some View {
HStack(spacing: 6) {
@ -228,14 +235,14 @@ struct VocabularyWordView: View {
Button(action: onDelete) {
Image(systemName: "xmark.circle.fill")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(isHovered ? .red : .secondary)
.foregroundStyle(isDeleteHovered ? .red : .secondary)
.contentTransition(.symbolEffect(.replace))
}
.buttonStyle(.borderless)
.help("Remove word")
.onHover { hover in
withAnimation(.easeInOut(duration: 0.2)) {
isHovered = hover
isDeleteHovered = hover
}
}
}

View File

@ -93,12 +93,13 @@ class WordReplacementManager: ObservableObject {
struct WordReplacementView: View {
@StateObject private var manager = WordReplacementManager()
@State private var showAddReplacementModal = false
@State private var showAlert = false
@State private var editingOriginal: String? = nil
@State private var alertMessage = ""
@State private var sortMode: SortMode = .originalAsc
@State private var originalWord = ""
@State private var replacementWord = ""
@State private var showInfoPopover = false
init() {
if let savedSort = UserDefaults.standard.string(forKey: "wordReplacementSortMode"),
@ -131,6 +132,10 @@ struct WordReplacementView: View {
}
UserDefaults.standard.set(sortMode.rawValue, forKey: "wordReplacementSortMode")
}
private var shouldShowAddButton: Bool {
!originalWord.isEmpty || !replacementWord.isEmpty
}
var body: some View {
VStack(alignment: .leading, spacing: 20) {
@ -141,296 +146,118 @@ struct WordReplacementView: View {
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
} icon: {
Image(systemName: "info.circle.fill")
.foregroundColor(.blue)
Button(action: { showInfoPopover.toggle() }) {
Image(systemName: "info.circle.fill")
.foregroundColor(.blue)
}
.buttonStyle(.plain)
.popover(isPresented: $showInfoPopover) {
WordReplacementInfoPopover()
}
}
}
VStack(spacing: 0) {
HStack(spacing: 16) {
Button(action: { toggleSort(for: .original) }) {
HStack(spacing: 4) {
Text("Original")
.font(.headline)
if sortMode == .originalAsc || sortMode == .originalDesc {
Image(systemName: sortMode == .originalAsc ? "chevron.up" : "chevron.down")
.font(.caption)
.foregroundColor(.accentColor)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
HStack(spacing: 8) {
TextField("Original text (use commas for multiple)", text: $originalWord)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13))
Image(systemName: "arrow.right")
.foregroundColor(.secondary)
.font(.system(size: 10))
.frame(width: 10)
TextField("Replacement text", text: $replacementWord)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13))
.onSubmit { addReplacement() }
if shouldShowAddButton {
Button(action: addReplacement) {
Image(systemName: "plus.circle.fill")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.blue)
.font(.system(size: 16, weight: .semibold))
}
.buttonStyle(.plain)
Image(systemName: "arrow.right")
.foregroundColor(.secondary)
.font(.system(size: 12))
.frame(width: 20)
Button(action: { toggleSort(for: .replacement) }) {
HStack(spacing: 4) {
Text("Replacement")
.font(.headline)
if sortMode == .replacementAsc || sortMode == .replacementDesc {
Image(systemName: sortMode == .replacementAsc ? "chevron.up" : "chevron.down")
.font(.caption)
.foregroundColor(.accentColor)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
HStack(spacing: 8) {
Button(action: { showAddReplacementModal = true }) {
Image(systemName: "plus")
}
.buttonStyle(.borderless)
}
.frame(width: 60)
.buttonStyle(.borderless)
.disabled(originalWord.isEmpty || replacementWord.isEmpty)
.help("Add word replacement")
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(Color(.controlBackgroundColor))
Divider()
// Content
if manager.replacements.isEmpty {
EmptyStateView(showAddModal: $showAddReplacementModal)
} else {
}
.animation(.easeInOut(duration: 0.2), value: shouldShowAddButton)
if !manager.replacements.isEmpty {
VStack(spacing: 0) {
HStack(spacing: 8) {
Button(action: { toggleSort(for: .original) }) {
HStack(spacing: 4) {
Text("Original")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary)
if sortMode == .originalAsc || sortMode == .originalDesc {
Image(systemName: sortMode == .originalAsc ? "chevron.up" : "chevron.down")
.font(.caption)
.foregroundColor(.accentColor)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.help("Sort by original")
Image(systemName: "arrow.right")
.foregroundColor(.secondary)
.font(.system(size: 10))
.frame(width: 10)
Button(action: { toggleSort(for: .replacement) }) {
HStack(spacing: 4) {
Text("Replacement")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary)
if sortMode == .replacementAsc || sortMode == .replacementDesc {
Image(systemName: sortMode == .replacementAsc ? "chevron.up" : "chevron.down")
.font(.caption)
.foregroundColor(.accentColor)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.help("Sort by replacement")
}
.padding(.horizontal, 4)
.padding(.vertical, 8)
Divider()
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(sortedReplacements.enumerated()), id: \.offset) { index, pair in
ForEach(sortedReplacements, id: \.key) { pair in
ReplacementRow(
original: pair.key,
replacement: pair.value,
onDelete: { manager.removeReplacement(original: pair.key) },
onEdit: { editingOriginal = pair.key }
)
if index != sortedReplacements.count - 1 {
if pair.key != sortedReplacements.last?.key {
Divider()
.padding(.leading, 32)
}
}
}
.background(Color(.controlBackgroundColor))
}
.frame(maxHeight: 300)
}
.padding(.top, 4)
}
}
.padding()
.sheet(isPresented: $showAddReplacementModal) {
AddReplacementSheet(manager: manager)
}
// Edit existing replacement
.sheet(item: $editingOriginal) { original in
EditReplacementSheet(manager: manager, originalKey: original)
}
}
}
struct EmptyStateView: View {
@Binding var showAddModal: Bool
var body: some View {
VStack(spacing: 12) {
Image(systemName: "text.word.spacing")
.font(.system(size: 32))
.foregroundColor(.secondary)
Text("No Replacements")
.font(.headline)
Text("Add word replacements to automatically replace text.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 250)
Button("Add Replacement") {
showAddModal = true
}
.buttonStyle(.borderedProminent)
.controlSize(.regular)
.padding(.top, 8)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct AddReplacementSheet: View {
@ObservedObject var manager: WordReplacementManager
@Environment(\.dismiss) private var dismiss
@State private var originalWord = ""
@State private var replacementWord = ""
@State private var showAlert = false
@State private var alertMessage = ""
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Button("Cancel", role: .cancel) {
dismiss()
}
.buttonStyle(.borderless)
.keyboardShortcut(.escape, modifiers: [])
Spacer()
Text("Add Word Replacement")
.font(.headline)
Spacer()
Button("Add") {
addReplacement()
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(originalWord.isEmpty || replacementWord.isEmpty)
.keyboardShortcut(.return, modifiers: [])
}
.padding(.horizontal)
.padding(.vertical, 12)
.background(CardBackground(isSelected: false))
Divider()
ScrollView {
VStack(spacing: 20) {
// Description
Text("Define a word or phrase to be automatically replaced.")
.font(.subheadline)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.padding(.top, 8)
// Form Content
VStack(spacing: 16) {
// Original Text Section
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Original Text")
.font(.headline)
.foregroundColor(.primary)
Text("Required")
.font(.caption)
.foregroundColor(.secondary)
}
TextField("Enter word or phrase to replace (use commas for multiple)", text: $originalWord)
.textFieldStyle(.roundedBorder)
.font(.body)
Text("Separate multiple originals with commas, e.g. Voicing, Voice ink, Voiceing")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.horizontal)
// Replacement Text Section
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Replacement Text")
.font(.headline)
.foregroundColor(.primary)
Text("Required")
.font(.caption)
.foregroundColor(.secondary)
}
TextEditor(text: $replacementWord)
.font(.body)
.frame(height: 100)
.padding(8)
.background(Color(.textBackgroundColor))
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color(.separatorColor), lineWidth: 1)
)
}
.padding(.horizontal)
}
// Example Section
VStack(alignment: .leading, spacing: 8) {
Text("Examples")
.font(.subheadline)
.foregroundColor(.secondary)
// Single original -> replacement
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text("Original:")
.font(.caption)
.foregroundColor(.secondary)
Text("my website link")
.font(.callout)
}
Image(systemName: "arrow.right")
.font(.caption)
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 4) {
Text("Replacement:")
.font(.caption)
.foregroundColor(.secondary)
Text("https://tryvoiceink.com")
.font(.callout)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(Color(.textBackgroundColor))
.cornerRadius(8)
// Comma-separated originals -> single replacement
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text("Original:")
.font(.caption)
.foregroundColor(.secondary)
Text("Voicing, Voice ink, Voiceing")
.font(.callout)
}
Image(systemName: "arrow.right")
.font(.caption)
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 4) {
Text("Replacement:")
.font(.caption)
.foregroundColor(.secondary)
Text("VoiceInk")
.font(.callout)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(Color(.textBackgroundColor))
.cornerRadius(8)
}
.padding(.horizontal)
.padding(.top, 8)
}
.padding(.vertical)
}
}
.frame(width: 460, height: 520)
.alert("Word Replacement", isPresented: $showAlert) {
Button("OK", role: .cancel) {}
} message: {
@ -440,7 +267,7 @@ struct AddReplacementSheet: View {
private func addReplacement() {
let original = originalWord.trimmingCharacters(in: .whitespacesAndNewlines)
let replacement = replacementWord
let replacement = replacementWord.trimmingCharacters(in: .whitespacesAndNewlines)
let tokens = original
.split(separator: ",")
@ -450,7 +277,8 @@ struct AddReplacementSheet: View {
let result = manager.addReplacement(original: original, replacement: replacement)
if result.success {
dismiss()
originalWord = ""
replacementWord = ""
} else {
if let conflictingWord = result.conflictingWord {
alertMessage = "'\(conflictingWord)' already exists in word replacements"
@ -462,68 +290,150 @@ struct AddReplacementSheet: View {
}
}
struct WordReplacementInfoPopover: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("How to use Word Replacements")
.font(.headline)
VStack(alignment: .leading, spacing: 8) {
Text("Separate multiple originals with commas:")
.font(.subheadline)
.foregroundColor(.secondary)
Text("Voicing, Voice ink, Voiceing")
.font(.callout)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.textBackgroundColor))
.cornerRadius(6)
}
Divider()
Text("Examples")
.font(.subheadline)
.foregroundColor(.secondary)
VStack(spacing: 12) {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 4) {
Text("Original:")
.font(.caption)
.foregroundColor(.secondary)
Text("my website link")
.font(.callout)
}
Image(systemName: "arrow.right")
.font(.caption)
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 4) {
Text("Replacement:")
.font(.caption)
.foregroundColor(.secondary)
Text("https://tryvoiceink.com")
.font(.callout)
}
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.textBackgroundColor))
.cornerRadius(6)
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 4) {
Text("Original:")
.font(.caption)
.foregroundColor(.secondary)
Text("Voicing, Voice ink")
.font(.callout)
}
Image(systemName: "arrow.right")
.font(.caption)
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 4) {
Text("Replacement:")
.font(.caption)
.foregroundColor(.secondary)
Text("VoiceInk")
.font(.callout)
}
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.textBackgroundColor))
.cornerRadius(6)
}
}
.padding()
.frame(width: 380)
}
}
struct ReplacementRow: View {
let original: String
let replacement: String
let onDelete: () -> Void
let onEdit: () -> Void
@State private var isEditHovered = false
@State private var isDeleteHovered = false
var body: some View {
HStack(spacing: 16) {
// Original Text Container
HStack {
Text(original)
.font(.body)
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.textBackgroundColor))
.cornerRadius(6)
}
.frame(maxWidth: .infinity)
// Arrow
HStack(spacing: 8) {
Text(original)
.font(.system(size: 13))
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "arrow.right")
.foregroundColor(.secondary)
.font(.system(size: 12))
// Replacement Text Container
HStack {
.font(.system(size: 10))
.frame(width: 10)
ZStack(alignment: .trailing) {
Text(replacement)
.font(.body)
.font(.system(size: 13))
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.textBackgroundColor))
.cornerRadius(6)
.padding(.trailing, 50)
HStack(spacing: 6) {
Button(action: onEdit) {
Image(systemName: "pencil.circle.fill")
.symbolRenderingMode(.hierarchical)
.foregroundColor(isEditHovered ? .accentColor : .secondary)
.contentTransition(.symbolEffect(.replace))
}
.buttonStyle(.borderless)
.help("Edit replacement")
.onHover { hover in
withAnimation(.easeInOut(duration: 0.2)) {
isEditHovered = hover
}
}
Button(action: onDelete) {
Image(systemName: "xmark.circle.fill")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(isDeleteHovered ? .red : .secondary)
.contentTransition(.symbolEffect(.replace))
}
.buttonStyle(.borderless)
.help("Remove replacement")
.onHover { hover in
withAnimation(.easeInOut(duration: 0.2)) {
isDeleteHovered = hover
}
}
}
}
.frame(maxWidth: .infinity)
// Edit Button
Button(action: onEdit) {
Image(systemName: "pencil.circle.fill")
.symbolRenderingMode(.hierarchical)
.foregroundColor(.accentColor)
.font(.system(size: 16))
}
.buttonStyle(.borderless)
.help("Edit replacement")
// Delete Button
Button(action: onDelete) {
Image(systemName: "xmark.circle.fill")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.red)
.font(.system(size: 16))
}
.buttonStyle(.borderless)
.help("Remove replacement")
}
.padding(.horizontal)
.padding(.vertical, 8)
.contentShape(Rectangle())
.background(Color(.controlBackgroundColor))
.padding(.horizontal, 4)
}
}