Skip to content

Commit a33dead

Browse files
committed
nicer title editing
1 parent 6596d42 commit a33dead

2 files changed

Lines changed: 192 additions & 126 deletions

File tree

Flitro/Editor/ContextDetailsView.swift

Lines changed: 10 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -118,25 +118,8 @@ struct ContextDetailsView: View {
118118
@Binding var showAddBrowserTabDialog: Bool
119119
@Binding var showAddTerminalDialog: Bool
120120

121-
@State private var isEditingTitle = false
122-
@State private var draftTitle = ""
123-
// New: clearer editing/hover/focus states for the title
124-
@State private var isHoveringTitle = false
125-
@FocusState private var titleFieldFocused: Bool
126-
127121
// New: Editing state for context items
128122
@State private var editingItemIndex: EditingIndex? = nil
129-
130-
// Force toolbar relayout when the context or title size category changes
131-
private var principalLayoutID: String {
132-
if isEditingTitle {
133-
// Important: do not depend on draftTitle length while editing to avoid TextField resets
134-
return "\(context.id)-e"
135-
} else {
136-
let bucket = max(1, min(8, context.name.count / 12)) // coarse buckets to reduce churn
137-
return "\(context.id)-v-\(bucket)"
138-
}
139-
}
140123

141124
var body: some View {
142125
ZStack {
@@ -285,93 +268,16 @@ struct ContextDetailsView: View {
285268
}
286269
}
287270
.toolbar {
288-
// Make the title occupy the principal (expandable) area of the toolbar
271+
// Invisible, flexible item claims the center (principal) space
289272
ToolbarItem(placement: .principal) {
290-
if isEditingTitle {
291-
HStack(spacing: 8) {
292-
TextField("Context Name", text: $draftTitle, onCommit: { commitTitle() })
293-
.font(.system(size: 17, weight: .bold))
294-
.textFieldStyle(PlainTextFieldStyle())
295-
.focused($titleFieldFocused)
296-
.onAppear {
297-
// Focus the field when entering edit mode; draftTitle is set when toggling edit mode
298-
DispatchQueue.main.async { self.titleFieldFocused = true }
299-
}
300-
.onExitCommand { cancelEditTitle() }
301-
.lineLimit(1)
302-
.allowsTightening(true)
303-
.minimumScaleFactor(0.5)
304-
.fixedSize(horizontal: false, vertical: true)
305-
.frame(maxWidth: .infinity, alignment: .leading)
306-
// Quick actions for explicit save/cancel
307-
Button { commitTitle() } label: {
308-
Image(systemName: "checkmark.circle.fill").foregroundColor(.accentColor)
309-
}
310-
.buttonStyle(.plain)
311-
.help("Save title")
312-
Button { cancelEditTitle() } label: {
313-
Image(systemName: "xmark.circle.fill").foregroundColor(.secondary)
314-
}
315-
.buttonStyle(.plain)
316-
.help("Cancel editing")
317-
}
318-
.padding(.vertical, 4)
319-
.padding(.horizontal, 8)
320-
.background(
321-
RoundedRectangle(cornerRadius: 8)
322-
.fill(Color(NSColor.textBackgroundColor))
323-
)
324-
.overlay(
325-
RoundedRectangle(cornerRadius: 8)
326-
.stroke(Color.accentColor.opacity(0.6), lineWidth: 1)
327-
)
328-
// Allow the title editor to expand across available toolbar space
329-
.frame(minWidth: 140, maxWidth: .infinity, alignment: .leading)
330-
// Keep stable id logic
331-
.id(principalLayoutID)
332-
} else {
333-
HStack(spacing: 6) {
334-
Text(context.name)
335-
.font(.system(size: 17, weight: .bold))
336-
.lineLimit(2)
337-
.truncationMode(.middle)
338-
.allowsTightening(true)
339-
.minimumScaleFactor(0.5)
340-
.multilineTextAlignment(.leading)
341-
.fixedSize(horizontal: false, vertical: true)
342-
.frame(maxWidth: .infinity, alignment: .leading)
343-
.contentShape(Rectangle())
344-
.onTapGesture {
345-
draftTitle = context.name
346-
isEditingTitle = true
347-
}
348-
// Reserve space for the pencil to avoid layout shifts and only fade it
349-
Image(systemName: "pencil")
350-
.font(.system(size: 13))
351-
.foregroundColor(.secondary)
352-
.opacity(isHoveringTitle ? 1 : 0)
353-
.frame(width: 16, height: 16)
354-
}
355-
.padding(.vertical, 4)
356-
.padding(.horizontal, 8)
357-
.background(
358-
RoundedRectangle(cornerRadius: 8)
359-
.fill(isHoveringTitle ? Color.accentColor.opacity(0.08) : Color.clear)
360-
)
361-
.overlay(
362-
RoundedRectangle(cornerRadius: 8)
363-
.stroke(isHoveringTitle ? Color.accentColor.opacity(0.25) : Color.clear, lineWidth: 1)
364-
)
365-
// Allow the title to expand across available toolbar space
366-
.frame(minWidth: 140, maxWidth: .infinity, alignment: .leading)
367-
.onHover { hovering in
368-
isHoveringTitle = hovering
369-
}
370-
.help("Click to rename")
371-
.id(principalLayoutID)
372-
}
273+
Color.clear.frame(maxWidth: .infinity)
373274
}
374-
// Ensure action buttons stay on the trailing edge
275+
// Title anchored on the very left (navigation area), allowed to expand
276+
ToolbarItem(placement: .navigation) {
277+
ContextTitleView(context: context, contextManager: contextManager)
278+
.frame(maxWidth: .infinity, alignment: .leading)
279+
}
280+
// Trailing actions stay on the right
375281
ToolbarItemGroup(placement: .primaryAction) {
376282
// Add dropdown menu for adding items (unchanged)
377283
Menu {
@@ -380,20 +286,16 @@ struct ContextDetailsView: View {
380286
Button("Browser Tab", action: { showAddBrowserTabDialog = true })
381287
Button("Shell Script", action: { showAddTerminalDialog = true })
382288
} label: {
383-
// HStack(spacing: 6) {
384289
Image(systemName: "plus")
385-
// Text("Add")
386-
// }
387-
// .font(.system(size: 16, weight: .medium))
388-
.help("Add Item")
290+
.help("Add Item")
389291
}
390292
// Single context button
391293
ContextButton(context: context, contextManager: contextManager)
392294
}
393295
}
394296
.navigationTitle("")
395297
.onChange(of: context.id) { _, _ in
396-
isEditingTitle = false
298+
// Title editing state now managed by ContextTitleView
397299
}
398300
}
399301

@@ -416,24 +318,6 @@ struct ContextDetailsView: View {
416318
.listRowInsets(EdgeInsets())
417319
.background(Color.clear)
418320
}
419-
420-
// MARK: - Title helpers
421-
private func commitTitle() {
422-
isEditingTitle = false
423-
let trimmed = draftTitle.trimmingCharacters(in: .whitespacesAndNewlines)
424-
if !trimmed.isEmpty && trimmed != context.name {
425-
context.name = trimmed
426-
contextManager.saveContexts()
427-
} else {
428-
// Restore draft to the current name if unchanged
429-
draftTitle = context.name
430-
}
431-
}
432-
433-
private func cancelEditTitle() {
434-
draftTitle = context.name
435-
isEditingTitle = false
436-
}
437321
}
438322

439323
struct ContextButton: View {
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import SwiftUI
2+
import AppKit
3+
4+
struct ContextTitleView: View {
5+
@ObservedObject var context: Context
6+
@ObservedObject var contextManager: ContextManager
7+
8+
@State private var isEditingTitle = false
9+
@State private var draftTitle = ""
10+
@State private var isHoveringTitle = false
11+
@FocusState private var titleFieldFocused: Bool
12+
// Freeze font size while editing to avoid per-keystroke layout changes
13+
@State private var editingFontSize: CGFloat = 17
14+
// Capture displayed width and freeze it during edit to avoid overflow and layout jumps
15+
@State private var displayWidth: CGFloat = 0
16+
@State private var editingWidth: CGFloat? = nil
17+
18+
// Force toolbar relayout when the context or title size category changes
19+
private var principalLayoutID: String {
20+
if isEditingTitle {
21+
// Important: do not depend on draftTitle length while editing to avoid TextField resets
22+
return "\(context.id)-e"
23+
} else {
24+
let bucket = max(1, min(8, context.name.count / 12)) // coarse buckets to reduce churn
25+
return "\(context.id)-v-\(bucket)"
26+
}
27+
}
28+
29+
// Adaptive font for display state only (no per-keystroke changes)
30+
private var displayTitleFont: Font {
31+
let size: CGFloat = context.name.count > 30 ? 15 : 17
32+
return .system(size: size, weight: .bold)
33+
}
34+
// Editing font uses a frozen size captured on edit begin
35+
private var editingTitleFont: Font {
36+
.system(size: editingFontSize, weight: .bold)
37+
}
38+
39+
var body: some View {
40+
Group {
41+
if isEditingTitle {
42+
HStack(spacing: 8) {
43+
TextField("Context Name", text: $draftTitle, onCommit: { commitTitle() })
44+
.font(editingTitleFont)
45+
.textFieldStyle(PlainTextFieldStyle())
46+
.focused($titleFieldFocused)
47+
.onAppear {
48+
// Focus the field when entering edit mode; draftTitle is set when toggling edit mode
49+
DispatchQueue.main.async { self.titleFieldFocused = true }
50+
}
51+
.onExitCommand { cancelEditTitle() }
52+
.lineLimit(1)
53+
.allowsTightening(true)
54+
.minimumScaleFactor(0.2)
55+
.frame(maxWidth: .infinity, alignment: .leading)
56+
.layoutPriority(1)
57+
// Quick actions for explicit save/cancel
58+
Button { commitTitle() } label: {
59+
Image(systemName: "checkmark.circle.fill").foregroundColor(.accentColor)
60+
}
61+
.buttonStyle(.plain)
62+
.help("Save title")
63+
Button { cancelEditTitle() } label: {
64+
Image(systemName: "xmark.circle.fill").foregroundColor(.secondary)
65+
}
66+
.buttonStyle(.plain)
67+
.help("Cancel editing")
68+
}
69+
.padding(.vertical, 4)
70+
.padding(.horizontal, 8)
71+
.background(
72+
RoundedRectangle(cornerRadius: 8)
73+
.fill(Color(NSColor.textBackgroundColor))
74+
)
75+
.overlay(
76+
RoundedRectangle(cornerRadius: 8)
77+
.stroke(Color.accentColor.opacity(0.6), lineWidth: 1)
78+
)
79+
// Allow the title editor to expand across available toolbar space (baseline)
80+
.frame(minWidth: 140, maxWidth: .infinity, alignment: .leading)
81+
// Freeze width to what was available at the start of editing (must be last)
82+
.frame(width: editingWidth, alignment: .leading)
83+
// Disable implicit animations tied to typing to prevent wiggle
84+
.animation(nil, value: draftTitle)
85+
// Keep stable id logic
86+
.id(principalLayoutID)
87+
} else {
88+
HStack(spacing: 6) {
89+
Text(context.name)
90+
.font(displayTitleFont)
91+
.lineLimit(1)
92+
.allowsTightening(true)
93+
.minimumScaleFactor(0.2)
94+
.multilineTextAlignment(.leading)
95+
.frame(maxWidth: .infinity, alignment: .leading)
96+
.layoutPriority(1)
97+
.contentShape(Rectangle())
98+
.onTapGesture {
99+
draftTitle = context.name
100+
// Capture font size and available width once for the edit session
101+
editingFontSize = (context.name.count > 30 ? 15 : 17)
102+
editingWidth = max(140, displayWidth)
103+
isEditingTitle = true
104+
}
105+
// Reserve space for the pencil to avoid layout shifts and only fade it
106+
Image(systemName: "pencil")
107+
.font(.system(size: 13))
108+
.foregroundColor(.secondary)
109+
.opacity(isHoveringTitle ? 1 : 0)
110+
.frame(width: 16, height: 16)
111+
}
112+
.padding(.vertical, 4)
113+
.padding(.horizontal, 8)
114+
.background(
115+
RoundedRectangle(cornerRadius: 8)
116+
.fill(isHoveringTitle ? Color.accentColor.opacity(0.08) : Color.clear)
117+
)
118+
.overlay(
119+
RoundedRectangle(cornerRadius: 8)
120+
.stroke(isHoveringTitle ? Color.accentColor.opacity(0.25) : Color.clear, lineWidth: 1)
121+
)
122+
// Allow the title to expand across available toolbar space
123+
.frame(minWidth: 140, maxWidth: .infinity, alignment: .leading)
124+
// Continuously capture laid-out width of the display state
125+
.background(
126+
GeometryReader { proxy in
127+
Color.clear.preference(key: TitleWidthKey.self, value: proxy.size.width)
128+
}
129+
)
130+
.onPreferenceChange(TitleWidthKey.self) { width in
131+
displayWidth = width
132+
}
133+
.onHover { hovering in
134+
isHoveringTitle = hovering
135+
}
136+
.help("Click to rename")
137+
.id(principalLayoutID)
138+
}
139+
}
140+
.onChange(of: context.id) { _, _ in
141+
isEditingTitle = false
142+
draftTitle = context.name
143+
editingWidth = nil
144+
}
145+
.onChange(of: isEditingTitle) { _, newValue in
146+
if newValue {
147+
// Ensure editing font is frozen even when entering edit via keyboard
148+
editingFontSize = (context.name.count > 30 ? 15 : 17)
149+
} else {
150+
// Reset width constraint when leaving edit mode
151+
editingWidth = nil
152+
}
153+
}
154+
}
155+
156+
// MARK: - Title helpers
157+
private func commitTitle() {
158+
isEditingTitle = false
159+
let trimmed = draftTitle.trimmingCharacters(in: .whitespacesAndNewlines)
160+
if !trimmed.isEmpty && trimmed != context.name {
161+
context.name = trimmed
162+
contextManager.saveContexts()
163+
} else {
164+
// Restore draft to the current name if unchanged
165+
draftTitle = context.name
166+
}
167+
}
168+
169+
private func cancelEditTitle() {
170+
draftTitle = context.name
171+
isEditingTitle = false
172+
editingWidth = nil
173+
}
174+
}
175+
176+
// Preference key for capturing the laid-out width of the title area in display mode
177+
private struct TitleWidthKey: PreferenceKey {
178+
static var defaultValue: CGFloat = 0
179+
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
180+
value = max(value, nextValue())
181+
}
182+
}

0 commit comments

Comments
 (0)