fl_panel 1.1.0
fl_panel: ^1.1.0 copied to clipboard
A docking layout for Flutter: a tree of splits, panels and tab groups you can resize, dock and persist, with ordinary Flutter widgets inside every panel.
fl_panel #
A docking layout for Flutter: a tree of splits, single panels and tab groups that the user resizes, docks and rearranges, with ordinary Flutter widgets inside every panel. The shape you know from Blender, Visual Studio and VS Code — and a file format to bring it back tomorrow.
final controller = PanelController(
workspace: PanelWorkspace(windows: [
PanelWindow(
id: 'main',
root: SplitNode(
id: 'root',
axis: PanelAxis.horizontal,
sizes: const [PanelExtent.fixed(240), PanelExtent.flex(1)],
children: [
SinglePanel(id: 'files', tab: PanelTab(id: 'files', contentId: 'files')),
TabGroup(id: 'editors', tabs: [
PanelTab(id: 'a', contentId: 'editor', metadata: {'title': 'a.md'}),
PanelTab(id: 'b', contentId: 'editor', metadata: {'title': 'b.md'}),
]),
],
),
),
]),
);
PanelHost(
controller: controller,
windowId: 'main',
contentBuilder: (context, tab) => switch (tab.contentId) {
'files' => const FileTree(),
_ => Editor(path: tab.metadataValue<String>('title') ?? 'untitled'),
},
);
How the model works #
PanelWorkspace every window, in one value
└── PanelWindow one window's tree, and its focused leaf
└── LayoutNode
├── SplitNode children along one axis, an extent each
│ └── LayoutNode …
├── SinglePanel a leaf holding exactly one tab, under a header
└── TabGroup a leaf holding tabs, under a strip
└── PanelTab a contentId and metadata — never a widget
A tab names its content by contentId; the content itself stays outside the
model, and your contentBuilder turns the id into a widget. That is what lets
the whole tree be saved, restored, edited in a plain dart test, and moved
between windows.
A single panel and a group of one are different things on purpose. A
SinglePanel is Blender's area: a header, no strip. A TabGroup keeps its
strip even with one tab, as VS Code's editor groups do. A group never
collapses into a panel by itself — a tab becomes a panel only when it is
dropped in the single form, and PanelTab.allowedForms says whether it may
— and a panel joined by a tab becomes a group under the same id.
What you get #
- A tree, not a widget tree. Immutable values, every edit a pure function
in
LayoutTree. Metadata is JSON-shaped and read typed withtab.metadataValue<String>('title'), which answers null rather than throwing on a file somebody edited. - Resizing with weights and fixed extents, content minimums honoured and redistributed, over-constrained layouts scaled together.
- Docking at both granularities: drag a tab out of its strip or a whole
panel by its header; the centre of a leaf joins it, its edges split it, the
window's edges split everything, a strip inserts at the index. A live preview
shows where it lands, and
DockZonessets how wide the bands are. - A tab can become a panel and a panel a tab, governed by two affinity
levels:
PanelTab.allowedFormssays which surface forms a content may take, and yourDockPolicysays who may share a strip with whom. - State survives moves. Content is rendered flat and keyed on the tab, so a text field keeps its selection when its tab lands in another group.
- Three tab styles —
attached(VS Code),blended(Chrome's shoulders, painted),floating(pills) — per theme or per group, with aPanelDecorationsfor icons, an unsaved dot and a strip button, and aPanelThemeyou cancopyWith; or replace the whole chrome throughPanelChrome. - Chips shrink to a floor, then the strip scrolls — by wheel, and by
controller.focus(tabId, keyboard: true), which activates the tab, brings its chip into view and hands its content the keyboard. - An editor area that stays: a
persistentgroup keeps its place with nothing in it — and a second one folds away when it empties, so only the last stands empty. A tab can beclosable: false— a file tree, a console — so it moves but never goes, andDockPolicy.canBeFocusedLeafkeeps such panels from ever being where the next document opens. - Right-click menus on chips, strips, headers and dividers, from the
controller's own verbs — close, split, move to an edge, equalise, swap —
greyed where they do not apply, and rewritable through
PanelMenus.build— which, returning nothing, is also how a host shows a menu of its own at the request'sglobalPosition. Every built-in entry carries aPanelMenuEntryId, so a builder finds one by what it is rather than by what it says. - In any language — see Localisation below. English until you say otherwise, with nothing to set up.
- A focused leaf per window, where
openputs new content andnextTab/closeActiveact; acloseGuardfor unsaved documents;closeOthers/closeToTheRight;updateTabfor a title or a flag; and aneventsstream (TabOpened,TabClosed,TabMoved, …) for whatever an application keeps behind its tabs. - Persistence with an integer
version:controller.toJson(),controller.load(json, resolve: …), where the resolver drops tabs this build no longer knows and the tree tidies itself. - Headless half.
package:fl_panel/model.dartimports nothing from Flutter: the tree, the edits, the solver, the resolver, the policy and the format run in a plaindarttest or on a server.
Localisation #
The words the host draws — today, its menus — come from PanelLocalizations,
the same pattern as Flutter's MaterialLocalizations. With no delegate in
scope it is DefaultPanelLocalizations, the English above. To translate it,
extend the default and hand Flutter a delegate for it, beside your own:
class GermanPanelLocalizations extends DefaultPanelLocalizations {
const GermanPanelLocalizations();
@override
String get closeTab => 'Schließen';
@override
String get closeOtherTabs => 'Andere schließen';
}
class GermanPanelDelegate extends LocalizationsDelegate<PanelLocalizations> {
const GermanPanelDelegate();
@override
bool isSupported(Locale locale) => locale.languageCode == 'de';
@override
Future<PanelLocalizations> load(Locale locale) =>
SynchronousFuture(const GermanPanelLocalizations());
@override
bool shouldReload(GermanPanelDelegate old) => false;
}
// MaterialApp(localizationsDelegates: [GermanPanelDelegate(), ...], …)
Extend, don't implement. A later minor version may add a string; a
subclass of the default shows it in English until you translate it, where an
implements would stop compiling.
Because a label is now in whatever language the host speaks, a menu builder matches the built-in entries by id:
PanelMenus(
build: (request, defaults) => [
for (final entry in defaults)
if (entry.id != PanelMenuEntryId.closeTabsAfter) entry,
],
)
Tab titles are not here: they are yours already, through PanelHost.titleOf.
Install #
dependencies:
fl_panel: ^1.1.0
Not yet #
Floating panels (the file format reserves the slot), maximise, pinning, and
multi-window hosting — the model already holds several windows and moves tabs
between them; wiring a second engine to a second PanelHost is the
application's job.
Example #
example/ is an IDE-shaped demo: files at a fixed width, editors in the
middle, tools on the right that only group with other tools, a console along
the bottom; a style switcher, a button that opens twelve editors to watch the
strip scroll, a focus menu, a context menu, an unsaved dot that makes the
close guard ask, and save/restore of the layout. It is live on GitHub
Pages, rebuilt from main
by .github/workflows/demo.yml.
cd example && flutter run -d linux