Files
lanework/Kanban/UI/Board/LaneLayoutMath.swift
T
rzen bab456c08d Collapsible lanes — frontmatter-backed slim strips outside the width division
A lane folds to a fixed slim vertical strip carrying its glyph, its card-count
badge and its title turned on its side, and the strip is deliberately not part
of the window's division: the expanded lanes' units divide what is left once
each folded strip's fixed width has come off the top, so folding a lane is a
re-divide trigger of the Show/Hide Trash family — the window never moves and
the siblings grow into what the lane gave up.

The state is a first-class lane frontmatter key, `collapsed: true`, and
document state exactly as `width` is: the files are the board, so an agent
folds a lane by writing one key. Absent means expanded, expanding removes the
key rather than writing `false` (the remove-at-default family beside a
one-unit `width`, the empty rename's `title` and the None well's
`background`), and the lane's `width` rides along untouched so expanding
restores the lane the user had. The read is `width`'s leniency one type over —
a boolean scalar or a quoted boolean word reads as itself, everything else has
no reading at all and renders as expanded, bytes preserved either way.

Toggling is the header's always-visible collapse chevron, the lane context
menu's single Collapse Lane / Expand Lane row, and a plain click anywhere on
the strip; a modified click on the strip stays the ordinary selection grammar,
so a folded lane is still selectable by pointer. The title reads bottom-up and
is justified to the top of the room below the strip's chrome (owner ruling
2026-08-08), truncating against the strip's own height.

While folded the lane draws no cards at all, which is what makes every
exclusion true by construction rather than by a guard per gesture: no card
face means no marquee target and no navigation frame, and no registered grid
means the masonry's drop zones have nothing to resolve against. What did need
code is the half that names absolute destinations — the option-arrow jumps and
the arrow seed scan past a folded lane, the lane domain's down-arrow is inert
on one, and New Card skips it (a selection inside one falls through to the
last-active lane, the stale selection's rule). A drop on the strip appends at
the lane's end, cards and Finder files alike, with an accent edge standing in
for the shadow the strip has no masonry to open; there is no hover-to-auto-
expand yet. Lane reorder works on the strip, and a dragged folded lane carries
its fold, so its shadow and its replica are the strip rather than its units.

The write is `writeLaneWidths` clause for clause — one `updateIndex` bracket,
the same stamp behaviour, the same three do-nothing paths — with two new
`WriteOperation` cases and two new undo verbs rather than one of each, because
a banner or an Edit-menu row that said "resize" after Collapse Lane would name
a control the user never touched.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 22:53:10 -04:00

449 lines
25 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import CoreGraphics
/// The board strip's geometry, as pure arithmetic — no view, no window, no state
/// (`LaneLayoutMathTests`).
///
/// Two rules from 03-board-ui.md meet here, and they are deliberately *different* mechanisms
/// sharing one set of numbers:
///
/// - **Full visibility** (§ Layout — full visibility): the window's width divides across the lanes'
/// width units, so `standardWidth` is the whole of the resting layout. There is no horizontal
/// scroll and no minimum lane width to honour — enough units in a small window compress every
/// lane, and that is accepted rather than floored.
/// - **The right-edge drag** (§ Lane): a snap between whole unit counts that grows or shrinks the
/// *window* by one standard width per tick, so the other lanes keep their exact pixels.
/// `slotWidth`, `snappedUnits`, `resistedWidth` and `maxUnits` are that interaction's arithmetic,
/// ported from the pathfinder's proven `ColumnResizeMath` (its reasoning is reproduced below,
/// since the behaviour is what was proven, not the code).
///
/// **The two mechanisms meet at the screen's visible frame** (settled 2026-08-08, § Lane): once the
/// window can grow no further the drag stops moving it and degrades to the re-divide — the same
/// fixed width across one more unit per tick, siblings compressing, which is precisely what the
/// stepper does. `pinnedStripWidth`, `resizeStandard`, `resizeMaxUnits` and `resizeWindowDelta` are
/// that second regime; they are written so the boundary itself costs no pixels, since the re-divided
/// standard at the screen fit *is* the frozen standard.
///
/// The one behavioural difference from the pathfinder: **Lanework has no upper width cap.** A lane
/// spans any whole number of units ≥ 1, so `allowedRange`'s ceiling is not a width range but the
/// strip's own capacity (`resizeMaxUnits`) — there is no `Column.widthRange` equivalent to fold in,
/// and shrinking is always allowed.
enum LaneLayoutMath {
// MARK: - The resting layout
/// The 1× (one unit) lane width for a strip `stripWidth` points wide laying out `totalUnits`
/// whole units with `gap` between lanes **and `gap` again outside the first and the last** —
/// hence `totalUnits + 1` gaps: the `totalUnits - 1` interior ones plus the strip's two outer
/// margins. The strip therefore always exactly fills, which is what "every lane is always on
/// screen" means arithmetically (03-board-ui.md § Layout — full visibility).
///
/// **Floored at 1pt, and at nothing else.** The design is explicit that the degenerate case is
/// accepted, not floored: a minimum lane width would reintroduce horizontal scroll, which was
/// considered in the pathfinder and deliberately rejected. The 1pt floor exists only so a frame
/// is never zero or negative — the pathological input (a strip narrower than its own gaps) must
/// not produce a negative size for SwiftUI to complain about.
///
/// **Collapsed lanes are taken off the top, never divided** (03-board-ui.md § Lane ▸ Collapsed
/// lanes): each one consumes a fixed `collapsedWidth` plus the gap that follows it, and the
/// remainder is what the expanded lanes' `totalUnits` divide. So a strip of `T` units and `C`
/// collapsed strips still fills exactly — `C·collapsedWidth + T·standard + (T + C + 1)·gap` — and
/// `collapsedCount * (collapsedWidth + gap)` is that identity rearranged, which is why the
/// subtraction carries a gap with it.
///
/// Both extra arguments default to nothing, so a board with no folded lane reads exactly as it did
/// before they existed — and so does every call site that has no collapse question to ask (the
/// resize drag's two regimes, whose `startTotalUnits` is already the expanded total).
static func standardWidth(
stripWidth: CGFloat,
totalUnits: Int,
gap: CGFloat,
collapsedCount: Int = 0,
collapsedWidth: CGFloat = 0
) -> CGFloat {
let count = CGFloat(max(1, totalUnits))
let folded = CGFloat(max(0, collapsedCount)) * (collapsedWidth + gap)
return max(1, (stripWidth - folded - gap * (count + 1)) / count)
}
/// The rendered width of a `units`-unit lane: `units` standard widths plus the `units - 1`
/// interior gaps it swallows. `BoardView`'s per-lane frame is this exact expression, so a
/// snapped slot and a committed lane are the same pixels.
static func slotWidth(units: Int, standard: CGFloat, gap: CGFloat) -> CGFloat {
standard * CGFloat(units) + gap * CGFloat(units - 1)
}
/// The whole units a lane spans on screen: its `width` when that read as a valid integer, 1
/// otherwise.
///
/// `Lane.width` is a **lenient** field (01-storage-format.md § Frontmatter): a missing key or
/// a non-numeric/fractional value arrives here as `.missing` or `.malformed` and renders as
/// one unit, while an exact-integer reading below 1 (zero, negative) is no longer malformed at
/// all — it coerces to 1 at the read side (**ranges are part of the sensible reading**,
/// settled). Either way the bytes on disk are left exactly as the author wrote them until the
/// user actually changes the width, at which point the Writer replaces them with an integer
/// (`BoardStore.setLaneWidth`). The `max(1,)` is belt over braces: the read side already never
/// produces anything below 1, and this function is the single place the rest of the UI asks
/// "how many units does this lane span".
static func displayUnits(of lane: Lane) -> Int {
max(1, lane.width.value ?? 1)
}
/// **Whether the lane is drawn as a slim strip** (03-board-ui.md § Lane ▸ Collapsed lanes) — the
/// one place the rest of the app asks, so the leniency below is stated once.
///
/// `collapsed` is a **lenient** field exactly like `width` (01-storage-format.md § Frontmatter): a
/// missing key, an explicit `false`, and a value with no boolean reading at all (`.malformed`) are
/// one answer here — **expanded** — with the author's bytes left alone either way. Only a real
/// `true` folds a lane, which is what makes an unreadable value harmless rather than surprising.
///
/// A collapsed lane keeps its `width` untouched (`BoardStore.setLaneCollapsed` writes one key and
/// only one), so `displayUnits` still answers for it — and is deliberately still asked, by the
/// interior masonry the lane will draw again the moment it expands.
static func isCollapsed(_ lane: Lane) -> Bool {
lane.collapsed.value == true
}
/// How many of `lanes` are drawn as slim strips — the count `standardWidth` takes off the top.
static func collapsedCount(of lanes: [Lane]) -> Int {
lanes.count { isCollapsed($0) }
}
/// A lane's **drawn** width: its slot width when expanded, the fixed strip when collapsed. The one
/// answer every hit test and every zone list is built from, so the arithmetic can never disagree
/// with what the strip laid out (`BoardView.laneSlot` frames each lane with this).
static func drawnWidth(
units: Int,
isCollapsed: Bool,
standard: CGFloat,
gap: CGFloat,
collapsedWidth: CGFloat
) -> CGFloat {
isCollapsed ? collapsedWidth : slotWidth(units: units, standard: standard, gap: gap)
}
/// `drawnWidth` over a run of lanes, in the order given — the widths list the strip's zones and
/// hit tests walk (`DropSlotMath.laneExtents`, `laneIndex(atX:widths:gap:)`).
static func drawnWidths(
of lanes: [Lane],
standard: CGFloat,
gap: CGFloat,
collapsedWidth: CGFloat
) -> [CGFloat] {
lanes.map {
drawnWidth(
units: displayUnits(of: $0),
isCollapsed: isCollapsed($0),
standard: standard,
gap: gap,
collapsedWidth: collapsedWidth)
}
}
/// `drawnWidth` over a **dragged** run, whose fold state travels beside its unit counts rather than
/// inside a `Lane` (`DragSession.laneUnits` / `laneCollapsed`, both frozen at pickup).
///
/// A `collapsed` array shorter than `units` reads as expanded past its end, which is what a
/// `beginLanes` caller that passed none means — and the trashed-lane restore is exactly that caller.
static func drawnWidths(
units: [Int],
collapsed: [Bool],
standard: CGFloat,
gap: CGFloat,
collapsedWidth: CGFloat
) -> [CGFloat] {
units.enumerated().map { index, units in
drawnWidth(
units: units,
isCollapsed: index < collapsed.count && collapsed[index],
standard: standard,
gap: gap,
collapsedWidth: collapsedWidth)
}
}
/// The unit total a strip of `lanes` divides across — the sum of the **expanded** lanes' display
/// units, never below 1 so `standardWidth` cannot be handed a zero divisor for an empty board.
///
/// The caller decides *which* lanes: the strip passes the snapshot's, in order. There is no
/// liveness question left to ask — "Cards only. Lanes are never trashed" (03-board-ui.md §
/// Trash), so every lane the snapshot holds is a lane on screen consuming its units.
///
/// **`trashUnits` is the trash column's fixed one unit, and it is *only* consumed while shown**
/// (03-board-ui.md § Trash): the trash "spans a fixed one width unit — no `width` frontmatter,
/// and neither the stepper nor the edge drag applies — consumed only while shown". Passing it
/// here rather than fabricating a `Lane` for the trash is what keeps that true: there is no lane
/// value anywhere that a reorder, a resize or a width write could reach.
///
/// Show/Hide Trash is therefore a **re-divide trigger** and nothing more — the window is
/// untouched, and the existing width divides across one more (or one fewer) unit, exactly as a
/// lane add does (§ Layout — full visibility).
///
/// **A collapsed lane contributes nothing** (03-board-ui.md § Lane ▸ Collapsed lanes: "the strip
/// is not part of the width re-division"): its fixed width is `standardWidth`'s subtraction, not a
/// share of the division, so folding a lane away is a re-divide trigger of the same family — the
/// window is untouched and the siblings grow into the space the lane gave up.
///
/// The `max(1,)` therefore covers one more shape than it used to: a board whose **every** lane is
/// collapsed has no expanded unit at all, and the 1 it answers is a divisor guard rather than a
/// description of anything on screen. Nothing draws with that standard — the strips take their
/// fixed width and the leftover is empty board — except a shown trash column, which is a real unit
/// in the total and correctly gets the whole remainder.
static func totalUnits(of lanes: [Lane], trashUnits: Int = 0) -> Int {
let units = lanes.reduce(0) { $0 + (isCollapsed($1) ? 0 : displayUnits(of: $1)) }
return max(1, units + max(0, trashUnits))
}
// MARK: - Hit testing
/// Which lane sits under `x` in strip coordinates (0 at the strip's leading edge, outer margin
/// included) — an index into `unitCounts`, or `nil` when `x` is not over a lane at all.
///
/// **The gaps and the margins answer `nil` deliberately**, and so does everything past the last
/// lane — which is where the trash column sits. That is the whole of drag-to-restore's
/// "a drop anywhere else is a no-op" (03-board-ui.md § Trash): a drop that does not land
/// squarely on a live lane writes nothing rather than guessing at the nearest one.
///
/// Same analytic geometry as `DropSlotMath.laneExtents` — resting positions computed from
/// the unit counts, never measured frames (03-board-ui.md § Motion, "motion never feeds back
/// into logic").
static func laneIndex(atX x: CGFloat, unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> Int? {
laneIndex(
atX: x,
widths: unitCounts.map { slotWidth(units: $0, standard: standard, gap: gap) },
gap: gap)
}
/// The same hit test over **drawn** widths — the shape a strip with collapsed lanes in it has to
/// ask, since a slim strip's width is a fixed figure rather than a multiple of the standard
/// (`drawnWidths(of:standard:gap:collapsedWidth:)`).
///
/// This is the primitive and the unit-count version above is its wrapper: one walk, one origin
/// convention, so a board with no folded lane cannot answer differently from one with.
static func laneIndex(atX x: CGFloat, widths: [CGFloat], gap: CGFloat) -> Int? {
var left = gap
for (index, width) in widths.enumerated() {
if x >= left, x < left + width { return index }
left += width + gap
}
return nil
}
// MARK: - The drag's snap
/// The snapped unit count after a live-width change: `currentUnits` unless the live width has
/// moved far enough into "shadow leads" territory for an adjacent slot, in which case it ticks
/// by exactly one.
///
/// This is an ASYMMETRIC snap, not a midpoint-±-band around a boundary: the shadow (the snapped
/// count, which sizes the visible slot) leads the live edge going up and deliberately lags it
/// coming back down.
///
/// • Tick UP fires the instant the live edge clears the FAR side of the gap that trails slot
/// `k` — `liveWidth > slotWidth(k) + gap` — which is exactly where the next lane's content
/// would start. The moment the cursor has eaten the whole gap, the bigger slot is already
/// the honest read of what is under it, so the shadow jumps there right away: it never
/// lags, and the live edge can only ever overhang the shadow by at most one gap width,
/// transiently, in the instant just before a tick.
/// • Tick DOWN fires only once the live edge has retreated `reentry` (10pt, fixed) back INTO
/// that same gap — `liveWidth < slotWidth(k - 1) + gap - reentry` — rather than at the
/// mirror image of the tick-up threshold. Shrinking back the instant the edge re-enters the
/// gap it just cleared would flap the window on the smallest jitter right at the crossing;
/// requiring a real 10pt of retreat means the cursor has to mean it. (03-board-ui.md § Lane
/// names exactly this: "shadow snaps at the inter-column gap with 10pt release hysteresis".)
///
/// `reentry` is the ONLY hysteresis in this design — it exists to give the tick-down threshold
/// room, not to make the two thresholds symmetric. Stability follows from the two thresholds
/// never meeting: for shadow `k` the hold band is `(slotWidth(k - 1) + gap - reentry,
/// slotWidth(k) + gap]`, which stays non-empty as long as `standard` is many times larger than
/// 10pt (true of every lane width a real window produces), so calling this on every drag event
/// never oscillates.
///
/// Ticks are capped to `allowedRange`, whose ceiling is the strip's capacity (`resizeMaxUnits`)
/// — there is no width cap to respect (03-board-ui.md § Lane: "1×, 2×, 3×, … — no cap"), and
/// past the screen fit the tick keeps firing, re-dividing rather than growing the window.
///
/// **`slotFor` rather than a standard**, because the standard is no longer one number for the
/// whole gesture: beyond the screen fit each further unit re-divides the pinned strip, so slot
/// `k` and slot `k + 1` are measured against *different* standards (`resizeStandard`). The
/// thresholds are unchanged in form — they simply ask the caller how wide each slot would be.
/// The band stays non-empty in the re-divide too: the slots still grow strictly with `k`, since
/// a unit added to a lane takes more from the strip than the re-divide gives back.
static func snappedUnits(
liveWidth: CGFloat,
currentUnits: Int,
slotFor: (Int) -> CGFloat,
gap: CGFloat,
allowedRange: ClosedRange<Int>,
reentry: CGFloat
) -> Int {
if currentUnits < allowedRange.upperBound, liveWidth > slotFor(currentUnits) + gap {
return currentUnits + 1
}
if currentUnits > allowedRange.lowerBound {
if liveWidth < slotFor(currentUnits - 1) + gap - reentry {
return currentUnits - 1
}
}
return currentUnits
}
/// The same snap where every slot is measured against one standard — the whole of the gesture
/// below the screen fit, and the shape the hit-testing and layout call sites think in.
static func snappedUnits(
liveWidth: CGFloat,
currentUnits: Int,
standard: CGFloat,
gap: CGFloat,
allowedRange: ClosedRange<Int>,
reentry: CGFloat
) -> Int {
snappedUnits(
liveWidth: liveWidth,
currentUnits: currentUnits,
slotFor: { slotWidth(units: $0, standard: standard, gap: gap) },
gap: gap,
allowedRange: allowedRange,
reentry: reentry)
}
/// The live width rubber-banded to stay near the allowed slot range: inside `[minSlot,
/// maxSlot]` the proposed width passes through untouched; beyond either end only `resistance`
/// (0.25) of the overshoot is applied, so the edge visibly resists but still gives, signalling
/// the bound without a hard stop (03-board-ui.md § Lane: the rubber band "moves to the true end
/// of travel — the strip's own capacity"). The snap tick never follows the width past the bound
/// (see `snappedUnits`' clamp), so this is purely cosmetic give.
static func resistedWidth(
proposed: CGFloat,
minSlot: CGFloat,
maxSlot: CGFloat,
resistance: CGFloat
) -> CGFloat {
if proposed < minSlot { return minSlot - (minSlot - proposed) * resistance }
if proposed > maxSlot { return maxSlot + (proposed - maxSlot) * resistance }
return proposed
}
/// The largest unit count that fits on screen: `currentUnits` plus as many whole `step`
/// (= standard + gap) growths as the window has room to expand into before its right edge would
/// pass the screen's visible frame. **Never less than `currentUnits`** — shrinking is always
/// allowed regardless of screen room, including from a window already hanging off the edge
/// (negative headroom).
///
/// Unlike the pathfinder's twin this is **not the tick's ceiling** — it is the boundary where
/// one mechanism hands over to the other (settled 2026-08-08, 03-board-ui.md § Lane). Up to it
/// the drag grows the window and the siblings keep their pixels; past it the window is spent and
/// each further tick re-divides instead (`resizeStandard`), which is how a lane keeps growing at
/// the siblings' expense on a full screen. A window with no headroom at all answers
/// `currentUnits`, so the very first tick is already a re-divide.
///
/// Pure so it can be unit-tested; the session computes `headroom` from the live window and its
/// screen and defers the arithmetic here.
static func maxUnits(currentUnits: Int, headroom: CGFloat, step: CGFloat) -> Int {
guard step > 0, headroom.isFinite else { return currentUnits }
let extra = Int(floor(max(0, min(headroom, CGFloat(Int.max) / 2)) / step))
return max(currentUnits, currentUnits + extra)
}
// MARK: - The drag past the screen: the re-divide
/// The strip's width once the window has grown as far as its screen allows — the width every
/// tick past the screen fit re-divides, since the window is pinned from there on.
///
/// The drag-start width is **derived, not measured**: the strip always fills exactly
/// (`standardWidth`), so a frozen standard `s` over `T` units means a strip of `s·T + g·(T + 1)`
/// and nothing else. Growth adds one `s + g` step per unit of on-screen headroom. Deriving it is
/// what makes the boundary free: a measured viewport width, carrying whatever half-point the
/// layout rounded to, would put a visible step where the two regimes meet.
static func pinnedStripWidth(
startUnits: Int,
startStandard: CGFloat,
startTotalUnits: Int,
fittingUnits: Int,
gap: CGFloat
) -> CGFloat {
let total = CGFloat(max(1, startTotalUnits))
let atStart = startStandard * total + gap * (total + 1)
return atStart + CGFloat(max(0, fittingUnits - startUnits)) * (startStandard + gap)
}
/// The strip's standard (1×) width part-way through a right-edge drag, for a dragged lane
/// spanning `units` — the whole of the drag's two regimes in one function (03-board-ui.md
/// § Lane, settled 2026-08-08).
///
/// At or below the screen fit the answer is the standard frozen at drag start: the window takes
/// the step, so the division is unchanged and every other lane keeps its exact pixels. Past the
/// fit the window is pinned, so each further unit the dragged lane claims is one more unit the
/// same `pinnedStripWidth` has to divide across — the siblings compress, which is the stepper's
/// mechanism arriving under the drag's fingers.
///
/// **The regimes meet with no pixel jump.** At `units == fittingUnits` the pinned width divided
/// across its own unit total is `startStandard` exactly, by the exact-fill identity
/// `pinnedStripWidth` is built from — the same reason the release hands back to the resting
/// layout without a flinch (`LaneResizeSession`).
static func resizeStandard(
forUnits units: Int,
startUnits: Int,
startStandard: CGFloat,
startTotalUnits: Int,
fittingUnits: Int,
gap: CGFloat
) -> CGFloat {
guard units > fittingUnits else { return startStandard }
return standardWidth(
stripWidth: pinnedStripWidth(
startUnits: startUnits, startStandard: startStandard,
startTotalUnits: startTotalUnits, fittingUnits: fittingUnits, gap: gap),
totalUnits: max(1, startTotalUnits) + (units - startUnits),
gap: gap)
}
/// The drag's ceiling — the true end of travel, which past the screen fit is a question about
/// the strip's capacity rather than about the screen.
///
/// The re-divide can always take one more unit, but not usefully forever: at some total
/// `standardWidth`'s 1pt floor engages and the strip stops filling exactly, which is the point
/// where the arithmetic stops describing anything on screen. That total is the largest `T`
/// satisfying `(W g·(T + 1)) / T ≥ 1`, i.e. `floor((W g) / (1 + g))`, and the dragged lane's
/// ceiling is that total read back through the units it contributed. This is where the rubber
/// band now sits (§ Lane: "the true end of travel — the strip's own capacity").
///
/// **Never below the screen fit**, and so never below the count the drag started from —
/// shrinking is always allowed. A degenerate strip (a non-finite standard, a gap at or below
/// 1pt, a capacity under one whole unit) falls back to the fit rather than inventing a bound.
static func resizeMaxUnits(
startUnits: Int,
startStandard: CGFloat,
startTotalUnits: Int,
fittingUnits: Int,
gap: CGFloat
) -> Int {
let fit = max(startUnits, fittingUnits)
guard startStandard.isFinite, gap.isFinite, gap > -1 else { return fit }
let width = pinnedStripWidth(
startUnits: startUnits, startStandard: startStandard,
startTotalUnits: startTotalUnits, fittingUnits: fittingUnits, gap: gap)
guard width.isFinite else { return fit }
let capacity = (width - gap) / (1 + gap)
guard capacity >= 1 else { return fit }
let total = Int(min(capacity, CGFloat(Int.max) / 2).rounded(.down))
return max(fit, startUnits + (total - max(1, startTotalUnits)))
}
/// How far the window moves on a tick from `oldUnits` to `newUnits`: one `step` per unit of that
/// change lying **inside** the screen fit, and nothing at all for the part beyond it, where the
/// re-divide has taken over and the window is pinned (03-board-ui.md § Lane).
///
/// A difference of clamped counts rather than a per-step walk, because a flick can cross the
/// boundary in one gesture event and the two sides must net out exactly: `F 1 → F + 2` is one
/// step (only the first unit was ever the window's to give), `F + 2 → F + 5` is none, and
/// `F + 2 → F 1` hands that one step back. Shrinking mirrors growing by construction.
static func resizeWindowDelta(
from oldUnits: Int,
to newUnits: Int,
fittingUnits: Int,
step: CGFloat
) -> CGFloat {
CGFloat(min(newUnits, fittingUnits) - min(oldUnits, fittingUnits)) * step
}
}