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
4 changes: 2 additions & 2 deletions App/Document/DocumentCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,9 @@ extension DrawingDocument {
set { UserDefaults.standard.set(newValue, forKey: snapGridDefaultsKey) }
}

/// ⇧⌘C opens the swatch popover. In canvas-first there is no permanent
/// ⇧⌘C opens the system colour panel. In canvas-first there is no permanent
/// colour surface, so the keystroke is the guaranteed way in.
@IBAction func showColours(_ sender: Any?) { model.isColourPopoverRequested = true }
@IBAction func showColours(_ sender: Any?) { model.presentSystemColourPicker(for: .foreground) }

// MARK: - Image

Expand Down
11 changes: 10 additions & 1 deletion App/Document/DrawingDocument.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ final class DrawingDocument: NSDocument {

let window = NSWindow(contentViewController: hosting)
window.setContentSize(sized.contentSize)
window.minSize = NSSize(width: 560, height: 420)
window.minSize = Self.minimumContentSize
window.title = displayName

// Canvas-first: the artwork runs to all four edges, so the titlebar is
Expand Down Expand Up @@ -188,6 +188,15 @@ final class DrawingDocument: NSDocument {
addWindowController(controller)
}

/// The narrowest window this app will make.
///
/// Stated once, so the header's shed-ladder can be checked against the number
/// the window is actually built from rather than against a copy of it. It was
/// a literal at the call site, and the header needed 647pt to draw itself with
/// *no filename in it* — the app shipped a window its own chrome could not fit
/// and nothing noticed, because nothing had ever compared the two.
static let minimumContentSize = NSSize(width: 560, height: 420)

/// The window size and zoom a canvas of `size` wants.
///
/// Shared by the open path and the grow path so a window opened at 1000×640
Expand Down
31 changes: 8 additions & 23 deletions App/Model/EditorModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,32 +141,19 @@ final class EditorModel {
isOptionsExpanded = true
}

var foreground: PaintColour {
didSet {
engine.colours.foreground = foreground
rememberColour(foreground)
}
}
var foreground: PaintColour { didSet { engine.colours.foreground = foreground } }

var background: PaintColour { didSet { engine.colours.background = background } }

var palette: Palette = .standard

/// Colours used recently that are not already in the fixed palette.
///
/// The 28 swatches are muscle memory and must not move, so custom colours
/// get their own short row in the popover instead of displacing them. Kept
/// out of the rail deliberately: they arrive unpredictably, and a toolbar
/// that changes size while you work moves the button you were reaching for.
/// Most recent first.
private(set) var recentColours: [PaintColour] = []

private func rememberColour(_ colour: PaintColour) {
guard !palette.swatches.contains(colour) else { return }
recentColours.removeAll { $0 == colour }
recentColours.insert(colour, at: 0)
if recentColours.count > 8 { recentColours.removeLast() }
}
// **Recent colours are the system's, not ours.** This model used to keep its
// own list of eight, rendered in one place: the bespoke colour popover. That
// popover is gone — the system panel it half-copied is better at every part of
// the job — and `NSColorPanel` already carries a recently-used row that
// persists across quitting and across every app on the Mac. Ours was
// per-document and died with the window. Two lists of the same thing, and the
// one that survived is the one that survives.

/// A stable identity for this model, so the shared colour panel knows which
/// document currently owns it.
Expand Down Expand Up @@ -284,8 +271,6 @@ final class EditorModel {
/// Turning it on draws the grid too. Snapping you cannot see is a drag that
/// disobeys you for reasons you have to guess.
var snapGrid: Int = 0 { didSet { engine.settings.snapGrid = snapGrid } }
/// Set by ⇧⌘C; the cluster's colour well observes it and opens.
var isColourPopoverRequested: Bool = false
var isSizeSheetPresented: Bool = false

var presentedError: PresentableError?
Expand Down
100 changes: 96 additions & 4 deletions App/UI/CanvasOverlays.swift
Original file line number Diff line number Diff line change
Expand Up @@ -520,8 +520,78 @@ struct TitlebarScrim: View {
/// not tools. The rail is a list of things that make marks; the clipboard acts on
/// the document, like undo and zoom, and grouping it with them is what keeps the
/// rail a short list you can scan.
/// **What a window of a given width can carry, and what it drops first.**
///
/// The rail sheds palette columns before it scrolls, for the reason it states: a
/// control below the fold of an indicator-less scroll view is a control nobody
/// can tell is missing. The header had no ladder at all — it simply ran off the
/// right edge — and `DrawingDocument.minimumContentSize` is 560pt against a row
/// that needs 647pt with *no filename in it*. The app shipped a window its own
/// header could not fit.
///
/// Every rung sheds a run whose commands have another door: a chord, a menu-bar
/// item, or a bar this app already puts on screen. Nothing that is the *only* way
/// to do something is ever shed — which inverts the obvious answer. Cut, copy and
/// paste go before the drag-out handle, because ⌘X/⌘C/⌘V are three chords every
/// Mac already has and `SelectionActions` restates two of them on screen the
/// moment there is a selection, while the drag-out handle has no chord and no menu
/// item anywhere. Undo and redo, the zoom read-out, Share and the filename are
/// never shed.
///
/// Declaration order is the ladder, widest first, so a window that has grown
/// climbs back to the rung it fell from.
enum HeaderFit: CaseIterable {
/// `noMenus` is the rung *below* the window floor, and that is its whole job:
/// `dragOutOnly` fits 560pt with one point to spare, which is not a margin. It
/// is what the ladder falls to if the window floor ever drops or a group ever
/// gains a cell, so the failure mode is a shed control rather than a clipped
/// one.
case full, trailing, zoomReadoutOnly, shareOnly, dragOutOnly, noMenus

var showsZoomSteppers: Bool { self == .full || self == .trailing }
var showsLastActions: Bool { showsZoomSteppers || self == .zoomReadoutOnly }
var showsClipboardRun: Bool { self != .dragOutOnly && self != .noMenus }
var showsMenus: Bool { self != .noMenus }

var workingWidth: CGFloat {
let groups = [
showsClipboardRun ? Tokens.Header.clipboard : Tokens.Header.dragOnly,
Tokens.Header.history,
showsMenus ? Tokens.Header.menus : nil,
showsZoomSteppers ? Tokens.Header.zoom : Tokens.Header.zoomOnly,
].compactMap { $0 }
return groups.reduce(0, +) + CGFloat(groups.count - 1) * Tokens.Space.tight
}

var documentWidth: CGFloat {
showsLastActions ? Tokens.Header.document : Tokens.Header.shareOnly
}

/// The narrowest window this arrangement fits, with the filename still
/// getting `Tokens.Header.titleRoom` of it.
var minimumWindow: CGFloat {
guard self == .full else {
return Tokens.Header.surround + workingWidth + documentWidth + Tokens.Header.titleRoom
}
// Measured about the window's midline, because the centred cluster is
// centred on the *window* rather than between its neighbours: everything
// the title needs on its side of the midline is doubled.
return (Tokens.Space.comfortable * 3 + Tokens.Chrome.trafficLightClearance
+ Tokens.Header.titleRoom + Tokens.Space.base + workingWidth / 2) * 2
}

/// The widest arrangement a window of `width` can carry.
///
/// The last rung is returned whether it fits or not: there is nothing below
/// it, and clipping is the one outcome this ladder exists to prevent.
static func fitting(_ width: CGFloat) -> HeaderFit {
allCases.first { width >= $0.minimumWindow } ?? .noMenus
}
}

struct WorkingActions: View {
@Bindable var model: EditorModel
var fit: HeaderFit = .full

var body: some View {
// Undo lives in the UI-free engine, so its computed flags are not
Expand All @@ -531,6 +601,7 @@ struct WorkingActions: View {

HStack(spacing: Tokens.Space.tight) {
HeaderGroup {
if fit.showsClipboardRun {
HeaderButton(
symbol: "scissors", title: "Cut",
shortcut: "⌘X", isEnabled: model.hasSelection
Expand Down Expand Up @@ -561,6 +632,9 @@ struct WorkingActions: View {
: "Copy an image somewhere first",
isEnabled: model.canPaste
) { model.paste() }
}
// Never shed: the one control in this row with no chord and no
// menu item. Everything above it is ⌘X/⌘C/⌘V.
DragOutHandle(model: model)
}

Expand All @@ -578,16 +652,20 @@ struct WorkingActions: View {
// What the picture is, and how you are looking at it. Between the
// clipboard and the history because that is the order of the work:
// get something in, change it, look at it, undo it.
HeaderGroup {
ImageMenu(model: model)
ViewMenu(model: model)
if fit.showsMenus {
HeaderGroup {
ImageMenu(model: model)
ViewMenu(model: model)
}
}

HeaderGroup {
if fit.showsZoomSteppers {
HeaderButton(
symbol: "minus", title: "Zoom out", shortcut: "⌘−",
isEnabled: model.zoom > (EditorModel.zoomSteps.first ?? 1)
) { model.zoomOut() }
}

// **The percentage is the button.** There used to be a separate
// `arrow.up.left.and.arrow.down.right` cell for Actual Size,
Expand All @@ -597,15 +675,20 @@ struct WorkingActions: View {
// *full screen*, so the pair managed to be both redundant and
// misleading. What is left is a real button whose label is the
// current zoom and whose action is 100%.
// Never shed: the only place the window states its zoom, and the
// Actual Size button. The two steppers either side of it have
// ⌘+, ⌘−, pinch, ⌘-scroll and the View menu.
ZoomReadout(label: zoomLabel, isActualSize: model.zoom == 1) {
model.hasUserZoomed = true
model.zoom = 1
}

if fit.showsZoomSteppers {
HeaderButton(
symbol: "plus", title: "Zoom in", shortcut: "⌘+",
isEnabled: model.zoom < (EditorModel.zoomSteps.last ?? 1)
) { model.zoomIn() }
}
}
}
}
Expand All @@ -624,12 +707,14 @@ struct WorkingActions: View {
/// press forty times an hour.
struct DocumentActions: View {
@Bindable var model: EditorModel
var fit: HeaderFit = .full

@Environment(TooltipController.self) private var tooltips
@State private var shareFrame: CGRect = .zero

var body: some View {
HeaderGroup {
if fit.showsLastActions {
// **Signing has a button now.** It lived only in the Tools menu
// under ⌃⌘S — a chord nothing else in the app uses — which made the
// one feature nobody would guess at the one feature the window never
Expand All @@ -645,6 +730,7 @@ struct DocumentActions: View {
}

HeaderDivider()
}

// Share is in the File menu, but a markup app's whole purpose is
// getting the result to someone else — burying its most common last
Expand All @@ -666,6 +752,11 @@ struct DocumentActions: View {
)
: tooltips.endHover(key: "header-share")
}

// Everything after Share is the last two minutes of a session, and
// every one of them has a chord and a menu-bar item. Share does not
// go: getting the result to someone else is what this app is for.
if fit.showsLastActions {
HeaderButton(
symbol: "doc.on.doc", title: "Duplicate", shortcut: "⇧⌘S",
detail: "Opens a copy in a new window"
Expand All @@ -689,6 +780,7 @@ struct DocumentActions: View {
) {
AppCommandsBridge.openGuide()
}
}
}
}
}
Expand Down Expand Up @@ -947,7 +1039,7 @@ struct ViewMenu: View {
model.chromeEdge = model.chromeEdge.toggled
}
Divider()
Button("Colours…") { model.isColourPopoverRequested = true }
Button("Colours…") { model.presentSystemColourPicker(for: .foreground) }
}
}

Expand Down
Loading
Loading