import "package:flutter/foundation.dart" show TargetPlatform, defaultTargetPlatform; import "package:flutter/widgets.dart"; import "package:garage_ui/theme/colour_scheme.dart"; import "package:garage_ui/theme/typography.dart"; /// The two control tiers. One type for both levels: hand it to [ThemeData] to /// set the app-wide density, or to any control to override that control. /// /// Replaces the pair this used to be - a `DensityMode` enum on the theme and a /// separate `ControlDensity` class on widgets - which modelled the same two /// values twice, in two shapes, and named the widget-level one after a single /// widget despite Select, Menubar, MenuButton, MenuPopup, TextField and /// TabView all taking it. enum ControlDensity { compact, normal; bool get isCompact => this == ControlDensity.compact; /// The canonical token set for this tier, ignoring any theme tuning. Density get canonical => isCompact ? const Density.compact() : const Density.normal(); /// The tier the theme is currently set to. static ControlDensity of(ThemeData theme) => theme.density.control; /// The token set a control on this tier should read. /// /// When the tier matches the theme's, the theme's own [Density] is used, so /// a tuned one survives. When a control asks for the other tier, it falls /// back to that tier's canonical tokens. Density tokens(ThemeData theme) => this == theme.density.control ? theme.density : canonical; EdgeInsets resolve(ThemeData theme) => tokens(theme).buttonPadding; /// Padding for an icon-only control, collapsed to its smallest side so the /// control comes out square rather than inheriting the asymmetric /// horizontal padding. EdgeInsets resolveIcon(ThemeData theme) => _squarePadding(resolve(theme)); } EdgeInsets _squarePadding(EdgeInsets p) { final side = p.vertical < p.horizontal ? p.vertical / 2 : p.horizontal / 2; return EdgeInsets.all(side); } /// The app-wide UI density. /// /// ONE RULE: the fields on this class are the only hand-set numbers in the /// control system. Everything else - line box, icon size, every vertical /// padding, every control height - is a getter derived from them. If you find /// yourself typing a pixel height anywhere else, it belongs here instead. /// /// The heights are the configurable part. You say how tall a control should be /// and the padding falls out of it: /// /// lineBox = fontSize * lineHeight /// controlPaddingY = (controlHeight - lineBox) / 2 /// /// NOT the other way round. Tuning padding until a height came out right is /// what produced four different control heights, a `Transform.translate` in two /// widgets, and 23/27 re-typed as literals in the explorer. /// /// `lineBox` needs no font metrics: when a TextStyle carries an explicit /// `height`, Flutter sizes the line box to exactly `fontSize * height` and does /// not consult the font. That is what makes this whole chain deterministic and /// testable - a style with a null `height` measures 1.0x in the test host and /// ~1.25x in the app, which is how a ~26px TextField once passed a suite of /// tests that all asserted 23. class Density { const Density({ this.control = ControlDensity.compact, // ---- type ---- this.fontSize = 10.0, this.lineHeight = 1.1, // ---- height targets (configure these; paddings derive from them) ---- this.controlHeight = 23.0, this.menuRowHeight = 20.0, this.popupRowHeight = 20.0, this.popupMaxRows = 12, this.explorerRowHeight = 22.0, // ---- horizontal + misc primitives ---- this.buttonPaddingX = 10.0, this.controlGap = 4.0, this.controlBorderWidth = 1.0, this.explorerRowBasePadding = 4.0, this.explorerRowEndPadding = 10.0, this.listRowIndent = 6.0, // ---- container-level spacing (panels, popovers, dialogs) ---- this.containerGap = 8.0, this.containerPadding = 16.0, // ---- optical ---- this.controlTextOffset = 0.0, }); /// The compact tier, said out loud. Same as the unnamed constructor's /// defaults - which is exactly why it exists, because `Density()` silently /// meaning "compact" was a trap. const Density.compact() : this(); const Density.normal() : control = ControlDensity.normal, fontSize = 10.0, lineHeight = 1.1, controlHeight = 27.0, menuRowHeight = 26.0, popupRowHeight = 26.0, popupMaxRows = 12, explorerRowHeight = 30.0, buttonPaddingX = 12.0, controlGap = 5.0, controlBorderWidth = 1.0, explorerRowBasePadding = 8.0, explorerRowEndPadding = 14.0, listRowIndent = 12.0, containerGap = 10.0, containerPadding = 20.0, controlTextOffset = 0.0; /// The product tier - for the Garage web apps rather than for A&A. /// /// compact and normal are both answers to "a properties inspector has to sit /// beside a viewport without eating it", which is why they share a 10px font /// and differ only in control geometry. An app with no canvas inherits that /// economy for nothing, so this tier scales the TYPE as well: 12px control /// font, 33px controls, and a gap ladder off a base of 5. /// /// `control` stays [ControlDensity.normal] deliberately. That enum is the /// two-way geometry switch widgets branch on (`isCompact`), not a name for /// the tier, and A&A switches over it exhaustively in its settings UI. /// /// 12 * 1.25 = 15 line box, (33 - 15) / 2 = 9 padding. both whole, so the /// control geometry stays exact - see [lineBox]. const Density.product() : control = ControlDensity.normal, fontSize = 12.0, lineHeight = 1.25, controlHeight = 33.0, menuRowHeight = 33.0, popupRowHeight = 33.0, popupMaxRows = 10, explorerRowHeight = 29.0, buttonPaddingX = 10.0, controlGap = 5.0, controlBorderWidth = 1.0, explorerRowBasePadding = 8.0, explorerRowEndPadding = 12.0, listRowIndent = 10.0, containerGap = 10.0, containerPadding = 20.0, controlTextOffset = 0.0; /// Which tier this token set represents. final ControlDensity control; // ---- type ---- /// Control font size. The app is a fixed compact desktop tool, so these are /// final pixel values and are deliberately NOT multiplied by `scaling`. final double fontSize; /// Line box as a multiple of [fontSize]. Pinning this is what makes control /// geometry font-independent - see the class doc. final double lineHeight; // ---- height targets ---- /// Button / text field / select trigger / icon button. final double controlHeight; /// Menu rows (MenuButton and friends). /// /// Happens to equal [popupRowHeight] in both densities today - menus and /// select popups are the same kind of surface. Kept as two tokens anyway so /// one can move without dragging the other along. final double menuRowHeight; /// Rows inside a select popup. final double popupRowHeight; /// How many rows a select popup shows before it starts scrolling. A count, /// not a pixel value - the height falls out of it via [popupMaxHeight], so a /// denser popup gets shorter rather than showing more of them. final int popupMaxRows; /// Explorer tree rows. final double explorerRowHeight; // ---- horizontal + misc ---- final double buttonPaddingX; /// Gap between a control's leading/trailing icon and its label. Sits INSIDE /// the control, so it is tighter than the outer padding on purpose. final double controlGap; final double controlBorderWidth; /// Leading indent applied per depth level in the explorer tree. final double explorerRowBasePadding; /// Trailing inset on an explorer row, so the hide toggle isnt sat right /// under the scrollbar thumb. final double explorerRowEndPadding; /// Leading inset on a flat list row, before its icon. Distinct from /// [explorerRowBasePadding], which is a per-depth indent in a tree. /// /// Authored from the values the Lines slots list was already branching on by /// hand. It and the explorer disagree about this inset (6/12 vs 4/8) and /// always have - reconciling them is a visual decision, not a refactor. final double listRowIndent; // ---- container-level ---- /// Spacing between elements in a panel / popover / dialog. This is layout /// spacing, NOT control-internal spacing - reach for [controlGap] inside a /// control. (Replaces the old shadcn-inherited `baseGap`.) final double containerGap; /// Padding inside a panel / popover / dialog. (Replaces `baseContentPadding` /// and `baseContainerPadding`, which always held the same value.) final double containerPadding; // ---- optical ---- /// Downward nudge applied to control content so it sits on its optical /// centre rather than its geometric one. Negative moves it up. /// /// This is the one value the maths cannot settle on its own - the line box is /// exact, but where the ink sits inside it depends on the font's /// ascent/descent split. So it is a judgement made by eye, once, here. It /// used to be `Offset(0, 1)` hardcoded in Button and `Offset(0, 2)` in /// Select, which is two judgements that disagreed. /// /// Currently 0 - i.e. the geometric centre is what looks right in Geist at /// this size. Keep the token even so: it is the knob, and a zero here costs /// nothing because the controls skip the transform entirely when it is 0. final double controlTextOffset; // ========================================================================= // derived - do not hand-set any of these, and do not re-derive them at a // call site // ========================================================================= /// Height of one line of control text: `fontSize * lineHeight`, rounded to a /// whole pixel. /// /// Measured, not assumed. Against Georgia and Andale Mono (both natural ratio /// 1.10) and the test host's fallback (1.00): when the product is a whole /// number the engine lays the line box out at exactly that in all three, so /// the font's own ascent/descent genuinely do not participate. That is what /// makes control geometry font-independent and testable. /// /// When the product is fractional the engine rounds it, and at a .5 tie the /// direction is FONT-DEPENDENT (13.5 came out 14 in Andale, 13 in Georgia). /// So keep `fontSize * lineHeight` on a whole number - 10 * 1.1 = 11 does - /// and every height below is exact. The rounding here keeps the derivation /// honest for other configs rather than quietly missing the target by a /// fraction of a pixel. double get lineBox => (fontSize * lineHeight).roundToDouble(); /// Control icons, sized against the TEXT rather than the line box. /// /// This used to be `lineBox` - 15px at product - on the reasoning that an /// icon-only control and a text control then come out the same height for /// free. That holds, but it isnt what the eye measures: lucide glyphs fill /// their box nearly edge to edge while a 12px font has a cap height around /// 8.5px, so a line-box icon reads about 70% taller than the letters beside /// it and every button with an icon in it looked slightly wrong. /// /// Level with the font. 1.1x was the first attempt at the optical match and /// still read a shade heavy next to the label beside it. Control HEIGHT is /// unaffected either way: that comes from controlHeight, not from whats /// inside. double get iconSize => fontSize; double get controlPaddingY => (controlHeight - lineBox) / 2; double get menuRowPaddingY => (menuRowHeight - lineBox) / 2; double get popupRowPaddingY => (popupRowHeight - lineBox) / 2; double get explorerRowPaddingY => (explorerRowHeight - lineBox) / 2; /// Height of a chrome bar - an editor's header or footer. /// /// One [controlGap] above the control and one below, which is [gapMd] all /// told. A bar sized any tighter than that isnt giving its contents a /// margin so much as a haircut: the old hardcoded 30 left 1.5px over a /// normal-tier control and would have been SHORTER than a product-tier one, /// so a button in the header didnt fit the header. /// /// Matching the vertical margin to the horizontal gap is the whole point - /// a row of buttons then sits in an even field instead of one thats /// generous side to side and tight top to bottom. /// /// compact 23 + 8 = 31, normal 27 + 10 = 37, product 33 + 10 = 43 double get chromeBarHeight => controlHeight + gapMd; /// Cap on a select popup's list. Counts rows only - the list's own padding /// (and a search field, when there is one) sits on top, so the last row /// clips slightly rather than landing flush. Thats deliberate: a half row /// showing is the cheapest "theres more below" hint there is. /// /// Was `kDefaultSelectMaxHeight = 240.0` in select.dart, hand-typed and then /// multiplied by `scaling`, so it never moved with density at all. double get popupMaxHeight => popupRowHeight * popupMaxRows; /// Padding for a borderless control (ghost/primary button, etc). EdgeInsets get buttonPadding => EdgeInsets.symmetric( horizontal: buttonPaddingX, vertical: controlPaddingY, ); /// Padding for a bordered control. A BoxDecoration border adds its width to /// the Container's layout, so the stroke comes out of the padding and the /// outer height stays [controlHeight] either way. EdgeInsets get borderedControlPadding => EdgeInsets.symmetric( horizontal: buttonPaddingX - controlBorderWidth, vertical: controlPaddingY - controlBorderWidth, ); /// Text fields are bordered controls. EdgeInsets get textFieldPadding => borderedControlPadding; // ---- text ---- // // The body text ladder. [fontSize] is the control font - what a button // label, a field's text and a select trigger render at - and these are the // sizes for text that ISN'T inside a control: dialog copy, headings, hints. // // Dialog copy reading a step larger than the button labels beneath it is // deliberate - content and controls are different things. The bug was never // that they differed, it's that they were UNLINKED: these were hardcoded // 12/14/18 times `scaling`, while the control font comes off this class and // is deliberately not scaled. So the intended 12-vs-10 held at scaling 1.0 // and drifted to 14.4-vs-10 at 1.2. The ratio moved with scaling, which is // the actual defect. // // Deriving them from [fontSize] pins the ratio. At both canonical densities // fontSize is 10, so these come out 12/14/18 - exactly the numbers they // replaced, so nothing shifts at scaling 1.0. /// The one step BELOW the control font - a caption sat under something, /// not beside it. A property row's subtitle and description, and the same /// tier anything else that explains a control rather than labelling it /// should reach for. /// /// This rung didn't exist. The ladder only ever went UP from [fontSize], /// because in A&A the control font IS the smallest thing on screen - so a /// page needing a caption had nowhere to go and hardcoded one. x0.875 lands /// on 9 against a 10px control font, and 14 against a 16px one. double get textXxs => (fontSize * 0.875).roundToDouble(); /// Content that sits near controls without being one - dialog copy, page /// headings, hints. /// /// x1.1, which lands on the ambient body size: 11 against a 10px control /// font. It was x1.2 (12), matching the literal the shorthands used to /// hardcode - but that put dialog copy and every page heading a fifth above /// the controls beneath them and a pixel above ordinary body text, which /// read as oversized rather than as hierarchy. Weight and colour carry the /// emphasis instead. double get textXs => (fontSize * 1.1).roundToDouble(); /// Geist's own line box, as a multiple of font size - hhea ascender 1005, /// descender -295, lineGap 0 over a 1000 upem. The package hardcodes Geist /// (see GarageTheme), and a label sets no explicit `height`, so this is the /// ratio its line actually lays out at. Flutter rounds each line to a whole /// pixel, which is why the derivations below round rather than ceil. static const double geistLineRatio = 1.3; /// Height of a labelled row's label column - the label on [textXs] with a /// subtitle under it on [textXxs], both on the font's own line box. /// /// Worth having as a token because it OUTGROWS [controlHeight] at the small /// tiers: 26 against a 23px compact control. A row sized on controlHeight /// alone therefore got its height from the label rather than from the /// control, which is backwards and moves with the font. double get labelColumnHeight => (textXs * geistLineRatio).roundToDouble() + (textXxs * geistLineRatio).roundToDouble(); /// Minimum height of a [PropertyRow]. Clears whichever of the control and /// the label column is taller, plus a step, so neither one is the thing /// setting the row height and a row is the same height with or without a /// subtitle. double get propertyRowHeight => (labelColumnHeight > controlHeight ? labelColumnHeight : controlHeight) + gapXxs; /// Body copy that wants to read a step above the controls. double get textSm => (fontSize * 1.4).roundToDouble(); /// Headings. double get textLg => (fontSize * 1.8).roundToDouble(); // ---- layout spacing ---- // // The gap scale. Layout spacing BETWEEN widgets - what a call site reaches // for when it puts a `Gap` between two things. Distinct from [controlGap], // which is spacing INSIDE a control, and which is the base unit here. // // Before this existed, call sites picked `Gap(n)` literals by eye. That was // documented rather than derived, and the apps drifted off it - the two // Garage web frontends between them had sixteen distinct gap values, // including a 3, a 5 and ten 14s that no scale would have produced. // // The steps are multiples of [controlGap], which makes [gapMd] equal to // [containerGap] and [gapXl] equal to [containerPadding] at both densities. // That agreement isn't arranged, it's what those two tokens already were - // which is the evidence the base unit is right. // // Rounded because the normal density's base is 5, and a 1.5x step off it // lands on 7.5. A fractional gap isn't wrong the way a fractional line box // is (nothing derives from it), but a whole pixel won't seam on a fractional // device ratio, so it's free to keep them whole. /// Hairline separation - a label sat directly above its value. double get gapXxs => (controlGap * 0.5).roundToDouble(); /// Tight. Icon-adjacent, or items that read as one unit. double get gapXs => controlGap; /// Snug, between [gapXs] and the default. double get gapSm => (controlGap * 1.5).roundToDouble(); /// The default. "These two things are related but distinct." When a call /// site has no particular reason to pick another step, it wants this one. double get gapMd => controlGap * 2; /// Section-level: separates groups within a panel or form. double get gapLg => controlGap * 3; /// Between major blocks of a layout. double get gapXl => controlGap * 4; /// The largest step - page-level separation, above the panel scale. double get gapXxl => controlGap * 6; } /// Icon sizes. `small` is the control icon and is derived from the density's /// line box so it can never drift from the text beside it. class IconThemeTokens { const IconThemeTokens({ required this.small, required this.medium, required this.large, }); final IconThemeData small; final IconThemeData medium; final IconThemeData large; static IconThemeTokens forDensity( Density density, double scaling, Color color, ) => IconThemeTokens( small: IconThemeData(size: density.iconSize, color: color), medium: IconThemeData(size: 20 * scaling, color: color), large: IconThemeData(size: 24 * scaling, color: color), ); } // The apps first class theme data. Everything the widgets used to pull off // shadcns ThemeData (colours, the global scaling, radius tokens, icon sizes, // the optional surface glass) now lives here as our own thing. class ThemeData { ThemeData({ required this.colorScheme, this.scaling = 1.0, this.radius = 0.5, this.surfaceOpacity, this.surfaceBlur, this.enableFeedback, this.panelRadius = 10, this.panelGap = 5, Density density = const Density(), Typography? typography, IconThemeTokens? iconTheme, }) : density = density, // typography and icon sizes are derived from the density so the control // type, the control icon and the control height all move together. typography = typography ?? Typography.forDensity(density), iconTheme = iconTheme ?? IconThemeTokens.forDensity(density, scaling, colorScheme.foreground); final ColourScheme colorScheme; // chrome panel layout - corner radius of the docked panels, and the gap // around + between them in the shell. final double panelRadius; final double panelGap; final Typography typography; final Density density; // haptic/click feedback toggle. null = decide by platform (mobile on). final bool? enableFeedback; // used by controls that behave differently on touch platforms. TargetPlatform get platform => defaultTargetPlatform; // global ui scale. was shadcns AdaptiveScaling(0.75). widgets multiply their // paddings/sizes by this to stay the size they always were. final double scaling; // base radius (rem-ish). the tokens below are derived from it exactly the way // shadcn derived theirs (radius * step). final double radius; final IconThemeTokens iconTheme; // optional surface glassmorphism - null means opaque / no blur, same defaults // shadcn shipped. final double? surfaceOpacity; final double? surfaceBlur; double get radiusXs => radius * 4; double get radiusSm => radius * 8; double get radiusMd => radius * 12; double get radiusLg => radius * 16; double get radiusXl => radius * 20; double get radiusXxl => radius * 24; BorderRadius get borderRadiusXs => BorderRadius.circular(radiusXs); BorderRadius get borderRadiusSm => BorderRadius.circular(radiusSm); BorderRadius get borderRadiusMd => BorderRadius.circular(radiusMd); BorderRadius get borderRadiusLg => BorderRadius.circular(radiusLg); BorderRadius get borderRadiusXl => BorderRadius.circular(radiusXl); BorderRadius get borderRadiusXxl => BorderRadius.circular(radiusXxl); Radius get radiusMdRadius => Radius.circular(radiusMd); Radius get radiusLgRadius => Radius.circular(radiusLg); Radius get radiusXlRadius => Radius.circular(radiusXl); ThemeData copyWith({ ColourScheme? colorScheme, double? scaling, double? radius, double? surfaceOpacity, double? surfaceBlur, Typography? typography, Density? density, bool? enableFeedback, double? panelRadius, double? panelGap, IconThemeTokens? iconTheme, }) { return ThemeData( colorScheme: colorScheme ?? this.colorScheme, scaling: scaling ?? this.scaling, radius: radius ?? this.radius, surfaceOpacity: surfaceOpacity ?? this.surfaceOpacity, surfaceBlur: surfaceBlur ?? this.surfaceBlur, typography: typography ?? this.typography, density: density ?? this.density, enableFeedback: enableFeedback ?? this.enableFeedback, panelRadius: panelRadius ?? this.panelRadius, panelGap: panelGap ?? this.panelGap, iconTheme: iconTheme ?? this.iconTheme, ); } }