Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 58 additions & 53 deletions graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,14 +123,21 @@ struct ArtifactorySection: View {
// in a list that can be taller than the rail — it could open scrolled out of
// sight, and it moved under the pointer as posts arrived. Pinned here it is
// always the thing directly above the section's rule while you are writing.
if isComposing {
composer
} else {
leaveANoteButton
}
leaveANoteButton
}
Rectangle().fill(.white.opacity(0.07)).frame(height: 1)
}
// A sheet, not a field in the rail — the only shape text entry has ever worked in
// this app, and now it is clear why. Ghostty's `NSView` holds the window's first
// responder while a loop is open, and `@FocusState` cannot take it off an AppKit
// view inside the same window: the caret never arrived, so every keystroke went to
// the terminal and the draft stayed empty. A sheet gets its own key window, which
// is why `JumpPaletteView` focuses with a bare `onAppear` and why the rename and
// delete prompts are hosted the same way. See `AppView`'s note on ⌘K opening over
// a terminal as readily as over a canvas.
.sheet(isPresented: $isComposing) {
composerSheet
}
}

private var unreadIDs: Set<Int> {
Expand Down Expand Up @@ -327,67 +334,65 @@ struct ArtifactorySection: View {

// MARK: - Composing

/// A human's voice on the board. Anchored at the foot, where the post will land.
private var composer: some View {
VStack(alignment: .leading, spacing: 7) {
TextField("topic (optional)", text: $draftTopic)
.textFieldStyle(.plain)
.font(.system(size: 10.5))
.foregroundStyle(.white.opacity(0.75))
TextField("a note for whoever comes next", text: $draft, axis: .vertical)
.textFieldStyle(.plain)
.font(.system(size: 12.5))
.foregroundStyle(.white.opacity(0.92))
.lineLimit(2...6)
.focused($draftFocused)
/// A human's voice on the board.
private var composerSheet: some View {
VStack(spacing: 12) {
Text("Leave a note").font(.headline)

Text(
"""
Every loop in this project reads this board, including loops that do not exist \
yet. Post what a peer or a successor should not have to rediscover — a dead end, \
a decision, a claim you are staking.
"""
)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)

Form {
TextField("Topic (optional)", text: $draftTopic, prompt: Text("claims, build, findings"))
.autocorrectionDisabled()
TextField("Note", text: $draft, axis: .vertical)
.lineLimit(3...10)
.focused($draftFocused)
}
.formStyle(.columns)
.fixedSize(horizontal: false, vertical: true)

HStack(spacing: 6) {
Image(systemName: "person")
.font(.system(size: 8))
.foregroundStyle(.white.opacity(0.42))
Text("posting as a human")
.font(.system(size: 10))
.foregroundStyle(.white.opacity(0.42))
.foregroundStyle(.secondary)
Text("Posts as “a human” — the app carries no loop identity.")
.font(.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
// Shown only as the bound approaches: a counter on an empty field is chrome,
// and the daemon refuses anything over this anyway.
// Only as the bound approaches: the daemon refuses anything over it, and a
// counter on an empty field is chrome.
if remainingBytes <= 120 {
Text("\(remainingBytes)")
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(remainingBytes < 0 ? .red : .white.opacity(0.5))
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(remainingBytes < 0 ? .red : .secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)

HStack {
Button("Cancel") { cancelCompose() }
.buttonStyle(.plain)
.font(.system(size: 10.5))
.foregroundStyle(.white.opacity(0.5))
.keyboardShortcut(.cancelAction)
Spacer()
Button("Post") { submit() }
.buttonStyle(.plain)
.font(.system(size: 10.5, weight: .semibold))
.foregroundStyle(
canPost
? AnyShapeStyle(Color(red: 0.549, green: 0.773, blue: 1.0))
: AnyShapeStyle(.white.opacity(0.3))
)
.keyboardShortcut(.defaultAction)
.disabled(!canPost)
.keyboardShortcut(.return, modifiers: .command)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 9)
.background(.white.opacity(0.045), in: RoundedRectangle(cornerRadius: 9))
.overlay {
RoundedRectangle(cornerRadius: 9)
.stroke(Theme.paneFocusTint.opacity(0.45), lineWidth: 1)
}
// A `@FocusState` write only lands on a field that is *already* in the view tree.
// Setting it in the same transaction that creates the composer — which is what the
// + button used to do — is dropped on the floor, and with the rail unfocused every
// keystroke goes to the terminal instead: the draft stays empty, so Post stays
// disabled and the section looks like it does nothing. `onAppear` is a transaction
// too, hence the hop: the field exists by the time this runs.
.onAppear {
DispatchQueue.main.async { draftFocused = true }
}
.padding(24)
.frame(width: 420)
// No run-loop hop needed, unlike the rail: the sheet's own window is key by the
// time this runs, which is the whole reason the composer moved into one.
.onAppear { draftFocused = true }
}

private var trimmedDraft: String {
Expand Down
21 changes: 13 additions & 8 deletions graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift
Original file line number Diff line number Diff line change
Expand Up @@ -195,14 +195,6 @@ struct LoopWorkspaceRail: View {
node: node, isFolded: isBoardFolded, onToggleFold: onBoardFoldToggled,
onExpand: onBoardExpanded)
}
// Above `THIS LOOP` for the same reason the summary is: what other loops have
// said to you outranks where you sit in the graph, and a section you have to
// scroll to is a section that answers nothing at a glance.
if ArtifactoryPresentation.hasContent(graph: graph, enabled: artifactoryEnabled) {
ArtifactorySection(
node: node, graph: graph, isFolded: isArtifactoryFolded,
onToggleFold: onArtifactoryFoldToggled, onPost: onArtifactoryPost)
}
section("THIS LOOP") {
RailMinimap(node: node, upstream: inbound, downstream: outbound.map(\.target))
}
Expand All @@ -219,6 +211,19 @@ struct LoopWorkspaceRail: View {
section("RECENT PASSES") { RailSparkline(node: node) }
}
Spacer(minLength: 0)
// The board sits at the foot, under the spacer, so the rail's slack collects
// *above* it rather than inside it. It went in above `THIS LOOP` first, on the
// reasoning that what other loops said to you outranks where you sit in the
// graph — but the sections above it are fixed-height and the board is not, so a
// quiet rail left it stranded at the top with everything it says a long way from
// the button that answers it. Low and against the footer is also where a message
// board belongs: newest at the bottom, composer under it, the way every other
// thing you read messages in is arranged.
if ArtifactoryPresentation.hasContent(graph: graph, enabled: artifactoryEnabled) {
ArtifactorySection(
node: node, graph: graph, isFolded: isArtifactoryFolded,
onToggleFold: onArtifactoryFoldToggled, onPost: onArtifactoryPost)
}
footer
}
.padding(12)
Expand Down
Loading