import "package:flutter/widgets.dart"; import "package:flutter_lucide/flutter_lucide.dart"; import "button.dart"; import "editor_chrome.dart"; import "surface.dart"; import "theme/garage_theme.dart"; /// One entry in a [TabView] - the label (and optional icon) for its header /// button, plus the widget shown while it's selected. class TabViewItem { const TabViewItem({required this.label, this.icon, required this.child}); final String label; final IconData? icon; final Widget child; } /// A row of buttons across the top, one page shown below at a time. /// /// Not a browser-style tab strip - no close buttons, no reordering, no /// scrolling overflow. This is closer to what FL Studio calls its pattern/ /// mixer/playlist "windows": conceptually separate workspaces that happen /// to live as pages in the same frame instead of actual floating windows. /// /// Every tab's widget stays mounted the whole time (via [IndexedStack]), /// just hidden when it isn't selected, so switching tabs doesn't reset /// whatever state the page underneath is holding onto - scroll position, /// text fields, a half-finished drag, etc. /// /// Controlled, not stateful - the caller owns [selectedIndex] and finds /// out about taps via [onSelected], same shape as everything else in /// GarageUI with a "which one is picked" concept (see Select). class TabView extends StatelessWidget { const TabView({ super.key, required this.tabs, required this.selectedIndex, required this.onSelected, this.onClosed, }); final List tabs; final int selectedIndex; final ValueChanged onSelected; /// Called with a tab's index when its close (x) button is pressed - only /// the active tab gets one. Leave null and no close button shows at all, /// same "nothing happens unless the caller wired it up" deal as everywhere /// else here - TabView itself doesn't know what "closing" a tab even means /// for the caller (remove it? just hide it? ask first?), it just reports it final ValueChanged? onClosed; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _header(context), Expanded( child: IndexedStack( index: selectedIndex, children: [for (final tab in tabs) tab.child], ), ), ], ); } Widget _header(BuildContext context) { final theme = GarageTheme.of(context); // tried wrapping ChromeBar in an outer bordered DecoratedBox for the // divider first - didnt work, ChromeBar paints its own opaque chrome- // coloured background as a child ON TOP of that outer decoration (same // bounds and everything) so the border stroke just gets painted over // and never shows. a real 1px sibling below it actually renders return Column( mainAxisSize: MainAxisSize.min, children: [ ChromeBar( padding: const EdgeInsets.fromLTRB(8, 4, 6, 4), child: Row( children: [ for (var i = 0; i < tabs.length; i++) ...[ if (i > 0) const Gap(4), _tabButton(context, i), ], ], ), ), Container(height: 1, color: theme.colorScheme.divider), ], ); } Widget _tabButton(BuildContext context, int index) { final theme = GarageTheme.of(context); final tab = tabs[index]; final selected = index == selectedIndex; // Button has no automatic icon theming of its own (it never wraps its // content in an IconTheme) - has to be coloured by hand here to match // whichever variant its sitting on, or it just comes out whatever the // ambient default happens to be, not the button's actual foreground. // ghost's foreground is just colorScheme.foreground, not a "ghost" // specific token - see _buttonGhostIconTheme final iconColor = selected ? theme.colorScheme.primaryForeground : theme.colorScheme.foreground; final leading = tab.icon == null ? null : Icon(tab.icon, size: theme.iconTheme.small.size, color: iconColor); // compact density, not the default - this header bar is the same // slim ChromeBar height as the app's own menu bar (which explicitly // opts into ControlDensity.compact for the same reason), a normal- // density button doesn't fit in that height without getting squished. // active tab reads as primary, everything else fades into ghost so it // doesnt fight the active one for attention final tabButton = selected ? Button.primary( style: const ButtonStyle.primary(density: ControlDensity.compact), leading: leading, onPressed: () => onSelected(index), child: Text(tab.label), ) : Button.ghost( style: const ButtonStyle.ghost(density: ControlDensity.compact), leading: leading, onPressed: () => onSelected(index), child: Text(tab.label), ); // close button only shows up on the active tab, and only if the caller // actually wants one - grouped onto the tab button via ButtonGroup so // the two share one pill shape instead of looking like two seperate // buttons bolted together if (!selected || onClosed == null) return tabButton; return ButtonGroup.horizontal( children: [ tabButton, IconButton.secondary( // same deal as the tab buttons above - Button never themes its // child's colour on its own, icon widget has to bring its own icon: Icon( LucideIcons.x, size: theme.iconTheme.small.size, color: theme.colorScheme.secondaryForeground, ), density: ControlDensity.compact, onPressed: () => onClosed!(index), ), ], ); } }