From c09cec3189679420e6a068e38c885db34aa70b38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:37:13 +0800 Subject: [PATCH 1/4] fix: keep bars clear of iPadOS window controls Closes #1679 --- ios/Runner.xcodeproj/project.pbxproj | 4 + ios/Runner/AppDelegate.swift | 16 ++ ios/Runner/WindowControls.swift | 168 ++++++++++++++++++++ lib/core/chan.dart | 35 ++++ lib/main.dart | 4 + lib/view/page/benchmark/tab.dart | 37 +++-- lib/view/page/home/nav.dart | 39 ++--- lib/view/page/server/tab/bar.dart | 77 ++++----- lib/view/page/server/tab/selection_bar.dart | 123 +++++++------- lib/view/page/snippet/list.dart | 59 +++---- lib/view/page/ssh/tab.dart | 35 ++-- lib/view/page/storage/file_browser.dart | 28 ++-- lib/view/page/storage/tab.dart | 41 ++--- lib/view/page/virt/guest.dart | 72 +++++---- lib/view/page/virt/list.dart | 99 ++++++------ lib/view/page/virt/resources.dart | 50 +++--- packages/fl_lib | 2 +- 17 files changed, 573 insertions(+), 316 deletions(-) create mode 100644 ios/Runner/WindowControls.swift diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 9cd1ca4122..3a06a456bb 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 4A2DCD6B2E4B127100CF68B7 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */; }; 4A2DCD712E4B127100CF68B7 /* DynamicIslandBrand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD722E4B127100CF68B7 /* DynamicIslandBrand.swift */; }; 4A2DCD732E4B127100CF68B7 /* PrivacyBlur.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD742E4B127100CF68B7 /* PrivacyBlur.swift */; }; + 4A2DCD752E4B127100CF68B7 /* WindowControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD762E4B127100CF68B7 /* WindowControls.swift */; }; 4A2DCD802E4B127100CF68B7 /* CrashDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD812E4B127100CF68B7 /* CrashDiagnostics.swift */; }; 4A2DCDB22E4B127100CF68B7 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCDB32E4B127100CF68B7 /* SceneDelegate.swift */; }; 4A2DCDB02E4B127100CF68B7 /* IncomingShare.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCDB12E4B127100CF68B7 /* IncomingShare.swift */; }; @@ -135,6 +136,7 @@ 4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; }; 4A2DCD722E4B127100CF68B7 /* DynamicIslandBrand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicIslandBrand.swift; sourceTree = ""; }; 4A2DCD742E4B127100CF68B7 /* PrivacyBlur.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyBlur.swift; sourceTree = ""; }; + 4A2DCD762E4B127100CF68B7 /* WindowControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowControls.swift; sourceTree = ""; }; 4A2DCD812E4B127100CF68B7 /* CrashDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashDiagnostics.swift; sourceTree = ""; }; 4A2DCDB32E4B127100CF68B7 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 4A2DCDB12E4B127100CF68B7 /* IncomingShare.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingShare.swift; sourceTree = ""; }; @@ -323,6 +325,7 @@ 4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */, 4A2DCD722E4B127100CF68B7 /* DynamicIslandBrand.swift */, 4A2DCD742E4B127100CF68B7 /* PrivacyBlur.swift */, + 4A2DCD762E4B127100CF68B7 /* WindowControls.swift */, 4A2DCD812E4B127100CF68B7 /* CrashDiagnostics.swift */, 4A2DCDB32E4B127100CF68B7 /* SceneDelegate.swift */, 4A2DCDB12E4B127100CF68B7 /* IncomingShare.swift */, @@ -652,6 +655,7 @@ 4A2DCD6C2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */, 4A2DCD712E4B127100CF68B7 /* DynamicIslandBrand.swift in Sources */, 4A2DCD732E4B127100CF68B7 /* PrivacyBlur.swift in Sources */, + 4A2DCD752E4B127100CF68B7 /* WindowControls.swift in Sources */, 4A2DCD802E4B127100CF68B7 /* CrashDiagnostics.swift in Sources */, 4A2DCDB22E4B127100CF68B7 /* SceneDelegate.swift in Sources */, 4A2DCDB02E4B127100CF68B7 /* IncomingShare.swift in Sources */, diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index b723ca0a81..3d352a1bc2 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -22,6 +22,7 @@ import UserNotifications ) { note in guard let scene = note.object as? UIWindowScene else { return } DynamicIslandBrand.shared.install(in: scene) + WindowControls.shared.install(in: scene) PrivacyBlur.shared.hideIfUnlocked() } @@ -86,6 +87,21 @@ import UserNotifications } } + // Pushed on change, and readable once: the first layout can report + // before Dart has set its handler, and a push nobody hears is lost. + let windowControlsChannel = FlutterMethodChannel(name: "tech.lolli.toolbox/window_controls", binaryMessenger: binaryMessenger) + WindowControls.shared.onChange = { zones in + windowControlsChannel.invokeMethod("changed", arguments: zones) + } + windowControlsChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in + switch call.method { + case "zones": + result(WindowControls.shared.zones) + default: + result(FlutterMethodNotImplemented) + } + }) + let homeWidgetChannel = FlutterMethodChannel(name: "tech.lolli.toolbox/home_widget", binaryMessenger: binaryMessenger) homeWidgetChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in switch call.method { diff --git a/ios/Runner/WindowControls.swift b/ios/Runner/WindowControls.swift new file mode 100644 index 0000000000..aa39f7ca5c --- /dev/null +++ b/ios/Runner/WindowControls.swift @@ -0,0 +1,168 @@ +// +// WindowControls.swift +// Runner +// + +import Flutter +import UIKit + +/// Reports to Dart where the system's window controls cover the app. +/// +/// A windowed scene on iPadOS 26 draws its close, minimise and tile buttons +/// over the top corner of the app's own content. UIKit's bars step around +/// them through the corner-adapted layout regions; `safeAreaInsets`, the one +/// number the Flutter engine hands on as `MediaQuery.padding`, leaves them +/// out, so a Flutter app bar put its back button under them. +/// +/// The covered area is the corner where an inset adapted on one axis grows +/// past the plain safe area: the horizontal adaptation gives its width, the +/// vertical one its height. Sent as a list of rects in the Flutter view's +/// coordinates, which are its points — empty when nothing is covered, as on +/// every iPhone and on a full-screen iPad scene. +final class WindowControls { + static let shared = WindowControls() + + /// `[left, top, right, bottom]` per covered corner. + private(set) var zones: [[Double]] = [] + + /// Called on the main thread whenever [zones] changes. + var onChange: (([[Double]]) -> Void)? + + /// `UIView` and not the probe type, which a stored property of a class + /// that is not itself iOS 26-only cannot name. + private weak var probes: UIView? + + private init() {} + + /// Idempotent per view: a scene reconnecting gets a new Flutter view, and + /// the probes have to be measured against whichever one is live. + func install(in scene: UIWindowScene) { + guard #available(iOS 26.0, *) else { return } + guard let view = scene.windows + .lazy + .compactMap({ $0.rootViewController as? FlutterViewController }) + .first?.view + else { return } + if let probes, probes.superview === view { return } + probes?.removeFromSuperview() + + let container = ProbeContainer { [weak self] zones in + self?.update(zones) + } + view.addSubview(container) + container.pin(to: view) + probes = container + } + + private func update(_ zones: [[Double]]) { + guard zones != self.zones else { return } + self.zones = zones + onChange?(zones) + } +} + +/// Invisible views pinned to the corner-adapted safe areas. +/// +/// Measuring from layout instead of asking once: the controls come and go as +/// the scene moves between full screen and a window, and the regions follow +/// them. A probe pinned to a region is resized whenever the region changes, +/// and a resize is a `layoutSubviews` — the one notification UIKit gives. +@available(iOS 26.0, *) +private final class ProbeContainer: UIView { + private let report: ([[Double]]) -> Void + private let horizontal = Probe() + private let vertical = Probe() + + init(report: @escaping ([[Double]]) -> Void) { + self.report = report + super.init(frame: .zero) + isUserInteractionEnabled = false + isHidden = true + translatesAutoresizingMaskIntoConstraints = false + for probe in [horizontal, vertical] { + probe.onLayout = { [weak self] in self?.measure() } + probe.translatesAutoresizingMaskIntoConstraints = false + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + /// Spans the whole view; the probes inside follow its regions. + func pin(to view: UIView) { + NSLayoutConstraint.activate([ + leadingAnchor.constraint(equalTo: view.leadingAnchor), + trailingAnchor.constraint(equalTo: view.trailingAnchor), + topAnchor.constraint(equalTo: view.topAnchor), + bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + for (probe, axis) in [ + (horizontal, UIView.LayoutRegion.AdaptivityAxis.horizontal), + (vertical, .vertical), + ] { + view.addSubview(probe) + let guide = view.layoutGuide(for: .safeArea(cornerAdaptation: axis)) + NSLayoutConstraint.activate([ + probe.leadingAnchor.constraint(equalTo: guide.leadingAnchor), + probe.trailingAnchor.constraint(equalTo: guide.trailingAnchor), + probe.topAnchor.constraint(equalTo: guide.topAnchor), + probe.bottomAnchor.constraint(equalTo: guide.bottomAnchor), + ]) + } + } + + override func removeFromSuperview() { + horizontal.removeFromSuperview() + vertical.removeFromSuperview() + super.removeFromSuperview() + } + + override func layoutSubviews() { + super.layoutSubviews() + measure() + } + + private func measure() { + guard let view = superview else { return } + let safe = view.safeAreaInsets + let wide = view.edgeInsets(for: .safeArea(cornerAdaptation: .horizontal)) + let tall = view.edgeInsets(for: .safeArea(cornerAdaptation: .vertical)) + let width = Double(view.bounds.width) + let bottom = Double(max(tall.top, safe.top)) + + var zones: [[Double]] = [] + // A window as large as the screen still rounds its corners, and the + // horizontal region steps around that too — but within the status + // bar, which the content is already below. + guard bottom > Double(safe.top) else { + report(zones) + return + } + if wide.left > safe.left { + zones.append([0, 0, Double(wide.left), bottom]) + } + if wide.right > safe.right { + zones.append([width - Double(wide.right), 0, width, bottom]) + } + report(zones) + } +} + +@available(iOS 26.0, *) +private final class Probe: UIView { + var onLayout: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + isUserInteractionEnabled = false + isHidden = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override func layoutSubviews() { + super.layoutSubviews() + onLayout?() + } +} diff --git a/lib/core/chan.dart b/lib/core/chan.dart index a8591fa33b..cd8566b2cf 100644 --- a/lib/core/chan.dart +++ b/lib/core/chan.dart @@ -398,6 +398,41 @@ abstract final class MethodChans { }); } + static const _windowControlsChannel = MethodChannel( + '${Miscs.pkgName}/window_controls', + ); + + /// Keeps [WindowControls.zones] at what the platform reports — iPadOS only, + /// where a windowed app has its window controls drawn over it. + /// + /// Read once as well as listened to: the first report can come before this + /// handler is set. + static void watchWindowControls() { + if (!isIOS) return; + List parse(Object? raw) => [ + for (final z in (raw as List?) ?? const []) + if (z case [final num l, final num t, final num r, final num b]) + Rect.fromLTRB(l.toDouble(), t.toDouble(), r.toDouble(), b.toDouble()), + ]; + // The reply to the read below can arrive after a push sent later, and + // would put back what was there before it. + var pushed = false; + _windowControlsChannel.setMethodCallHandler((call) async { + if (call.method == 'changed') { + pushed = true; + WindowControls.zones.value = parse(call.arguments); + } + }); + _windowControlsChannel + .invokeMethod>('zones') + .then((raw) { + if (!pushed) WindowControls.zones.value = parse(raw); + }) + .catchError((Object e) { + Loggers.app.warning('windowControls', e); + }); + } + /// Register a handler for native -> Flutter callbacks. /// Currently handles: /// - `disconnectSession` with argument map {id: string} diff --git a/lib/main.dart b/lib/main.dart index bfbe287f42..2bc39c771a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -134,6 +134,10 @@ Future _initApp(ProviderContainer container) async { // framework's animations — see [AppBinding]. AppBinding(); + // As early as there is a binding: until the first report arrives, a bar is + // laid out as if nothing covered it. + MethodChans.watchWindowControls(); + // Before anything that can fail, so that a failure during startup is at // least recorded — the errors worth catching most are the ones that stop the // app from reaching a screen anyone could copy a log from. diff --git a/lib/view/page/benchmark/tab.dart b/lib/view/page/benchmark/tab.dart index 045e6860ee..1c81b1006a 100644 --- a/lib/view/page/benchmark/tab.dart +++ b/lib/view/page/benchmark/tab.dart @@ -217,24 +217,27 @@ extension _Widgets on _BenchmarkTabPageState { // plain title says "this page". appBar: PreferredSize( preferredSize: const Size.fromHeight(SessionTabBar.height), - child: SizedBox( - height: SessionTabBar.height, - child: Row( - children: [ - Expanded( - child: SessionSwitcherLabel( - name: spi.name, - icon: Icons.dns_outlined, - onTap: () => _pickServer(false), + child: WindowControlsInset( + safeArea: true, + child: SizedBox( + height: SessionTabBar.height, + child: Row( + children: [ + Expanded( + child: SessionSwitcherLabel( + name: spi.name, + icon: Icons.dns_outlined, + onTap: () => _pickServer(false), + ), + ), + Btn.icon( + text: l10n.history, + icon: const Icon(Icons.history, size: 18), + onTap: () => _showHistorySheet(servers), ), - ), - Btn.icon( - text: l10n.history, - icon: const Icon(Icons.history, size: 18), - onTap: () => _showHistorySheet(servers), - ), - const SizedBox(width: 7), - ], + const SizedBox(width: 7), + ], + ), ), ), ), diff --git a/lib/view/page/home/nav.dart b/lib/view/page/home/nav.dart index a9c23e1fcf..1ed9ad9260 100644 --- a/lib/view/page/home/nav.dart +++ b/lib/view/page/home/nav.dart @@ -184,24 +184,27 @@ extension _HomePageStrip on _HomePageState { /// rail draws the first `shown` of [_tabs] and `_showMoreSheet` takes the /// remainder, exactly as the bar does. Widget _buildRailBar() { - return SafeArea( - // Anchored to the start, so the inset on the far side is not its to - // keep clear: taking it would make the rail wider than the room the - // `Row` holds open for it, by however much the other edge is cut off. - right: false, - child: LayoutBuilder( - builder: (context, constraints) { - final capacity = railCapacity( - height: constraints.maxHeight, - destinationExtent: railDestinationExtent, - ); - final shown = railShownCount( - wanted: _barTabs.length, - total: _tabs.length, - capacity: capacity, - ); - return _buildRail(shown: shown); - }, + return WindowControlsInset( + axis: Axis.vertical, + child: SafeArea( + // Anchored to the start, so the inset on the far side is not its to + // keep clear: taking it would make the rail wider than the room the + // `Row` holds open for it, by however much the other edge is cut off. + right: false, + child: LayoutBuilder( + builder: (context, constraints) { + final capacity = railCapacity( + height: constraints.maxHeight, + destinationExtent: railDestinationExtent, + ); + final shown = railShownCount( + wanted: _barTabs.length, + total: _tabs.length, + capacity: capacity, + ); + return _buildRail(shown: shown); + }, + ), ), ); } diff --git a/lib/view/page/server/tab/bar.dart b/lib/view/page/server/tab/bar.dart index fac6d425da..8f00d09939 100644 --- a/lib/view/page/server/tab/bar.dart +++ b/lib/view/page/server/tab/bar.dart @@ -23,44 +23,47 @@ extension _Bar on _ServerPageState { // own default is a full toolbar. preferSize: const Size.fromHeight(SessionTabBar.height), builder: () { - return SizedBox( - height: SessionTabBar.height, - child: InlineSearchBar( - controller: _search, - child: LayoutBuilder( - builder: (_, cons) => Row( - children: [ - // The way back comes first and takes no room when there is - // nowhere to go back to. - // - // Inset to where the switcher's own glyph sits when there is - // nothing open — `SessionSwitcherLabel` holds it 14 off the - // edge, and a button's own 7 is half of that — so the first - // thing in the bar is in the same place either way. - if (openId != null) const SizedBox(width: 7), - if (openId != null) - Btn.icon( - text: libL10n.close, - icon: const Icon(Icons.arrow_back_ios_new, size: 17), - onTap: _closeDetail, + return WindowControlsInset( + safeArea: true, + child: SizedBox( + height: SessionTabBar.height, + child: InlineSearchBar( + controller: _search, + child: LayoutBuilder( + builder: (_, cons) => Row( + children: [ + // The way back comes first and takes no room when there is + // nowhere to go back to. + // + // Inset to where the switcher's own glyph sits when there is + // nothing open — `SessionSwitcherLabel` holds it 14 off the + // edge, and a button's own 7 is half of that — so the first + // thing in the bar is in the same place either way. + if (openId != null) const SizedBox(width: 7), + if (openId != null) + Btn.icon( + text: libL10n.close, + icon: const Icon(Icons.arrow_back_ios_new, size: 17), + onTap: _closeDetail, + ), + Expanded( + child: openId == null + ? _buildTagSwitcher() + : _buildServerSwitcher(openId, filtered), ), - Expanded( - child: openId == null - ? _buildTagSwitcher() - : _buildServerSwitcher(openId, filtered), - ), - // Not while one is open: the page is one machine then, and - // how many of them fit on a screen is not a question it has. - if (openId == null) - _buildDensityControl(filtered.length, room: cons.maxWidth), - // With one open, the page is that machine, so the bar offers - // what its own page does when pushed on a phone. - if (openId == null) - ..._listActions() - else - ..._serverActions(openId), - const SizedBox(width: 7), - ], + // Not while one is open: the page is one machine then, and + // how many of them fit on a screen is not a question it has. + if (openId == null) + _buildDensityControl(filtered.length, room: cons.maxWidth), + // With one open, the page is that machine, so the bar offers + // what its own page does when pushed on a phone. + if (openId == null) + ..._listActions() + else + ..._serverActions(openId), + const SizedBox(width: 7), + ], + ), ), ), ), diff --git a/lib/view/page/server/tab/selection_bar.dart b/lib/view/page/server/tab/selection_bar.dart index 64b86f1dde..4b9b9a719a 100644 --- a/lib/view/page/server/tab/selection_bar.dart +++ b/lib/view/page/server/tab/selection_bar.dart @@ -49,68 +49,71 @@ class ServerSelectionBar extends StatelessWidget Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; - return SizedBox( - height: SessionTabBar.height, - child: Row( - children: [ - Btn.icon( - text: libL10n.close, - icon: const Icon(Icons.close, size: 18), - onTap: onClose, - ), - Icon(Icons.check_box, size: 19, color: scheme.primary), - const SizedBox(width: 9), - Text( - '$count', - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, + return WindowControlsInset( + safeArea: true, + child: SizedBox( + height: SessionTabBar.height, + child: Row( + children: [ + Btn.icon( + text: libL10n.close, + icon: const Icon(Icons.close, size: 18), + onTap: onClose, ), - ), - const SizedBox(width: 7), - // The one part that gives way on a narrow bar. The buttons after it - // are fixed-size icons and all of them are needed; the total is the - // least of what the bar says, and at 320pt with large text it was - // what pushed them past the edge. - Expanded( - child: Text( - '/ $total', - style: UIs.text11Grey, - maxLines: 1, - softWrap: false, - overflow: TextOverflow.ellipsis, + Icon(Icons.check_box, size: 19, color: scheme.primary), + const SizedBox(width: 9), + Text( + '$count', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + ), ), - ), - Btn.icon( - text: l10n.connect, - icon: const Icon(Icons.link, size: 18), - onTap: onConnect, - ), - Btn.icon( - text: l10n.disconnect, - icon: const Icon(Icons.link_off, size: 18), - onTap: onDisconnect, - ), - Btn.icon( - text: libL10n.tag, - icon: const Icon(MingCute.hashtag_line, size: 18), - onTap: onTag, - ), - // The one action here that is about the list rather than about the - // machines. Dragging is how one server is moved, and forty is - // exactly where dragging stops being a way to do anything. - Btn.icon( - text: l10n.move, - icon: const Icon(Icons.swap_vert, size: 18), - onTap: onMove, - ), - Btn.icon( - text: libL10n.delete, - icon: const Icon(Icons.delete, size: 18), - onTap: onDelete, - ), - const SizedBox(width: 7), - ], + const SizedBox(width: 7), + // The one part that gives way on a narrow bar. The buttons after it + // are fixed-size icons and all of them are needed; the total is the + // least of what the bar says, and at 320pt with large text it was + // what pushed them past the edge. + Expanded( + child: Text( + '/ $total', + style: UIs.text11Grey, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + ), + ), + Btn.icon( + text: l10n.connect, + icon: const Icon(Icons.link, size: 18), + onTap: onConnect, + ), + Btn.icon( + text: l10n.disconnect, + icon: const Icon(Icons.link_off, size: 18), + onTap: onDisconnect, + ), + Btn.icon( + text: libL10n.tag, + icon: const Icon(MingCute.hashtag_line, size: 18), + onTap: onTag, + ), + // The one action here that is about the list rather than about the + // machines. Dragging is how one server is moved, and forty is + // exactly where dragging stops being a way to do anything. + Btn.icon( + text: l10n.move, + icon: const Icon(Icons.swap_vert, size: 18), + onTap: onMove, + ), + Btn.icon( + text: libL10n.delete, + icon: const Icon(Icons.delete, size: 18), + onTap: onDelete, + ), + const SizedBox(width: 7), + ], + ), ), ); } diff --git a/lib/view/page/snippet/list.dart b/lib/view/page/snippet/list.dart index 677aa9834f..262b7cc622 100644 --- a/lib/view/page/snippet/list.dart +++ b/lib/view/page/snippet/list.dart @@ -230,35 +230,38 @@ final class _SnippetBar extends StatelessWidget implements PreferredSizeWidget { @override Widget build(BuildContext context) { // In place of the switcher, as on every other tab that searches. - return InlineSearchBar( - controller: search, - child: Padding( - padding: const EdgeInsets.only(left: 10, right: 4), - child: Row( - children: [ - TagSwitcher( - tags: tags, - onTagChanged: onTagChanged, - initTag: initTag, - singleLine: true, - ).expanded(), - Btn.icon( - text: libL10n.search, - icon: const Icon(Icons.search, size: 20), - onTap: onSearch, - ), - // Beside search rather than floating over the list, which is where - // every other page of this app puts the same action. A button that - // sits on top of the content also covers the last row of it, and - // needed two of itself — one size for a pane and another for a full - // width — for nothing the bar has to think about. - Btn.icon( - text: libL10n.add, - icon: const Icon(Icons.add, size: 20), - onTap: onAdd, + return WindowControlsInset( + safeArea: true, + child: InlineSearchBar( + controller: search, + child: Padding( + padding: const EdgeInsets.only(left: 10, right: 4), + child: Row( + children: [ + TagSwitcher( + tags: tags, + onTagChanged: onTagChanged, + initTag: initTag, + singleLine: true, + ).expanded(), + Btn.icon( + text: libL10n.search, + icon: const Icon(Icons.search, size: 20), + onTap: onSearch, + ), + // Beside search rather than floating over the list, which is where + // every other page of this app puts the same action. A button that + // sits on top of the content also covers the last row of it, and + // needed two of itself — one size for a pane and another for a full + // width — for nothing the bar has to think about. + Btn.icon( + text: libL10n.add, + icon: const Icon(Icons.add, size: 20), + onTap: onAdd, + ), + ], ), - ], - ), + ), ), ); } diff --git a/lib/view/page/ssh/tab.dart b/lib/view/page/ssh/tab.dart index 1eefcb954f..abaf435aac 100644 --- a/lib/view/page/ssh/tab.dart +++ b/lib/view/page/ssh/tab.dart @@ -250,22 +250,25 @@ class _SSHTabPageState extends ConsumerState // The wrapper is what the `Scaffold` measures, so it has to be told; // its own default is a full toolbar. preferSize: const Size.fromHeight(SessionTabBar.height), - builder: () => SizedBox( - height: SessionTabBar.height, - // In place of the strip, not over it. Only on the picker: a search here - // narrows that list, and the tabs beside it are open terminals rather - // than anything to find. - child: InlineSearchBar( - controller: _search, - child: SessionTabBar( - names: _sessions.names, - index: _sessions.index, - onTap: _sessions.select, - onClose: _confirmClose, - detailOf: _sessionAddr, - leadingOf: _sessions.statusOf, - sessionActions: _serverActions, - leadingActions: [_sortBtn, _searchBtn, _settingsBtn], + builder: () => WindowControlsInset( + safeArea: true, + child: SizedBox( + height: SessionTabBar.height, + // In place of the strip, not over it. Only on the picker: a search here + // narrows that list, and the tabs beside it are open terminals rather + // than anything to find. + child: InlineSearchBar( + controller: _search, + child: SessionTabBar( + names: _sessions.names, + index: _sessions.index, + onTap: _sessions.select, + onClose: _confirmClose, + detailOf: _sessionAddr, + leadingOf: _sessions.statusOf, + sessionActions: _serverActions, + leadingActions: [_sortBtn, _searchBtn, _settingsBtn], + ), ), ), ), diff --git a/lib/view/page/storage/file_browser.dart b/lib/view/page/storage/file_browser.dart index 8bb845e005..247628aa4b 100644 --- a/lib/view/page/storage/file_browser.dart +++ b/lib/view/page/storage/file_browser.dart @@ -1187,20 +1187,22 @@ class _FileBrowserPageState extends ConsumerState builder: (_, _) => Scaffold( appBar: PreferredSize( preferredSize: const Size.fromHeight(kToolbarHeight), - child: SafeArea( - bottom: false, - child: SizedBox( - height: kToolbarHeight, - // In place of the title and its buttons, as on every other page - // that searches in this app. - child: InlineSearchBar( - controller: _search, - child: CustomAppBar( - title: AnimatedSwitcher( - duration: Durations.short3, - child: Text(title, key: ValueKey(title)), + child: WindowControlsInset( + child: SafeArea( + bottom: false, + child: SizedBox( + height: kToolbarHeight, + // In place of the title and its buttons, as on every other page + // that searches in this app. + child: InlineSearchBar( + controller: _search, + child: CustomAppBar( + title: AnimatedSwitcher( + duration: Durations.short3, + child: Text(title, key: ValueKey(title)), + ), + actions: [...actions, const SizedBox(width: 7)], ), - actions: [...actions, const SizedBox(width: 7)], ), ), ), diff --git a/lib/view/page/storage/tab.dart b/lib/view/page/storage/tab.dart index d8242fae7c..f0913220f4 100644 --- a/lib/view/page/storage/tab.dart +++ b/lib/view/page/storage/tab.dart @@ -311,25 +311,28 @@ class _FileTabPageState extends ConsumerState // The wrapper is what the `Scaffold` measures, so it has to be told; // its own default is a full toolbar. preferSize: const Size.fromHeight(SessionTabBar.height), - builder: () => SizedBox( - height: SessionTabBar.height, - // In place of the strip, as on the terminal tab: what is searched is the - // picker, and the tabs beside it are open browsers. - child: InlineSearchBar( - controller: _search, - child: SessionTabBar( - names: _sessions.names, - index: _sessions.index, - leadingIcon: MingCute.folder_fill, - onTap: _sessions.select, - onClose: _close, - detailOf: _sessionPath, - // One widget that follows whichever session is showing, rather than a - // list the bar would have to rebuild itself to keep current. - sessionActions: [_SessionActions(sessions: _sessions)], - // The same two the rail carries. On one screen the picker is a tab - // rather than a column, and these act on what it lists. - leadingActions: [_searchBtn], + builder: () => WindowControlsInset( + safeArea: true, + child: SizedBox( + height: SessionTabBar.height, + // In place of the strip, as on the terminal tab: what is searched is the + // picker, and the tabs beside it are open browsers. + child: InlineSearchBar( + controller: _search, + child: SessionTabBar( + names: _sessions.names, + index: _sessions.index, + leadingIcon: MingCute.folder_fill, + onTap: _sessions.select, + onClose: _close, + detailOf: _sessionPath, + // One widget that follows whichever session is showing, rather than a + // list the bar would have to rebuild itself to keep current. + sessionActions: [_SessionActions(sessions: _sessions)], + // The same two the rail carries. On one screen the picker is a tab + // rather than a column, and these act on what it lists. + leadingActions: [_searchBtn], + ), ), ), ), diff --git a/lib/view/page/virt/guest.dart b/lib/view/page/virt/guest.dart index 2246748a72..560832090a 100644 --- a/lib/view/page/virt/guest.dart +++ b/lib/view/page/virt/guest.dart @@ -555,43 +555,45 @@ VirtGuestViewKind.overview => _buildOverview(st, guest, state), preferredSize: const Size.fromHeight(SessionTabBar.height), // Pushed on a phone, this page is outside the tab's `SafeArea`, and the // `Scaffold` leaves the status bar's inset to its bar. - child: SafeArea( - bottom: false, - child: SizedBox( - height: SessionTabBar.height, - child: Row( - children: [ - ?widget.leading, - Expanded( - child: SessionSwitcherLabel( - name: guest?.name ?? '', - leading: guest == null - ? null - : VirtStateDot(st.displayState(guest)), - position: widget.switcher && at >= 0 ? at + 1 : null, - total: widget.switcher ? guests.length : 0, - onTap: widget.switcher && guests.length > 1 - ? () => unawaited(_showGuests(st)) - : null, + child: WindowControlsInset( + child: SafeArea( + bottom: false, + child: SizedBox( + height: SessionTabBar.height, + child: Row( + children: [ + ?widget.leading, + Expanded( + child: SessionSwitcherLabel( + name: guest?.name ?? '', + leading: guest == null + ? null + : VirtStateDot(st.displayState(guest)), + position: widget.switcher && at >= 0 ? at + 1 : null, + total: widget.switcher ? guests.length : 0, + onTap: widget.switcher && guests.length > 1 + ? () => unawaited(_showGuests(st)) + : null, + ), ), - ), - if (guest != null) - for (final action in VirtPowerActionUi.barOrder) - if (actions.contains(action)) - Btn.icon( - key: ValueKey(action), - text: action.label, - icon: Icon( - action.icon, - size: 18, - color: action.destructive - ? Theme.of(context).colorScheme.error - : null, + if (guest != null) + for (final action in VirtPowerActionUi.barOrder) + if (actions.contains(action)) + Btn.icon( + key: ValueKey(action), + text: action.label, + icon: Icon( + action.icon, + size: 18, + color: action.destructive + ? Theme.of(context).colorScheme.error + : null, + ), + onTap: () => unawaited(_onPower(guest, action)), ), - onTap: () => unawaited(_onPower(guest, action)), - ), - const SizedBox(width: 7), - ], + const SizedBox(width: 7), + ], + ), ), ), ), diff --git a/lib/view/page/virt/list.dart b/lib/view/page/virt/list.dart index ae88daed15..10ea9973d5 100644 --- a/lib/view/page/virt/list.dart +++ b/lib/view/page/virt/list.dart @@ -58,58 +58,61 @@ extension _List on _VirtTabPageState { final at = hostId == null ? -1 : hostIds.indexOf(hostId); final picking = split && _showHosts; - final bar = SizedBox( - height: SessionTabBar.height, - child: InlineSearchBar( - controller: _search, - hint: libL10n.name, - child: Row( - children: [ - Expanded( - child: FoldingBar( - label: SessionSwitcherLabel( - name: servers[hostId]?.name ?? l10n.virtualization, - icon: Icons.dns_outlined, - position: at < 0 ? null : at + 1, - total: hostIds.length, - // The picker below this bar, where there is a column to - // open it in; a sheet over it otherwise, which needs no - // `open`. - open: picking, - onTap: () => split - ? setState(() => _showHosts = !_showHosts) - : unawaited(_showHostSheet(hostId)), - ), - actions: [ - if (hostId != null && !picking) ...[ - BarAction( - icon: Icons.search, - label: libL10n.search, - onTap: _search.start, - ), - BarAction( - icon: Icons.refresh, - label: libL10n.refresh, - onTap: () => _refresh(hostId), - // Where the reading is said, rather than a line under - // the bar: the button asked for it. - loading: refreshing, - ), - // In the section a new one lands in; the host's answer - // says whether it takes one. - if (_createLabel(caps) case final label?) + final bar = WindowControlsInset( + safeArea: true, + child: SizedBox( + height: SessionTabBar.height, + child: InlineSearchBar( + controller: _search, + hint: libL10n.name, + child: Row( + children: [ + Expanded( + child: FoldingBar( + label: SessionSwitcherLabel( + name: servers[hostId]?.name ?? l10n.virtualization, + icon: Icons.dns_outlined, + position: at < 0 ? null : at + 1, + total: hostIds.length, + // The picker below this bar, where there is a column to + // open it in; a sheet over it otherwise, which needs no + // `open`. + open: picking, + onTap: () => split + ? setState(() => _showHosts = !_showHosts) + : unawaited(_showHostSheet(hostId)), + ), + actions: [ + if (hostId != null && !picking) ...[ BarAction( - key: const ValueKey('virt:create'), - icon: Icons.add, - label: label, - onTap: () => unawaited(_startCreate(hostId, split)), + icon: Icons.search, + label: libL10n.search, + onTap: _search.start, ), + BarAction( + icon: Icons.refresh, + label: libL10n.refresh, + onTap: () => _refresh(hostId), + // Where the reading is said, rather than a line under + // the bar: the button asked for it. + loading: refreshing, + ), + // In the section a new one lands in; the host's answer + // says whether it takes one. + if (_createLabel(caps) case final label?) + BarAction( + key: const ValueKey('virt:create'), + icon: Icons.add, + label: label, + onTap: () => unawaited(_startCreate(hostId, split)), + ), + ], ], - ], + ), ), - ), - const SizedBox(width: 7), - ], + const SizedBox(width: 7), + ], + ), ), ), ); diff --git a/lib/view/page/virt/resources.dart b/lib/view/page/virt/resources.dart index cd1a1cf1e1..5ae9691ae0 100644 --- a/lib/view/page/virt/resources.dart +++ b/lib/view/page/virt/resources.dart @@ -387,31 +387,33 @@ PreferredSizeWidget virtResourceBar({ preferredSize: const Size.fromHeight(SessionTabBar.height), // Pushed on a phone, the page is outside the tab's `SafeArea`, and the // `Scaffold` leaves the status bar's inset to its bar. - child: SafeArea( - bottom: false, - child: SizedBox( - height: SessionTabBar.height, - child: Row( - children: [ - ?leading, - Expanded( - child: SessionSwitcherLabel( - name: name, - icon: icon, - position: position, - total: total, - onTap: onSwitch, - ), - ), - ...actions, - if (onRefresh != null) - Btn.icon( - text: libL10n.refresh, - icon: const Icon(Icons.refresh, size: 18), - onTap: onRefresh, + child: WindowControlsInset( + child: SafeArea( + bottom: false, + child: SizedBox( + height: SessionTabBar.height, + child: Row( + children: [ + ?leading, + Expanded( + child: SessionSwitcherLabel( + name: name, + icon: icon, + position: position, + total: total, + onTap: onSwitch, + ), ), - const SizedBox(width: 7), - ], + ...actions, + if (onRefresh != null) + Btn.icon( + text: libL10n.refresh, + icon: const Icon(Icons.refresh, size: 18), + onTap: onRefresh, + ), + const SizedBox(width: 7), + ], + ), ), ), ), diff --git a/packages/fl_lib b/packages/fl_lib index a7a29a2b34..b0c2dd0868 160000 --- a/packages/fl_lib +++ b/packages/fl_lib @@ -1 +1 @@ -Subproject commit a7a29a2b34db85c4328200eae28715a9e224a1f7 +Subproject commit b0c2dd0868df79f0fdcda79520c6a446ceee0dba From abcb90df735e2f4b2db2647309f269da91e643c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:43:46 +0800 Subject: [PATCH 2/4] chore: bump fl_lib --- packages/fl_lib | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fl_lib b/packages/fl_lib index b0c2dd0868..5c3f324dc2 160000 --- a/packages/fl_lib +++ b/packages/fl_lib @@ -1 +1 @@ -Subproject commit b0c2dd0868df79f0fdcda79520c6a446ceee0dba +Subproject commit 5c3f324dc2fea87006954651d4f3759e8e39bfe6 From 5d9aff6de220e2fd67c46575001389de9155a90c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:30:14 +0800 Subject: [PATCH 3/4] feat: one bar for every narrow tab --- CLAUDE.md | 2 +- lib/view/page/agent/agent.dart | 43 ++- lib/view/page/agent/history.dart | 24 +- lib/view/page/agent/view.dart | 272 ++++++++++-------- lib/view/page/benchmark/tab.dart | 93 +++--- lib/view/page/remote_desktop/tab.dart | 10 +- lib/view/page/remote_desktop/viewer.dart | 247 ++++++---------- lib/view/page/server/tab/bar.dart | 110 ++++--- lib/view/page/server/tab/selection_bar.dart | 111 +++---- lib/view/page/server/tab/sheets.dart | 55 +--- lib/view/page/server/tab/tab.dart | 1 + lib/view/page/server/tab/utils.dart | 36 +-- lib/view/page/snippet/list.dart | 101 +++++-- lib/view/page/ssh/tab.dart | 118 ++++---- lib/view/page/ssh/tab_add.dart | 2 +- lib/view/page/storage/file_browser.dart | 67 ++--- lib/view/page/storage/local.dart | 16 +- lib/view/page/storage/server_file.dart | 16 +- lib/view/page/storage/sftp.dart | 23 +- lib/view/page/storage/tab.dart | 107 ++++--- lib/view/page/virt/create.dart | 6 +- lib/view/page/virt/guest.dart | 67 ++--- lib/view/page/virt/list.dart | 101 +++---- lib/view/page/virt/network.dart | 17 +- lib/view/page/virt/resources.dart | 52 ++-- lib/view/page/virt/storage.dart | 18 +- lib/view/page/virt/tab.dart | 1 - lib/view/widget/folding_bar.dart | 118 -------- lib/view/widget/nav_rail.dart | 10 +- lib/view/widget/tag_switcher.dart | 78 +++++ lib/view/widget/terminal_status_sheet.dart | 54 ++-- packages/fl_lib | 2 +- test/unit/app/folding_bar_test.dart | 116 -------- .../remote_desktop_viewer_test.dart | 6 - test/widget/benchmark_resume_test.dart | 4 +- test/widget/session_tab_bar_test.dart | 10 +- 36 files changed, 889 insertions(+), 1225 deletions(-) delete mode 100644 lib/view/widget/folding_bar.dart create mode 100644 lib/view/widget/tag_switcher.dart delete mode 100644 test/unit/app/folding_bar_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index 325abfab88..fa1ef5f0b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,7 +77,7 @@ A server is reached over SSH, a monitor agent, both, or is this device (`Spi.loc ### Tabs - **A tab's single column is the subject**; the list moves behind a bar button (`view/page/benchmark/tab.dart`). -- Bar: `SessionSwitcherLabel` left, `Btn.icon` at **18pt** right, labels as in the terminal tab. +- **A narrow (one-column) tab bar is a `SwitcherBar`** (fl_lib): a `SessionSwitcherLabel` left, `BarAction`s right (folded into ⋮ when short; `BarAction.listen` for live state; `.button()` for a wide pane's own header). It keeps clear of the iPadOS window controls; a hand-built top bar must wrap itself in `WindowControlsInset`. Wide two-pane layouts keep their own headers. - `detailId` is null at the root; give `leading` where the back button has nowhere to go; no `ref.watch`/`ref.listen` in `detailBuilder`; the subject is a widget, not a route. - `AppTab` is positional; index 7 stays in `_retiredIndices`. diff --git a/lib/view/page/agent/agent.dart b/lib/view/page/agent/agent.dart index e5623d3886..33b0aebf7a 100644 --- a/lib/view/page/agent/agent.dart +++ b/lib/view/page/agent/agent.dart @@ -28,6 +28,22 @@ class _AgentPageState extends ConsumerState @override bool get wantKeepAlive => true; + /// Sends this conversation floating, so it stays reachable from the other + /// tabs, or stops it. On by default, but the window only comes along once + /// there is a chat to follow — see `AgentChats.engaged`. + BarAction _floatAction() { + final floating = ref.watch(agentShellProvider) != FloatShellMode.hidden; + return BarAction( + icon: floating + ? Icons.picture_in_picture_alt + : Icons.picture_in_picture_alt_outlined, + label: context.l10n.floatOverTabs, + // On in the accent, as the server tab's globe toggle is. + color: floating ? Theme.of(context).colorScheme.primary : null, + onTap: ref.read(agentShellProvider.notifier).toggle, + ); + } + @override Widget build(BuildContext context) { super.build(context); @@ -53,32 +69,7 @@ class _AgentPageState extends ConsumerState sideBuilder: (_) => const AgentHistoryPanel(inSheet: false), builder: (_, split) => AgentConversationView( compact: !split, - headerTrailing: const _FloatToggle(), - ), - ); - } -} - -/// Sends this conversation floating, so it stays reachable from the other -/// tabs, or stops it. On by default, but the window only comes along once -/// there is a chat to follow — see `AgentChats.engaged`. -class _FloatToggle extends ConsumerWidget { - const _FloatToggle(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final floating = ref.watch(agentShellProvider) != FloatShellMode.hidden; - // The tab's bar, so the 18pt `Btn.icon` the other tabs' bars use — on in - // the accent, as the server tab's globe toggle is. - return Btn.icon( - text: context.l10n.floatOverTabs, - onTap: ref.read(agentShellProvider.notifier).toggle, - icon: Icon( - floating - ? Icons.picture_in_picture_alt - : Icons.picture_in_picture_alt_outlined, - size: 18, - color: floating ? Theme.of(context).colorScheme.primary : null, + headerAction: _floatAction(), ), ); } diff --git a/lib/view/page/agent/history.dart b/lib/view/page/agent/history.dart index 9f5296d121..bf97666251 100644 --- a/lib/view/page/agent/history.dart +++ b/lib/view/page/agent/history.dart @@ -206,19 +206,19 @@ class _AgentHistoryPanelState extends State { search: _search, actions: [ if (chats.isNotEmpty) - Btn.icon( - text: libL10n.clearHistory, - icon: const Icon(Icons.delete_sweep_outlined, size: 18), + BarAction( + icon: Icons.delete_sweep_outlined, + label: libL10n.clearHistory, onTap: busy ? null : _clear, ), - Btn.icon( - text: libL10n.search, - icon: const Icon(Icons.search, size: 18), + BarAction( + icon: Icons.search, + label: libL10n.search, onTap: _search.start, ), - Btn.icon( - text: context.l10n.askAiNewConversation, - icon: const Icon(Icons.add, size: 18), + BarAction( + icon: Icons.add, + label: context.l10n.askAiNewConversation, onTap: () { AgentChats.startNew(_scope); _closeIfSheet(); @@ -226,10 +226,10 @@ class _AgentHistoryPanelState extends State { ), // Over the sheet rather than in place of it: back returns // to the list it was opened from. - Btn.icon( + BarAction( key: const ValueKey('agent-settings'), - text: libL10n.setting, - icon: const Icon(Icons.settings_outlined, size: 18), + icon: Icons.settings_outlined, + label: libL10n.setting, onTap: () => SettingsSectionPage.route.go( context, SettingsSection.ai, diff --git a/lib/view/page/agent/view.dart b/lib/view/page/agent/view.dart index e12da16b49..873d2a5b73 100644 --- a/lib/view/page/agent/view.dart +++ b/lib/view/page/agent/view.dart @@ -2,6 +2,7 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:fl_pi_llm_ui/fl_pi_llm_ui.dart' show Chats, Composer, LlmConversation, LlmStores, llmL10n; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icons_plus/icons_plus.dart'; import 'package:material_ui/material_ui.dart'; import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/core/llm/scope.dart'; @@ -17,13 +18,8 @@ class AgentHeaderActions extends StatelessWidget { super.key, this.showConversations = false, this.scope, - this.tabBar = false, }); - /// Drawn in the tab's bar, which is drawn as every other tab's — see - /// [agentHeaderButton]. - final bool tabBar; - /// Whether to carry the history and new-chat buttons. /// /// Only where nothing else does: the tab's own line names the chat and opens @@ -39,45 +35,38 @@ class AgentHeaderActions extends StatelessWidget { children: [ if (showConversations) ...[ agentHeaderButton( - tabBar: tabBar, tooltip: context.l10n.askAiHistory, onTap: () => showAgentHistorySheet(context, scope: scope), icon: Icons.history, ), agentHeaderButton( - tabBar: tabBar, tooltip: context.l10n.askAiNewConversation, onTap: () => AgentChats.startNew(scope), icon: Icons.add, ), ], // Only the app-wide Agent opens connections of its own. - if (scope == null) _AdHocSessionsButton(tabBar: tabBar), + if (scope == null) const _AdHocSessionsButton(), ], ); } } -/// One button of an Agent header. -/// -/// In the tab's bar, the `Btn.icon` at 18pt that every other tab's bar draws -/// its actions with. Elsewhere — the floating shell's title bar, the terminal's -/// sheet — an `IconButton` at [floatHeaderIconSize], which those bars are -/// measured for. +/// One button of an Agent header outside the tab — the floating shell's title +/// bar, the terminal's sheet — an `IconButton` at [floatHeaderIconSize], which +/// those bars are measured for. The tab's bar draws [BarAction]s. /// /// [badge] is a count drawn on the icon's corner. Widget agentHeaderButton({ - required bool tabBar, required String tooltip, required VoidCallback onTap, required IconData icon, int? badge, }) { - final glyph = Icon(icon, size: tabBar ? 18 : floatHeaderIconSize); + final glyph = Icon(icon, size: floatHeaderIconSize); final drawn = badge == null ? glyph : Badge.count(count: badge, child: glyph); - if (tabBar) return Btn.icon(text: tooltip, icon: drawn, onTap: onTap); return IconButton(tooltip: tooltip, onPressed: onTap, icon: drawn); } @@ -87,58 +76,56 @@ Widget agentHeaderButton({ /// no server row — and a connection the model opened and forgot about should /// not be something only the model knows exists. class _AdHocSessionsButton extends ConsumerWidget { - const _AdHocSessionsButton({required this.tabBar}); - - final bool tabBar; + const _AdHocSessionsButton(); @override Widget build(BuildContext context, WidgetRef ref) { final sessions = ref.watch(adHocSshSessionsProvider); if (sessions.isEmpty) return const SizedBox.shrink(); return agentHeaderButton( - tabBar: tabBar, tooltip: context.l10n.agentAdHocSessions, - onTap: () => _show(context), + onTap: () => _showAdHocSessions(context), icon: Icons.cable, badge: sessions.length, ); } +} - void _show(BuildContext context) { - context.showRoundDialog( - title: context.l10n.agentAdHocSessions, - child: Consumer( - builder: (context, ref, _) { - final sessions = ref.watch(adHocSshSessionsProvider).values.toList(); - if (sessions.isEmpty) return Text(libL10n.empty); - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final session in sessions) - ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.cable, size: 20), - title: Text(session.label), - subtitle: Text( - session.id, - style: const TextStyle(fontFamily: 'monospace'), - ), - trailing: IconButton( - tooltip: libL10n.close, - onPressed: () => ref - .read(adHocSshSessionsProvider.notifier) - .close(session.id), - icon: const Icon(Icons.link_off), - ), +/// The connections the Agent opened on its own, each with a way to close it. +void _showAdHocSessions(BuildContext context) { + context.showRoundDialog( + title: context.l10n.agentAdHocSessions, + child: Consumer( + builder: (context, ref, _) { + final sessions = ref.watch(adHocSshSessionsProvider).values.toList(); + if (sessions.isEmpty) return Text(libL10n.empty); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final session in sessions) + ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.cable, size: 20), + title: Text(session.label), + subtitle: Text( + session.id, + style: const TextStyle(fontFamily: 'monospace'), ), - ], - ); - }, - ), - actions: [Btn.ok()], - ); - } + trailing: IconButton( + tooltip: libL10n.close, + onPressed: () => ref + .read(adHocSshSessionsProvider.notifier) + .close(session.id), + icon: const Icon(Icons.link_off), + ), + ), + ], + ); + }, + ), + actions: [Btn.ok()], + ); } /// A chat of [scope] and the box you type into: fl_pi_llm_ui's conversation @@ -152,7 +139,7 @@ class AgentConversationView extends StatelessWidget { required this.compact, this.scope, this.showHeader = true, - this.headerTrailing, + this.headerAction, }); /// Too narrow for the chat list to sit beside it, so the header opens it. @@ -165,7 +152,7 @@ class AgentConversationView extends StatelessWidget { final bool showHeader; /// After the chat's own buttons. - final Widget? headerTrailing; + final BarAction? headerAction; @override Widget build(BuildContext context) { @@ -222,76 +209,113 @@ class AgentConversationView extends StatelessWidget { } Widget _buildHeader(BuildContext context, String? id) { - return ListenableBuilder( - listenable: LlmStores.chat.changes, - builder: (context, _) { - final title = switch (id == null ? null : LlmStores.chat.fetch(id)) { - // As the list names it: an empty title is no title. - final meta? when meta.title?.isNotEmpty ?? false => meta.title!, - _? => context.l10n.askAiUntitledConversation, - null => context.l10n.askAiNewConversation, - }; - return Padding( - padding: const EdgeInsets.fromLTRB(13, 7, 7, 0), - child: Row( - children: [ - Expanded( - child: InkWell( - borderRadius: BorderRadius.circular(9), - // Too narrow for the list beside it: the title is the way to - // it, as the terminal tab's session name is. - onTap: compact - ? () => showAgentHistorySheet(context, scope: scope) - : null, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 7), - child: Row( - children: [ - Flexible( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w500, - ), - ), - ), - if (compact) const Icon(Icons.expand_more, size: 18), - // Writing a reply or running a tool: the one sign of - // it that stays when the reply is scrolled away. - AgentBusyBuilder( - scope: scope, - builder: (_, activity) => - activity == AgentActivity.idle - ? UIs.placeholder - : Padding( - padding: const EdgeInsets.only(left: 9), - child: AgentActivityMark(activity), - ), + return Consumer( + builder: (context, ref, _) { + // Only the app-wide Agent opens connections of its own. + final adHoc = scope == null + ? ref.watch(adHocSshSessionsProvider) + : const {}; + final actions = [ + // Only where the list is out of sight: beside it, the list's own + // `+` is the one, and a second here was the same button twice in + // one window. + if (id != null && compact) + BarAction( + icon: Icons.add, + label: context.l10n.askAiNewConversation, + onTap: () => AgentChats.startNew(scope), + ), + // How many hosts the Agent has open that are not configured + // servers. They are invisible otherwise — no card, no server row. + if (adHoc.isNotEmpty) + BarAction( + icon: Icons.cable, + label: context.l10n.agentAdHocSessions, + badge: adHoc.length, + onTap: () => _showAdHocSessions(context), + ), + ?headerAction, + ]; + return ListenableBuilder( + listenable: LlmStores.chat.changes, + builder: (context, _) { + final title = switch (id == null ? null : LlmStores.chat.fetch(id)) { + // As the list names it: an empty title is no title. + final meta? when meta.title?.isNotEmpty ?? false => meta.title!, + _? => context.l10n.askAiUntitledConversation, + null => context.l10n.askAiNewConversation, + }; + return compact + ? _buildBar(context, title, actions) + : _buildPaneHeader(title, actions); + }, + ); + }, + ); + } + + /// One column: every tab's bar. The title is the way to the list, as the + /// terminal tab's session name is. + Widget _buildBar(BuildContext context, String title, List actions) { + return AgentBusyBuilder( + scope: scope, + builder: (context, activity) => SwitcherBar( + switcher: SessionSwitcherLabel( + name: title, + icon: MingCute.magic_2_fill, + // Writing a reply or running a tool: the one sign of it that stays + // when the reply is scrolled away. + leading: activity == AgentActivity.idle + ? null + : AgentActivityMark(activity), + onTap: () => showAgentHistorySheet(context, scope: scope), + ), + actions: actions, + ), + ); + } + + /// Beside the list of chats, which already says how to reach the others. + Widget _buildPaneHeader(String title, List actions) { + return WindowControlsInset( + safeArea: true, + child: Padding( + padding: const EdgeInsets.fromLTRB(13, 7, 7, 0), + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 7), + child: Row( + children: [ + Flexible( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, ), - ], + ), ), - ), + AgentBusyBuilder( + scope: scope, + builder: (_, activity) => activity == AgentActivity.idle + ? UIs.placeholder + : Padding( + padding: const EdgeInsets.only(left: 9), + child: AgentActivityMark(activity), + ), + ), + ], ), ), - // Only where the list is out of sight: beside it, the list's - // own `+` is the one, and a second here was the same button - // twice in one window. - if (id != null && compact) - agentHeaderButton( - tabBar: true, - tooltip: context.l10n.askAiNewConversation, - onTap: () => AgentChats.startNew(scope), - icon: Icons.add, - ), - AgentHeaderActions(scope: scope, tabBar: true), - ?headerTrailing, - ], - ), - ); - }, + ), + for (final action in actions) action.button(), + ], + ), + ), ); } diff --git a/lib/view/page/benchmark/tab.dart b/lib/view/page/benchmark/tab.dart index 1c81b1006a..a6a3ada05b 100644 --- a/lib/view/page/benchmark/tab.dart +++ b/lib/view/page/benchmark/tab.dart @@ -215,31 +215,19 @@ extension _Widgets on _BenchmarkTabPageState { // and the other ways out as actions on the right. The chevron is what // makes that reading available at all — it says "one of several" where a // plain title says "this page". - appBar: PreferredSize( - preferredSize: const Size.fromHeight(SessionTabBar.height), - child: WindowControlsInset( - safeArea: true, - child: SizedBox( - height: SessionTabBar.height, - child: Row( - children: [ - Expanded( - child: SessionSwitcherLabel( - name: spi.name, - icon: Icons.dns_outlined, - onTap: () => _pickServer(false), - ), - ), - Btn.icon( - text: l10n.history, - icon: const Icon(Icons.history, size: 18), - onTap: () => _showHistorySheet(servers), - ), - const SizedBox(width: 7), - ], - ), - ), + appBar: SwitcherBar( + switcher: SessionSwitcherLabel( + name: spi.name, + icon: Icons.dns_outlined, + onTap: () => _pickServer(false), ), + actions: [ + BarAction( + icon: Icons.history, + label: l10n.history, + onTap: () => _showHistorySheet(servers), + ), + ], ), ); } @@ -262,39 +250,32 @@ extension _Widgets on _BenchmarkTabPageState { return Scaffold( // No title of its own: the nav rail beside this already names the tab. - // The bar is the search field while a search is on, and the buttons - // otherwise — the same strip changing rather than one control swapped - // for another, as on every other tab that searches. - // - // An explicit leading for the same reason as the run column's: with none, - // `CustomAppBar` supplies a back button wired to `onCloseDetail`, and - // this column is not a detail — it is the thing a detail is closed back - // to. - appBar: CustomAppBar( - // In a sheet the way out is dragging it away or the button that opened - // it; a back arrow there would be a third answer to a question already - // answered twice. - leading: const SizedBox.shrink(), - title: InlineSearchBar( - controller: _search, - hint: libL10n.server, - child: const SizedBox.shrink(), - ), - actions: [ - Btn.icon( - text: libL10n.search, - icon: const Icon(Icons.search, size: 18), - onTap: _search.start, + // The head every list column has — the same row of buttons, centred, + // and the field it becomes while a search is on. + appBar: PreferredSize( + preferredSize: const Size.fromHeight(SideBarActions.height), + child: WindowControlsInset( + safeArea: true, + child: SideBarActions( + search: _search, + searchHint: libL10n.server, + actions: [ + BarAction( + icon: Icons.search, + label: libL10n.search, + onTap: _search.start, + ), + // Not in the sheet: with one column the run is already on + // screen behind it, and it carries this itself. + if (servers.isNotEmpty && !inSheet) + BarAction( + icon: Icons.play_arrow, + label: l10n.benchmark, + onTap: () => _pickServer(split), + ), + ], ), - // Not in the sheet: with one column the run is already on screen - // behind it, and it carries this itself. - if (servers.isNotEmpty && !inSheet) - Btn.icon( - text: l10n.benchmark, - icon: const Icon(Icons.play_arrow, size: 18), - onTap: () => _pickServer(split), - ), - ], + ), ), body: servers.isEmpty ? _centered(l10n.benchmarkNoServers) diff --git a/lib/view/page/remote_desktop/tab.dart b/lib/view/page/remote_desktop/tab.dart index f819573121..afcd34a557 100644 --- a/lib/view/page/remote_desktop/tab.dart +++ b/lib/view/page/remote_desktop/tab.dart @@ -249,13 +249,13 @@ class _RemoteDesktopTabPageState extends ConsumerState { }, detailOf: (index) => _sessionDetail(sessions[index - 1]), sessionActions: const [], - leadingActions: [_sortBtn], + leadingActions: [_sortAction], ); } - Widget get _sortBtn => Btn.icon( - text: libL10n.sort, - icon: Icon(_SortOrder.stored.icon, size: 18), + BarAction get _sortAction => BarAction( + icon: _SortOrder.stored.icon, + label: libL10n.sort, onTap: _showSortSheet, ); @@ -272,7 +272,7 @@ class _RemoteDesktopTabPageState extends ConsumerState { ) => ListView( padding: const EdgeInsets.only(top: 4, bottom: 77), children: [ - SideBarActions(actions: [_sortBtn]), + SideBarActions(actions: [_sortAction]), if (state.ordered.isNotEmpty) SideBarSection(libL10n.running), for (final session in state.ordered) SideBarTile( diff --git a/lib/view/page/remote_desktop/viewer.dart b/lib/view/page/remote_desktop/viewer.dart index 03bd14870d..990e21fa42 100644 --- a/lib/view/page/remote_desktop/viewer.dart +++ b/lib/view/page/remote_desktop/viewer.dart @@ -281,147 +281,98 @@ class _RemoteDesktopViewerState extends ConsumerState { ); } - /// The server tab's bar: [SessionTabBar.height] tall, the switcher on the - /// left, [Btn.icon]s at 18pt on the right. + /// Every tab's bar — see [SwitcherBar]. Widget _toolbar(RemoteDesktopSessionView session) { final l10n = context.l10n; final scheme = Theme.of(context).colorScheme; final notifier = ref.read(remoteDesktopSessionsProvider.notifier); final connected = session.connectionState == ffi.RemoteDesktopConnectionState.connected; + final input = connected && !session.viewOnly; final switcher = widget.switcher; - /// A toggle drawn in the primary while it is on, as the globe button is. - Color? on(bool active) => active ? scheme.primary : null; - - /// `Btn.icon` has no disabled look of its own: a null `onTap` only stops - /// the ink, so the icon is dimmed here to say so. - Widget btn( - String text, - IconData icon, - VoidCallback? onTap, { - Color? color, - Key? key, - }) => Btn.icon( - key: key, - text: text, - icon: Icon( - icon, - size: 18, - color: onTap == null ? scheme.onSurface.withValues(alpha: 0.38) : color, - ), - onTap: onTap, - ); - return Material( color: scheme.surface, - child: SizedBox( - height: SessionTabBar.height, - child: LayoutBuilder( - builder: (context, constraints) { - final compact = remoteDesktopUsesCompactToolbar( - constraints.maxWidth, - ); - return Row( - children: [ - Expanded( - child: widget.showName - ? SessionSwitcherLabel( - name: session.profile.name, - position: switcher?.position, - total: switcher?.total ?? 0, - leading: _connectionDot(session.connectionState), - onTap: switcher?.onTap, - ) - : Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.only(left: 17), - child: _connectionDot(session.connectionState), - ), - ), + child: SwitcherBar( + switcher: widget.showName + ? SessionSwitcherLabel( + name: session.profile.name, + position: switcher?.position, + total: switcher?.total ?? 0, + leading: _connectionDot(session.connectionState), + onTap: switcher?.onTap, + ) + : Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(left: 17), + child: _connectionDot(session.connectionState), ), - _scaleBtn(), - btn( - session.viewOnly - ? l10n.remoteDesktopDisableViewOnly - : l10n.remoteDesktopViewOnly, - session.viewOnly ? Icons.visibility : Icons.mouse, - () => _setViewOnly(session, !session.viewOnly), - // On is the exception: input is not reaching the desktop. - color: on(session.viewOnly), - key: _viewOnlyKey, - ), - if (!compact) - btn( - l10n.remoteDesktopSendClipboardText, - Icons.content_paste, - connected && !session.viewOnly - ? () => _sendClipboard(session) - : null, - ), - btn( - l10n.remoteDesktopShowKeyboard, - Icons.keyboard, - session.viewOnly ? null : _showKeyboard, - key: _keyboardKey, - ), - btn( - widget.fullScreen - ? l10n.remoteDesktopExitFullScreen - : l10n.remoteDesktopFullScreen, - widget.fullScreen ? Icons.fullscreen_exit : Icons.fullscreen, - _toggleFullScreen, - ), - _menuBtn( - key: _moreKey, - text: l10n.remoteDesktopMoreControls, - icon: Icons.more_horiz, - actions: () { - final input = connected && !session.viewOnly; - return [ - if (compact) - ContextMenuAction( - text: l10n.remoteDesktopSendClipboardText, - icon: Icons.content_paste, - enabled: input, - onTap: () => _sendClipboard(session), - ), - ContextMenuAction( - text: l10n.remoteDesktopSendCtrlAltDelete, - icon: Icons.keyboard_command_key, - enabled: input, - onTap: () => _sendCtrlAltDelete(session), - ), - if (isMobile) - ContextMenuAction( - text: _touchMode == _TouchMode.trackpad - ? l10n.remoteDesktopUseDirectPointer - : l10n.remoteDesktopUseTouchpadPointer, - icon: _touchMode == _TouchMode.trackpad - ? Icons.touch_app - : Icons.mouse, - onTap: _toggleTouchMode, - ), - ContextMenuAction( - text: l10n.remoteDesktopReconnect, - icon: Icons.refresh, - onTap: () => notifier.reconnect(session.id), - ), - ContextMenuAction( - text: l10n.remoteDesktopCloseSession, - icon: Icons.link_off, - destructive: true, - onTap: () => _closeSession(session), - ), - ]; - }, - ), - const SizedBox(width: 7), - ], - ); - }, - ), + ), + // Most needed first: what does not fit folds into the menu from the + // end. + actions: [ + BarAction( + key: _viewOnlyKey, + icon: session.viewOnly ? Icons.visibility : Icons.mouse, + label: session.viewOnly + ? l10n.remoteDesktopDisableViewOnly + : l10n.remoteDesktopViewOnly, + // On is the exception: input is not reaching the desktop. + color: session.viewOnly ? scheme.primary : null, + onTap: () => _setViewOnly(session, !session.viewOnly), + ), + BarAction( + key: _keyboardKey, + icon: Icons.keyboard, + label: l10n.remoteDesktopShowKeyboard, + onTap: session.viewOnly ? null : _showKeyboard, + ), + _scaleAction(), + BarAction( + icon: widget.fullScreen ? Icons.fullscreen_exit : Icons.fullscreen, + label: widget.fullScreen + ? l10n.remoteDesktopExitFullScreen + : l10n.remoteDesktopFullScreen, + onTap: _toggleFullScreen, + ), + BarAction( + icon: Icons.content_paste, + label: l10n.remoteDesktopSendClipboardText, + onTap: input ? () => _sendClipboard(session) : null, + ), + ], + menuKey: _moreKey, + menuLabel: l10n.remoteDesktopMoreControls, + menu: () => [ + ContextMenuAction( + text: l10n.remoteDesktopSendCtrlAltDelete, + icon: Icons.keyboard_command_key, + enabled: input, + onTap: () => _sendCtrlAltDelete(session), + ), + if (isMobile) + ContextMenuAction( + text: _touchMode == _TouchMode.trackpad + ? l10n.remoteDesktopUseDirectPointer + : l10n.remoteDesktopUseTouchpadPointer, + icon: _touchMode == _TouchMode.trackpad + ? Icons.touch_app + : Icons.mouse, + onTap: _toggleTouchMode, + ), + ContextMenuAction( + text: l10n.remoteDesktopReconnect, + icon: Icons.refresh, + onTap: () => notifier.reconnect(session.id), + ), + ContextMenuAction( + text: l10n.remoteDesktopCloseSession, + icon: Icons.link_off, + destructive: true, + onTap: () => _closeSession(session), + ), + ], ), ); } @@ -452,7 +403,7 @@ class _RemoteDesktopViewerState extends ConsumerState { /// Fit, actual size and the zoom steps are one choice, so one button: its /// icon says which is in effect, the menu ticks it. - Widget _scaleBtn() { + BarAction _scaleAction() { final l10n = context.l10n; ContextMenuAction item( String text, @@ -460,8 +411,8 @@ class _RemoteDesktopViewerState extends ConsumerState { VoidCallback onTap, ) => ContextMenuAction(text: text, checked: current, onTap: onTap); final custom = _scaleMode == RemoteDesktopScaleMode.custom; - return _menuBtn( - text: switch (_scaleMode) { + return BarAction.menu( + label: switch (_scaleMode) { RemoteDesktopScaleMode.fit => l10n.remoteDesktopFitToWindow, RemoteDesktopScaleMode.actual => l10n.remoteDesktopActualSize, RemoteDesktopScaleMode.custom => @@ -472,7 +423,7 @@ class _RemoteDesktopViewerState extends ConsumerState { RemoteDesktopScaleMode.actual => Icons.crop_free, RemoteDesktopScaleMode.custom => Icons.zoom_in, }, - actions: () => [ + menu: () => [ item( l10n.remoteDesktopFitToWindow, _scaleMode == RemoteDesktopScaleMode.fit, @@ -496,32 +447,6 @@ class _RemoteDesktopViewerState extends ConsumerState { ); } - /// A [Btn.icon] that opens a menu under itself: fl_lib's context menu, the - /// one the rest of the app's lists open. - Widget _menuBtn({ - Key? key, - required String text, - required IconData icon, - required List Function() actions, - }) => Builder( - key: key, - builder: (btnContext) => Btn.icon( - text: text, - icon: Icon(icon, size: 18), - onTap: () { - final box = btnContext.findRenderObject(); - showContextMenu( - btnContext, - actions(), - // Hung from the button's lower edge, so the menu drops from it. - at: box is RenderBox && box.hasSize - ? box.localToGlobal(box.size.bottomCenter(Offset.zero)) - : null, - ); - }, - ), - ); - Widget _connectionDot(ffi.RemoteDesktopConnectionState state) { final l10n = context.l10n; final color = switch (state) { @@ -1024,6 +949,4 @@ class _RemoteDesktopViewerState extends ConsumerState { } } -bool remoteDesktopUsesCompactToolbar(double width) => width < 650; - enum _TouchMode { trackpad, direct } diff --git a/lib/view/page/server/tab/bar.dart b/lib/view/page/server/tab/bar.dart index 8f00d09939..d49b38eea5 100644 --- a/lib/view/page/server/tab/bar.dart +++ b/lib/view/page/server/tab/bar.dart @@ -15,14 +15,24 @@ extension _Bar on _ServerPageState { /// shift the page contents by that much on every switch between tabs. PreferredSizeWidget _buildTagBar(String? openId, List filtered) { if (_selecting) return _buildSelectionBar(filtered); + final narrow = !_opensInPlace(context); return PreferredSizeListenBuilder( // Which tag is on, what tags there are to choose between, and how the // list is ordered — the sort button draws its own current icon. listenable: Listenable.merge([_tags, _tag, _sortVersion, _autoDensity]), // The wrapper is what the `Scaffold` measures, so it has to be told; its // own default is a full toolbar. - preferSize: const Size.fromHeight(SessionTabBar.height), + preferSize: const Size.fromHeight(SwitcherBar.height), builder: () { + // One column: every tab's bar. A server opens as a page of its own + // there, so this is always the list's. + if (narrow) { + return SwitcherBar( + search: _search, + switcher: _buildTagSwitcher(), + actions: [_densityAction(filtered.length), ..._listActions()], + ); + } return WindowControlsInset( safeArea: true, child: SizedBox( @@ -39,13 +49,7 @@ extension _Bar on _ServerPageState { // nothing open — `SessionSwitcherLabel` holds it 14 off the // edge, and a button's own 7 is half of that — so the first // thing in the bar is in the same place either way. - if (openId != null) const SizedBox(width: 7), - if (openId != null) - Btn.icon( - text: libL10n.close, - icon: const Icon(Icons.arrow_back_ios_new, size: 17), - onTap: _closeDetail, - ), + if (openId != null) SwitcherBar.back(onTap: _closeDetail), Expanded( child: openId == null ? _buildTagSwitcher() @@ -57,10 +61,10 @@ extension _Bar on _ServerPageState { _buildDensityControl(filtered.length, room: cons.maxWidth), // With one open, the page is that machine, so the bar offers // what its own page does when pushed on a phone. - if (openId == null) - ..._listActions() - else - ..._serverActions(openId), + for (final action in openId == null + ? _listActions() + : _serverActions(openId)) + action.button(), const SizedBox(width: 7), ], ), @@ -72,7 +76,7 @@ extension _Bar on _ServerPageState { ); } - List _serverActions(String openId) { + List _serverActions(String openId) { final spi = ref.read(serversProvider).servers[openId]; if (spi == null) return const []; return [ @@ -81,11 +85,7 @@ extension _Bar on _ServerPageState { spi, onDeleted: _closeDetail, )) - Btn.icon( - text: action.label, - icon: Icon(action.icon, size: 18), - onTap: action.onTap, - ), + BarAction(icon: action.icon, label: action.label, onTap: action.onTap), ]; } @@ -101,14 +101,7 @@ extension _Bar on _ServerPageState { // Room for four labelled positions, the switcher beside them and the four // buttons after them. Below it the labels are what would have to shrink, // and a segmented control with no labels is four unexplained icons. - if (room < 860) { - final resolved = _resolvedDensity(stored, count); - return Btn.icon( - text: resolved.label, - icon: Icon(resolved.icon, size: 18), - onTap: () => _showDensitySheet(count), - ); - } + if (room < 860) return _densityAction(count).button(); return Padding( padding: const EdgeInsets.symmetric(horizontal: 7), @@ -133,6 +126,17 @@ extension _Bar on _ServerPageState { ); } + /// The density as a button that opens the four as a sheet, which is what + /// the tag and the sort already do. + BarAction _densityAction(int count) { + final resolved = _resolvedDensity(ServerDensityPref.of(_tag.value), count); + return BarAction( + icon: resolved.icon, + label: resolved.label, + onTap: () => _showDensitySheet(count), + ); + } + /// How much larger than drawn the list's text is, which is what rules a /// tile out: the page's own scale, asked of the size a tile's name is. /// @@ -181,16 +185,9 @@ extension _Bar on _ServerPageState { Widget _buildTagSwitcher() { final tags = _tags.value.toList(); - final current = _tag.value; - final at = tags.indexOf(current); - - return SessionSwitcherLabel( - name: current.isEmpty ? libL10n.all : '#$current', - // Counting from 1, and null on "all" — which is not one of the tags but - // the absence of a choice among them, so it shows the icon instead. - position: at < 0 ? null : at + 1, - total: tags.length, - icon: MingCute.hashtag_line, + return tagSwitcherLabel( + tags: tags, + current: _tag.value, // Opens even with no tags anywhere. It used to be a plain label then — // the rule the session strips follow with nothing open — but the two // cases are not alike: a terminal strip with no sessions is a feature @@ -228,39 +225,30 @@ extension _Bar on _ServerPageState { /// for the guide to point at. A second caller drawing these while the bar is /// mounted would need its own key: two widgets holding one `GlobalKey` at /// the same time is an exception rather than a bad layout. - List _listActions() => [ - Btn.icon( - text: libL10n.search, - icon: const Icon(Icons.search, size: 18), - onTap: _search.start, - ), - Btn.icon( - text: libL10n.sort, - icon: Icon(ServerSortOrder.of(_tag.value).icon, size: 18), + List _listActions() => [ + BarAction(icon: Icons.search, label: libL10n.search, onTap: _search.start), + BarAction( + icon: ServerSortOrder.of(_tag.value).icon, + label: libL10n.sort, onTap: _showSortSheet, ), // Absent, not disabled, when the feature is off: a button that explains // itself by doing nothing is worse than one that is not offered. if (Stores.setting.globeEnabled.fetch()) - _globe.listenVal( - (on) => Btn.icon( - key: _globeBtnKey, - text: l10n.globe, - icon: Icon( - on ? Icons.grid_view_rounded : Icons.public, - size: 18, - color: on ? Theme.of(context).colorScheme.primary : null, - ), + BarAction.listen( + key: _globeBtnKey, + listenable: _globe, + build: (context) => BarAction( + icon: _globe.value ? Icons.grid_view_rounded : Icons.public, + label: l10n.globe, + color: _globe.value ? Theme.of(context).colorScheme.primary : null, onTap: _toggleGlobe, ), ), - // Its own context, so the menu drops from this button. - Builder( - builder: (ctx) => Btn.icon( - text: libL10n.add, - icon: const Icon(Icons.add, size: 18), - onTap: () => _onTapAddServer(ctx), - ), + BarAction.menu( + icon: Icons.add, + label: libL10n.add, + menu: _addServerMenu, ), ]; diff --git a/lib/view/page/server/tab/selection_bar.dart b/lib/view/page/server/tab/selection_bar.dart index 4b9b9a719a..753cedecf6 100644 --- a/lib/view/page/server/tab/selection_bar.dart +++ b/lib/view/page/server/tab/selection_bar.dart @@ -43,78 +43,59 @@ class ServerSelectionBar extends StatelessWidget final VoidCallback onDelete; @override - Size get preferredSize => const Size.fromHeight(SessionTabBar.height); + Size get preferredSize => const Size.fromHeight(SwitcherBar.height); @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; - return WindowControlsInset( - safeArea: true, - child: SizedBox( - height: SessionTabBar.height, - child: Row( - children: [ - Btn.icon( - text: libL10n.close, - icon: const Icon(Icons.close, size: 18), - onTap: onClose, - ), - Icon(Icons.check_box, size: 19, color: scheme.primary), - const SizedBox(width: 9), - Text( - '$count', - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(width: 7), - // The one part that gives way on a narrow bar. The buttons after it - // are fixed-size icons and all of them are needed; the total is the - // least of what the bar says, and at 320pt with large text it was - // what pushed them past the edge. - Expanded( - child: Text( - '/ $total', - style: UIs.text11Grey, - maxLines: 1, - softWrap: false, - overflow: TextOverflow.ellipsis, - ), - ), - Btn.icon( - text: l10n.connect, - icon: const Icon(Icons.link, size: 18), - onTap: onConnect, - ), - Btn.icon( - text: l10n.disconnect, - icon: const Icon(Icons.link_off, size: 18), - onTap: onDisconnect, - ), - Btn.icon( - text: libL10n.tag, - icon: const Icon(MingCute.hashtag_line, size: 18), - onTap: onTag, - ), - // The one action here that is about the list rather than about the - // machines. Dragging is how one server is moved, and forty is - // exactly where dragging stops being a way to do anything. - Btn.icon( - text: l10n.move, - icon: const Icon(Icons.swap_vert, size: 18), - onTap: onMove, - ), - Btn.icon( - text: libL10n.delete, - icon: const Icon(Icons.delete, size: 18), - onTap: onDelete, + return SwitcherBar( + leading: Btn.icon( + text: libL10n.close, + icon: const Icon(Icons.close, size: 18), + onTap: onClose, + ), + switcherMinWidth: 72, + switcher: Row( + children: [ + Icon(Icons.check_box, size: 19, color: scheme.primary), + const SizedBox(width: 9), + Text( + '$count', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + ), + const SizedBox(width: 7), + // The part that gives way first: the total is the least of what the + // bar says. + Expanded( + child: Text( + '/ $total', + style: UIs.text11Grey, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, ), - const SizedBox(width: 7), - ], - ), + ), + ], ), + actions: [ + BarAction(icon: Icons.link, label: l10n.connect, onTap: onConnect), + BarAction( + icon: Icons.link_off, + label: l10n.disconnect, + onTap: onDisconnect, + ), + BarAction( + icon: MingCute.hashtag_line, + label: libL10n.tag, + onTap: onTag, + ), + // The one action here that is about the list rather than about the + // machines. Dragging is how one server is moved, and forty is exactly + // where dragging stops being a way to do anything. + BarAction(icon: Icons.swap_vert, label: l10n.move, onTap: onMove), + BarAction(icon: Icons.delete, label: libL10n.delete, onTap: onDelete), + ], ); } } diff --git a/lib/view/page/server/tab/sheets.dart b/lib/view/page/server/tab/sheets.dart index 4c142b71cd..e5a0e6f8af 100644 --- a/lib/view/page/server/tab/sheets.dart +++ b/lib/view/page/server/tab/sheets.dart @@ -85,52 +85,15 @@ extension _Sheets on _ServerPageState { ); } - /// The tags, as rows. The same sheet the session switchers open, for the - /// same reason: a strip of them would be as wide as the names happened to be. - Future _showTagSheet(List tags) async { - await showRowsSheet( - context, - rows: (ctx) { - void pick(String tag) { - Navigator.of(ctx).pop(); - _tag.value = tag; - } - - return [ - SheetChoiceTile( - icon: MingCute.hashtag_line, - title: libL10n.all, - selected: _tag.value.isEmpty, - onTap: () => pick(TagSwitcher.kDefaultTag), - ), - const Divider(height: 1), - // Where tags come from, for the sheet that would otherwise be one - // row saying "All" — which reads as a broken filter rather than as - // an empty one. A server's editor is the only place they are made, - // and nothing on this tab says so. - if (tags.isEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(17, 17, 17, 27), - child: Text( - l10n.tagsEmptyTip, - style: UIs.textGrey, - textAlign: TextAlign.center, - ), - ) - else - for (final tag in tags) - // The same shape as the row above it: the mark, then the name. - // The mark is the `#`, so the name does not carry one as well. - SheetChoiceTile( - icon: MingCute.hashtag_line, - title: tag, - selected: tag == _tag.value, - onTap: () => pick(tag), - ), - ]; - }, - ); - } + /// Where tags come from is a server's editor, and nothing on this tab + /// says so — see [showTagSheet]. + Future _showTagSheet(List tags) => showTagSheet( + context, + tags: tags, + current: _tag.value, + onPick: (tag) => _tag.value = tag, + emptyTip: l10n.tagsEmptyTip, + ); /// Every machine, for picking one without going back to the grid. /// diff --git a/lib/view/page/server/tab/tab.dart b/lib/view/page/server/tab/tab.dart index 8c83b5b2c4..cc8aaa8706 100644 --- a/lib/view/page/server/tab/tab.dart +++ b/lib/view/page/server/tab/tab.dart @@ -43,6 +43,7 @@ import 'package:server_box/view/widget/dist_icon.dart'; import 'package:server_box/view/widget/pane_settings.dart'; import 'package:server_box/view/widget/server_globe.dart'; import 'package:server_box/view/widget/server_share.dart'; +import 'package:server_box/view/widget/tag_switcher.dart'; part 'bar.dart'; part 'bulk.dart'; diff --git a/lib/view/page/server/tab/utils.dart b/lib/view/page/server/tab/utils.dart index a02cf23e95..28b26f990e 100644 --- a/lib/view/page/server/tab/utils.dart +++ b/lib/view/page/server/tab/utils.dart @@ -109,24 +109,24 @@ extension _Actions on _ServerPageState { /// The cost is a tap: this used to open the editor directly. Adding a server /// is rare enough that finding the other two is worth more than saving it. /// - /// A menu dropped from [anchor], the button that was pressed, rather than a - /// dialog: three entries with nothing to read first, and a dialog took the - /// middle of the window and a cancel button to say so. - void _onTapAddServer(BuildContext anchor) { - showContextMenu( - anchor, - [ - for (final way in _AddServerWay.values) - if (way.available) - ContextMenuAction( - text: way.label, - icon: way.icon, - onTap: () => unawaited(_addServer(way)), - ), - ], - at: contextMenuAnchorBelow(anchor), - ); - } + /// A menu dropped from the button that was pressed, rather than a dialog: + /// three entries with nothing to read first, and a dialog took the middle of + /// the window and a cancel button to say so. + void _onTapAddServer(BuildContext anchor) => showContextMenu( + anchor, + _addServerMenu(), + at: contextMenuAnchorBelow(anchor), + ); + + List _addServerMenu() => [ + for (final way in _AddServerWay.values) + if (way.available) + ContextMenuAction( + text: way.label, + icon: way.icon, + onTap: () => unawaited(_addServer(way)), + ), + ]; Future _addServer(_AddServerWay way) async { if (!mounted) return; diff --git a/lib/view/page/snippet/list.dart b/lib/view/page/snippet/list.dart index 262b7cc622..1908783ac9 100644 --- a/lib/view/page/snippet/list.dart +++ b/lib/view/page/snippet/list.dart @@ -1,11 +1,13 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icons_plus/icons_plus.dart'; import 'package:material_ui/material_ui.dart'; import 'package:server_box/core/utils/tag_group.dart'; import 'package:server_box/data/model/server/snippet.dart'; import 'package:server_box/data/provider/snippet.dart'; import 'package:server_box/view/page/snippet/edit.dart'; import 'package:server_box/view/widget/pane_settings.dart'; +import 'package:server_box/view/widget/tag_switcher.dart'; class SnippetListPage extends ConsumerStatefulWidget { const SnippetListPage({super.key}); @@ -112,6 +114,7 @@ class _SnippetListPageState extends ConsumerState onTagChanged: (tag) => _tag.value = tag, initTag: _tag.value, search: _search, + split: split, onSearch: _search.start, onAdd: () => _edit(null, split), ), @@ -214,6 +217,10 @@ final class _SnippetBar extends StatelessWidget implements PreferredSizeWidget { final VoidCallback onSearch; final VoidCallback onAdd; + /// Beside the editor, the head of a list column; on one column, every + /// tab's bar. + final bool split; + /// The bar's search — see [InlineSearchBar]. It was a results page, which /// put a read-only copy of the list over the list. final InlineSearchController search; @@ -224,48 +231,80 @@ final class _SnippetBar extends StatelessWidget implements PreferredSizeWidget { required this.onTagChanged, required this.onSearch, required this.onAdd, + required this.split, required this.search, }); @override Widget build(BuildContext context) { - // In place of the switcher, as on every other tab that searches. + if (!split) { + return ListenableBuilder( + listenable: tags, + builder: (context, _) { + final all = tags.value.toList(); + return SwitcherBar( + search: search, + switcher: tagSwitcherLabel( + tags: all, + current: initTag, + onTap: all.isEmpty + ? null + : () => showTagSheet( + context, + tags: all, + current: initTag, + onPick: onTagChanged, + ), + ), + actions: [ + BarAction(icon: Icons.search, label: libL10n.search, onTap: onSearch), + BarAction(icon: Icons.add, label: libL10n.add, onTap: onAdd), + ], + ); + }, + ); + } + // Beside the editor: the head of the column, as every other tab's list + // has — the same row of buttons, centred, and the field it becomes. return WindowControlsInset( safeArea: true, - child: InlineSearchBar( - controller: search, - child: Padding( - padding: const EdgeInsets.only(left: 10, right: 4), - child: Row( - children: [ - TagSwitcher( - tags: tags, - onTagChanged: onTagChanged, - initTag: initTag, - singleLine: true, - ).expanded(), - Btn.icon( - text: libL10n.search, - icon: const Icon(Icons.search, size: 20), - onTap: onSearch, - ), - // Beside search rather than floating over the list, which is where - // every other page of this app puts the same action. A button that - // sits on top of the content also covers the last row of it, and - // needed two of itself — one size for a pane and another for a full - // width — for nothing the bar has to think about. - Btn.icon( - text: libL10n.add, - icon: const Icon(Icons.add, size: 20), - onTap: onAdd, - ), - ], + child: SideBarActions( + search: search, + actions: [ + BarAction.listen( + listenable: tags, + build: (context) => BarAction.menu( + icon: MingCute.hashtag_line, + label: initTag.isEmpty ? libL10n.tag : '#$initTag', + // On in the accent while it filters, as a toggle is. + color: initTag.isEmpty + ? null + : Theme.of(context).colorScheme.primary, + menu: () => [ + ContextMenuAction( + text: libL10n.all, + checked: initTag.isEmpty, + onTap: () => onTagChanged(TagSwitcher.kDefaultTag), + ), + for (final tag in tags.value) + ContextMenuAction( + text: tag, + checked: tag == initTag, + onTap: () => onTagChanged(tag), + ), + ], + ), ), - ), + BarAction(icon: Icons.search, label: libL10n.search, onTap: onSearch), + // Beside search rather than floating over the list, which is where + // every other page of this app puts the same action. + BarAction(icon: Icons.add, label: libL10n.add, onTap: onAdd), + ], ), ); } @override - Size get preferredSize => const Size.fromHeight(TagSwitcher.kTagBtnHeight); + Size get preferredSize => + Size.fromHeight(split ? SideBarActions.height : SwitcherBar.height); } diff --git a/lib/view/page/ssh/tab.dart b/lib/view/page/ssh/tab.dart index abaf435aac..03c4a09593 100644 --- a/lib/view/page/ssh/tab.dart +++ b/lib/view/page/ssh/tab.dart @@ -98,6 +98,10 @@ class _SSHTabPageState extends ConsumerState /// The bar's search: what is typed, and whether the bar is a field at all. final _search = InlineSearchController(); + /// The session floating over the tabs, mirrored out of the provider for the + /// bar's float button, which is drawn from a listenable. + late final ValueNotifier _floating; + /// The picker, and the button for adding a server to pick from. /// /// A scaffold of its own so the button belongs to the page it acts on, @@ -123,6 +127,8 @@ class _SSHTabPageState extends ConsumerState @override void initState() { super.initState(); + // Here and not lazily: [dispose] touches it, and `ref` is gone by then. + _floating = ValueNotifier(ref.read(terminalShellProvider)?.session); Rootfs.removed.addListener(_onRootfsRemoved); // Both after the first frame, and in this order: a queued request is what // the user just asked for, and it should end up beside the tabs that were @@ -149,6 +155,7 @@ class _SSHTabPageState extends ConsumerState _sessions.dispose(); _sortVersion.dispose(); _search.dispose(); + _floating.dispose(); super.dispose(); } @@ -181,6 +188,10 @@ class _SSHTabPageState extends ConsumerState super.build(context); ref.listen(terminalRequestsProvider, (_, _) => _drainRequests()); ref.listen(terminalCloseAllRequestProvider, (_, _) => _drainCloseAll()); + ref.listen( + terminalShellProvider.select((shell) => shell?.session), + (_, session) => _floating.value = session, + ); return ListenBuilder( listenable: _sessions, builder: () => SbPaneList( @@ -193,7 +204,7 @@ class _SSHTabPageState extends ConsumerState sessions: _sessions, sortVersion: Listenable.merge([_sortVersion, _search]), search: _search, - actions: [_sortBtn, _searchBtn], + actions: [_sortAction, _searchAction], onOpen: _openServer, onLocal: () => _open(const LocalSource()), onRootfsOpen: _openRootfs, @@ -246,31 +257,23 @@ class _SSHTabPageState extends ConsumerState PreferredSizeWidget get _tabBar => PreferredSizeListenBuilder( // Both: the bar shows which tab is current *and* how the picker behind it // is sorted. - listenable: Listenable.merge([_sessions, _sortVersion, _search]), + listenable: Listenable.merge([_sessions, _sortVersion]), // The wrapper is what the `Scaffold` measures, so it has to be told; // its own default is a full toolbar. preferSize: const Size.fromHeight(SessionTabBar.height), - builder: () => WindowControlsInset( - safeArea: true, - child: SizedBox( - height: SessionTabBar.height, - // In place of the strip, not over it. Only on the picker: a search here - // narrows that list, and the tabs beside it are open terminals rather - // than anything to find. - child: InlineSearchBar( - controller: _search, - child: SessionTabBar( - names: _sessions.names, - index: _sessions.index, - onTap: _sessions.select, - onClose: _confirmClose, - detailOf: _sessionAddr, - leadingOf: _sessions.statusOf, - sessionActions: _serverActions, - leadingActions: [_sortBtn, _searchBtn, _settingsBtn], - ), - ), - ), + builder: () => SessionTabBar( + names: _sessions.names, + index: _sessions.index, + onTap: _sessions.select, + onClose: _confirmClose, + detailOf: _sessionAddr, + leadingOf: _sessions.statusOf, + sessionActions: _serverActions, + leadingActions: [_sortAction, _searchAction, _settingsAction], + // In place of the strip, not over it. Only on the picker: a search here + // narrows that list, and the tabs beside it are open terminals rather + // than anything to find. + search: _search, ), ); @@ -295,7 +298,10 @@ class _SSHTabPageState extends ConsumerState // stays up with none of them open there may be no such terminal. A // button that looks tappable and does nothing is worse than no button. actions: [ - if (current == null) _settingsBtn else ..._serverActions, + for (final action in current == null + ? [_settingsAction] + : _serverActions) + action.button(), const SizedBox(width: 7), ], ); @@ -615,18 +621,18 @@ extension _Actions on _SSHTabPageState { /// /// [_floatBtn] is last, which puts it in the corner of the window — the /// place a control over the window itself belongs. - List get _serverActions { + List get _serverActions { final current = _sessions.current; if (current == null) return const []; final onServer = current.data.page.args.spi != null; - final status = TerminalStatusButton( + final status = terminalStatusAction( session: current.data.session, paneLabel: (pane) => current.data.pageKey.currentState?.tmuxPaneLabel(pane), ); return onServer - ? [status, _agentBtn, _snippetBtn, _settingsBtn, _floatBtn] - : [status, _snippetBtn, _settingsBtn, _floatBtn]; + ? [status, _agentAction, _snippetAction, _settingsAction, _floatAction] + : [status, _snippetAction, _settingsAction, _floatAction]; } /// Sends the terminal on screen into the window that floats over every tab, @@ -638,24 +644,16 @@ extension _Actions on _SSHTabPageState { /// /// Its own [Consumer] so that only the button rebuilds when the window opens /// or closes; the bar around it is driven by the session list. - Widget get _floatBtn => Consumer( - builder: (_, ref, _) { + BarAction get _floatAction => BarAction.listen( + listenable: _floating, + build: (_) { final session = _sessions.current?.data.pageKey.currentState?.session; - final floating = - session != null && - ref.watch( - terminalShellProvider.select( - (shell) => identical(shell?.session, session), - ), - ); - return Btn.icon( - text: floating ? l10n.floatReturnToTab : l10n.floatOverTabs, - icon: Icon( - floating - ? Icons.picture_in_picture_alt - : Icons.picture_in_picture_alt_outlined, - size: 18, - ), + final floating = session != null && identical(_floating.value, session); + return BarAction( + icon: floating + ? Icons.picture_in_picture_alt + : Icons.picture_in_picture_alt_outlined, + label: floating ? l10n.floatReturnToTab : l10n.floatOverTabs, onTap: _toggleFloat, ); }, @@ -682,36 +680,36 @@ extension _Actions on _SSHTabPageState { /// Opens the agent on the terminal that is on screen, the same way the /// snippet picker beside it works. - Widget get _agentBtn => Btn.icon( - text: l10n.askAi, - icon: const Icon(Icons.auto_awesome, size: 18), + BarAction get _agentAction => BarAction( + icon: Icons.auto_awesome, + label: l10n.askAi, onTap: () => _sessions.current?.data.pageKey.currentState?.openAgentFromToolbar(), ); - Widget get _snippetBtn => Btn.icon( - text: libL10n.snippet, - icon: const Icon(Icons.code, size: 18), + BarAction get _snippetAction => BarAction( + icon: Icons.code, + label: libL10n.snippet, onTap: () => _sessions.current?.data.pageKey.currentState?.pickSnippetFromToolbar(), ); - Widget get _sortBtn => Btn.icon( - text: libL10n.sort, - icon: Icon(_SortOrder.stored.icon, size: 18), + BarAction get _sortAction => BarAction( + icon: _SortOrder.stored.icon, + label: libL10n.sort, onTap: _showSortMenu, ); - Widget get _searchBtn => Btn.icon( - text: libL10n.search, - icon: const Icon(Icons.search, size: 18), + BarAction get _searchAction => BarAction( + icon: Icons.search, + label: libL10n.search, onTap: _search.start, ); - Widget get _settingsBtn => Btn.icon( + BarAction get _settingsAction => BarAction( key: const ValueKey('terminal-settings'), - text: libL10n.setting, - icon: const Icon(Icons.settings_outlined, size: 18), + icon: Icons.settings_outlined, + label: libL10n.setting, onTap: () => SettingsSectionPage.route.go( context, SettingsSection.ssh, diff --git a/lib/view/page/ssh/tab_add.dart b/lib/view/page/ssh/tab_add.dart index e66a286e48..14ecb2aa10 100644 --- a/lib/view/page/ssh/tab_add.dart +++ b/lib/view/page/ssh/tab_add.dart @@ -276,7 +276,7 @@ class _SideBar extends ConsumerStatefulWidget { /// The bar's search. Read rather than listened to: [sortVersion] carries it. final InlineSearchController search; - final List actions; + final List actions; final void Function(Spi spi) onOpen; /// Opens a shell on the machine the app is running on. diff --git a/lib/view/page/storage/file_browser.dart b/lib/view/page/storage/file_browser.dart index 247628aa4b..a6fbd482f8 100644 --- a/lib/view/page/storage/file_browser.dart +++ b/lib/view/page/storage/file_browser.dart @@ -90,7 +90,7 @@ class FileBrowserArgs { /// Given one, this draws no app bar: the file tab already has a strip across /// the top, and a page under it with its own bar is two rows of chrome where /// one would do. - final ValueNotifier>? actionsSink; + final ValueNotifier>? actionsSink; /// Told which directory is shown, once a listing of it has succeeded. /// @@ -100,7 +100,7 @@ class FileBrowserArgs { final void Function(String path)? onPathChanged; /// Toolbar buttons only this backend has. - final List Function(FileBrowserHandle)? extraActions; + final List Function(FileBrowserHandle)? extraActions; // `bottomActions` was here. The bottom is an address bar now and carries no // per-backend buttons; the only one ever passed was SFTP's upload, which @@ -1104,7 +1104,7 @@ class _FileBrowserPageState extends ConsumerState // directory again — not to rebuild on every progress tick. ref.listen(fileTransferProvider, (_, next) => _refreshOnArrival(next)); - final actions = [ + final actions = [ ...?widget.args.extraActions?.call(this), // The same menu a right-click on the directory gives — new folder, new // file, and bringing one in from outside — where it can be seen. It was @@ -1113,29 +1113,27 @@ class _FileBrowserPageState extends ConsumerState // *server* list and adding a file had no button at all. // Said where the `+` would be, so its absence is explained. if (_readOnly) - Btn.icon( - text: l10n.monitorFilesReadOnly, - icon: const Icon(Icons.lock_outline, size: 18), + BarAction( + icon: Icons.lock_outline, + label: l10n.monitorFilesReadOnly, onTap: () => Toast.show(l10n.monitorFilesReadOnly), ) else if (!widget.args.isPickFile && !widget.args.isPickDir) - // Its own context, so the menu drops from this button rather than - // opening as a dialog in the middle of the page. - Builder( - builder: (ctx) => Btn.icon( - text: libL10n.add, - icon: const Icon(Icons.add, size: 18), - onTap: () => showContextMenu( - ctx, - _createActions, - at: contextMenuAnchorBelow(ctx), - ), - ), + // A menu that drops from this button rather than a dialog in the + // middle of the page. + BarAction.menu( + icon: Icons.add, + label: libL10n.add, + menu: () => _createActions, ), - _buildViewBtn(), - Btn.icon(text: libL10n.search, icon: const Icon(Icons.search, size: 18), onTap: _search.start), + _viewAction, + BarAction( + icon: Icons.search, + label: libL10n.search, + onTap: _search.start, + ), if (isDesktop) - Btn.icon(text: libL10n.refresh, icon: const Icon(Icons.refresh, size: 18), onTap: refresh), + BarAction(icon: Icons.refresh, label: libL10n.refresh, onTap: refresh), ]; final body = Column( @@ -1201,7 +1199,10 @@ class _FileBrowserPageState extends ConsumerState duration: Durations.short3, child: Text(title, key: ValueKey(title)), ), - actions: [...actions, const SizedBox(width: 7)], + actions: [ + for (final action in actions) action.button(), + const SizedBox(width: 7), + ], ), ), ), @@ -1830,12 +1831,15 @@ class _FileBrowserPageState extends ConsumerState /// How this list is shown: what it is ordered by, and whether it hides the /// dotfiles. One menu, because both are the same kind of decision and the /// toolbar has no room for a button each. - Widget _buildViewBtn() { - return _sort.listenVal((value) { + BarAction get _viewAction => BarAction.listen( + listenable: _sort, + build: (_) { + final value = _sort.value; final hidden = Stores.setting.showHiddenFiles.fetch(); - return ContextMenuButton( - tooltip: libL10n.sort, - actions: () => [ + return BarAction.menu( + icon: Icons.sort, + label: libL10n.sort, + menu: () => [ for (final by in _SortBy.values) ContextMenuAction( text: by.i18n, @@ -1858,13 +1862,10 @@ class _FileBrowserPageState extends ConsumerState }, ), ], - child: const Padding( - padding: EdgeInsets.all(7), - child: Icon(Icons.sort, size: 18), - ), ); - }); - } + }, + ); + } @immutable diff --git a/lib/view/page/storage/local.dart b/lib/view/page/storage/local.dart index ff230f2e7d..811c4f35d3 100644 --- a/lib/view/page/storage/local.dart +++ b/lib/view/page/storage/local.dart @@ -25,7 +25,7 @@ final class LocalFilePageArgs { final String? initDir; /// Where to put this page's toolbar, for a host that draws a bar of its own. - final ValueNotifier>? actionsSink; + final ValueNotifier>? actionsSink; /// Told which directory is being shown, as it changes. final void Function(String path)? onPathChanged; @@ -99,16 +99,16 @@ class _LocalFilePageState extends ConsumerState { ); } - List _actions(FileBrowserHandle handle) => [ + List _actions(FileBrowserHandle handle) => [ if (isDesktop) - Btn.icon( - text: libL10n.open, - icon: const Icon(Icons.folder_open_outlined, size: 18), + BarAction( + icon: Icons.folder_open_outlined, + label: libL10n.open, onTap: () => Pfs.revealPath(Paths.file), ), - Btn.icon( - text: libL10n.mission, - icon: const Icon(Icons.downloading, size: 18), + BarAction( + icon: Icons.downloading, + label: libL10n.mission, onTap: () => showTransfers(context), ), ]; diff --git a/lib/view/page/storage/server_file.dart b/lib/view/page/storage/server_file.dart index d69b54ed1a..77b9fb819f 100644 --- a/lib/view/page/storage/server_file.dart +++ b/lib/view/page/storage/server_file.dart @@ -124,10 +124,10 @@ class _LocalServerFilePage extends StatelessWidget { } }, extraActions: (_) => [ - IconButton( - tooltip: libL10n.mission, - icon: const Icon(Icons.downloading), - onPressed: () => showTransfers(context), + BarAction( + icon: Icons.downloading, + label: libL10n.mission, + onTap: () => showTransfers(context), ), ], // This device's files are this device's, whichever page lists them: @@ -232,10 +232,10 @@ class _MonitorFilePageState extends ConsumerState<_MonitorFilePage> { actionsSink: widget.args.actionsSink, onPathChanged: _onPathChanged, extraActions: (_) => [ - IconButton( - tooltip: libL10n.mission, - icon: const Icon(Icons.downloading), - onPressed: () => showTransfers(context), + BarAction( + icon: Icons.downloading, + label: libL10n.mission, + onTap: () => showTransfers(context), ), ], refOf: (path) => MonitorFileRef.forServer(_spi, path), diff --git a/lib/view/page/storage/sftp.dart b/lib/view/page/storage/sftp.dart index b6bdcd498a..93e0c4707f 100644 --- a/lib/view/page/storage/sftp.dart +++ b/lib/view/page/storage/sftp.dart @@ -45,7 +45,7 @@ final class SftpPageArgs { /// A host that already has a bar of its own — the file tab's strip of /// sessions — takes them so the screen does not carry two. Null means draw /// the usual app bar, which is what a pushed page does. - final ValueNotifier>? actionsSink; + final ValueNotifier>? actionsSink; /// Told where the browser has moved to, after each successful listing. /// @@ -369,20 +369,23 @@ extension _Open on _SftpPageState { } extension _Actions on _SftpPageState { - List _toolbarActions(FileBrowserHandle handle) => [ - Btn.icon(text: libL10n.mission, - icon: const Icon(Icons.downloading), + List _toolbarActions(FileBrowserHandle handle) => [ + BarAction( + icon: Icons.downloading, + label: libL10n.mission, onTap: () => showTransfers(context), ), if (_sudoHelper.enabled) - _sudoMode.listenVal( - (on) => IconButton( - tooltip: l10n.trySudo, - onPressed: () { - _sudoMode.value = !on; + BarAction.listen( + listenable: _sudoMode, + build: (_) => BarAction( + icon: Icons.security, + label: l10n.trySudo, + color: _sudoMode.value ? UIs.primaryColor : null, + onTap: () { + _sudoMode.value = !_sudoMode.value; handle.refresh(); }, - icon: Icon(Icons.security, color: on ? UIs.primaryColor : null), ), ), ]; diff --git a/lib/view/page/storage/tab.dart b/lib/view/page/storage/tab.dart index f0913220f4..2ddfc00236 100644 --- a/lib/view/page/storage/tab.dart +++ b/lib/view/page/storage/tab.dart @@ -53,7 +53,7 @@ sealed class FileSession { /// /// One per session: every page is built, including the ones off screen, and /// a shared sink would have them overwriting each other. - final actions = ValueNotifier>(const []); + final actions = ValueNotifier>(const []); String? get path => currentPath ?? initialPath; @@ -203,7 +203,7 @@ class _FileTabPageState extends ConsumerState sideBuilder: (_) => _SideBar( sessions: _sessions, search: _search, - actions: [_searchBtn], + actions: [_searchAction], onLocal: _openLocal, onServer: _openRemote, onSelect: _sessions.select, @@ -307,43 +307,53 @@ class _FileTabPageState extends ConsumerState } PreferredSizeWidget get _tabBar => PreferredSizeListenBuilder( - listenable: Listenable.merge([_sessions, _search]), + listenable: _sessions, // The wrapper is what the `Scaffold` measures, so it has to be told; // its own default is a full toolbar. preferSize: const Size.fromHeight(SessionTabBar.height), - builder: () => WindowControlsInset( - safeArea: true, - child: SizedBox( - height: SessionTabBar.height, - // In place of the strip, as on the terminal tab: what is searched is the - // picker, and the tabs beside it are open browsers. - child: InlineSearchBar( - controller: _search, - child: SessionTabBar( - names: _sessions.names, - index: _sessions.index, - leadingIcon: MingCute.folder_fill, - onTap: _sessions.select, - onClose: _close, - detailOf: _sessionPath, - // One widget that follows whichever session is showing, rather than a - // list the bar would have to rebuild itself to keep current. - sessionActions: [_SessionActions(sessions: _sessions)], - // The same two the rail carries. On one screen the picker is a tab - // rather than a column, and these act on what it lists. - leadingActions: [_searchBtn], - ), + builder: () { + // The page's own, handed over through its sink — see + // [FileSession.actions]. + final actions = _sessions.current?.data.actions ?? _noActions; + return ListenBuilder( + listenable: actions, + builder: () => SessionTabBar( + names: _sessions.names, + index: _sessions.index, + leadingIcon: MingCute.folder_fill, + onTap: _sessions.select, + onClose: _close, + detailOf: _sessionPath, + sessionActions: actions.value, + // The same one the rail carries. On one screen the picker is a tab + // rather than a column, and this acts on what it lists. + leadingActions: [_searchAction], + // In place of the strip, as on the terminal tab: what is searched is + // the picker, and the tabs beside it are open browsers. + search: _search, ), - ), - ), + ); + }, ); PreferredSizeWidget get _sessionBar => PreferredSizeListenBuilder( listenable: _sessions, - builder: () => CustomAppBar( - title: Text(_sessions.current?.name ?? libL10n.file), - actions: [_SessionActions(sessions: _sessions)], - ), + builder: () { + final actions = _sessions.current?.data.actions ?? _noActions; + return CustomAppBar( + title: Text(_sessions.current?.name ?? libL10n.file), + actions: [ + ListenBuilder( + listenable: actions, + builder: () => Row( + mainAxisSize: MainAxisSize.min, + children: [for (final action in actions.value) action.button()], + ), + ), + const SizedBox(width: 7), + ], + ); + }, ); /// What a session's row in the sheet says under the name: where that browser @@ -476,11 +486,14 @@ extension _Sessions on _FileTabPageState { } } +/// What the bar listens to while no browser is open. +final _noActions = ValueNotifier>(const []); + /// What acts on the list of places rather than on one browser in it. extension _Actions on _FileTabPageState { - Widget get _searchBtn => Btn.icon( - text: libL10n.search, - icon: const Icon(Icons.search, size: 18), + BarAction get _searchAction => BarAction( + icon: Icons.search, + label: libL10n.search, onTap: _search.start, ); @@ -589,7 +602,7 @@ class _SideBar extends ConsumerWidget { final InlineSearchController search; /// What acts on the rail rather than on one session in it. - final List actions; + final List actions; final VoidCallback onLocal; final void Function(Spi spi) onServer; @@ -654,27 +667,3 @@ class _SideBar extends ConsumerWidget { } } -/// The toolbar of whichever session is showing. -/// -/// Rebuilt from that session's own notifier, so the strip does not have to -/// listen to every open session to keep one row of buttons current. -class _SessionActions extends StatelessWidget { - const _SessionActions({required this.sessions}); - - final SessionTabsController sessions; - - @override - Widget build(BuildContext context) { - final current = sessions.current; - if (current == null) return const SizedBox.shrink(); - return ValueListenableBuilder( - valueListenable: current.data.actions, - builder: (_, actions, _) => Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (final action in actions) ...[action, const SizedBox(width: 7)], - ], - ), - ); - } -} diff --git a/lib/view/page/virt/create.dart b/lib/view/page/virt/create.dart index 13924fc04a..8504e136d5 100644 --- a/lib/view/page/virt/create.dart +++ b/lib/view/page/virt/create.dart @@ -205,9 +205,9 @@ class _VirtCreateViewState extends ConsumerState leading: widget.leading, actions: [ if (widget.onCancel case final cancel?) - Btn.icon( - text: libL10n.cancel, - icon: const Icon(Icons.close, size: 18), + BarAction( + icon: Icons.close, + label: libL10n.cancel, onTap: cancel, ), ], diff --git a/lib/view/page/virt/guest.dart b/lib/view/page/virt/guest.dart index 560832090a..411e6538ff 100644 --- a/lib/view/page/virt/guest.dart +++ b/lib/view/page/virt/guest.dart @@ -551,52 +551,29 @@ VirtGuestViewKind.overview => _buildOverview(st, guest, state), final actions = guest == null ? const {} : st.actionsOf(guest); - return PreferredSize( - preferredSize: const Size.fromHeight(SessionTabBar.height), - // Pushed on a phone, this page is outside the tab's `SafeArea`, and the - // `Scaffold` leaves the status bar's inset to its bar. - child: WindowControlsInset( - child: SafeArea( - bottom: false, - child: SizedBox( - height: SessionTabBar.height, - child: Row( - children: [ - ?widget.leading, - Expanded( - child: SessionSwitcherLabel( - name: guest?.name ?? '', - leading: guest == null - ? null - : VirtStateDot(st.displayState(guest)), - position: widget.switcher && at >= 0 ? at + 1 : null, - total: widget.switcher ? guests.length : 0, - onTap: widget.switcher && guests.length > 1 - ? () => unawaited(_showGuests(st)) - : null, - ), - ), - if (guest != null) - for (final action in VirtPowerActionUi.barOrder) - if (actions.contains(action)) - Btn.icon( - key: ValueKey(action), - text: action.label, - icon: Icon( - action.icon, - size: 18, - color: action.destructive - ? Theme.of(context).colorScheme.error - : null, - ), - onTap: () => unawaited(_onPower(guest, action)), - ), - const SizedBox(width: 7), - ], - ), - ), - ), + return SwitcherBar( + leading: widget.leading, + switcher: SessionSwitcherLabel( + name: guest?.name ?? '', + leading: guest == null ? null : VirtStateDot(st.displayState(guest)), + position: widget.switcher && at >= 0 ? at + 1 : null, + total: widget.switcher ? guests.length : 0, + onTap: widget.switcher && guests.length > 1 + ? () => unawaited(_showGuests(st)) + : null, ), + actions: [ + if (guest != null) + for (final action in VirtPowerActionUi.barOrder) + if (actions.contains(action)) + BarAction( + key: ValueKey(action), + icon: action.icon, + label: action.label, + destructive: action.destructive, + onTap: () => unawaited(_onPower(guest, action)), + ), + ], ); } diff --git a/lib/view/page/virt/list.dart b/lib/view/page/virt/list.dart index 10ea9973d5..ede00f9063 100644 --- a/lib/view/page/virt/list.dart +++ b/lib/view/page/virt/list.dart @@ -58,63 +58,47 @@ extension _List on _VirtTabPageState { final at = hostId == null ? -1 : hostIds.indexOf(hostId); final picking = split && _showHosts; - final bar = WindowControlsInset( - safeArea: true, - child: SizedBox( - height: SessionTabBar.height, - child: InlineSearchBar( - controller: _search, - hint: libL10n.name, - child: Row( - children: [ - Expanded( - child: FoldingBar( - label: SessionSwitcherLabel( - name: servers[hostId]?.name ?? l10n.virtualization, - icon: Icons.dns_outlined, - position: at < 0 ? null : at + 1, - total: hostIds.length, - // The picker below this bar, where there is a column to - // open it in; a sheet over it otherwise, which needs no - // `open`. - open: picking, - onTap: () => split - ? setState(() => _showHosts = !_showHosts) - : unawaited(_showHostSheet(hostId)), - ), - actions: [ - if (hostId != null && !picking) ...[ - BarAction( - icon: Icons.search, - label: libL10n.search, - onTap: _search.start, - ), - BarAction( - icon: Icons.refresh, - label: libL10n.refresh, - onTap: () => _refresh(hostId), - // Where the reading is said, rather than a line under - // the bar: the button asked for it. - loading: refreshing, - ), - // In the section a new one lands in; the host's answer - // says whether it takes one. - if (_createLabel(caps) case final label?) - BarAction( - key: const ValueKey('virt:create'), - icon: Icons.add, - label: label, - onTap: () => unawaited(_startCreate(hostId, split)), - ), - ], - ], - ), - ), - const SizedBox(width: 7), - ], - ), - ), + final bar = SwitcherBar( + search: _search, + searchHint: libL10n.name, + switcher: SessionSwitcherLabel( + name: servers[hostId]?.name ?? l10n.virtualization, + icon: Icons.dns_outlined, + position: at < 0 ? null : at + 1, + total: hostIds.length, + // The picker below this bar, where there is a column to open it in; a + // sheet over it otherwise, which needs no `open`. + open: picking, + onTap: () => split + ? setState(() => _showHosts = !_showHosts) + : unawaited(_showHostSheet(hostId)), ), + actions: [ + if (hostId != null && !picking) ...[ + BarAction( + icon: Icons.search, + label: libL10n.search, + onTap: _search.start, + ), + BarAction( + icon: Icons.refresh, + label: libL10n.refresh, + onTap: () => _refresh(hostId), + // Where the reading is said, rather than a line under the bar: + // the button asked for it. + loading: refreshing, + ), + // In the section a new one lands in; the host's answer says whether + // it takes one. + if (_createLabel(caps) case final label?) + BarAction( + key: const ValueKey('virt:create'), + icon: Icons.add, + label: label, + onTap: () => unawaited(_startCreate(hostId, split)), + ), + ], + ], ); final Widget body; @@ -150,10 +134,7 @@ extension _List on _VirtTabPageState { } return Scaffold( - appBar: PreferredSize( - preferredSize: const Size.fromHeight(SessionTabBar.height), - child: bar, - ), + appBar: bar, // Crossed rather than cut, so the bar above does not look like it was // pointing at one host's guests and now, between two frames, at // another's. See [_listSwapTransition]. diff --git a/lib/view/page/virt/network.dart b/lib/view/page/virt/network.dart index 7d00f4c8ec..e0ad60e508 100644 --- a/lib/view/page/virt/network.dart +++ b/lib/view/page/virt/network.dart @@ -227,13 +227,12 @@ class _VirtNetworkViewState extends ConsumerState : null, actions: [ if (net != null && caps.networkStart) - Btn.icon( + BarAction( key: const ValueKey('net:toggle'), - text: net.active ? libL10n.stop : libL10n.start, - icon: Icon( - net.active ? Icons.stop_circle_outlined : Icons.play_circle_outline, - size: 18, - ), + icon: net.active + ? Icons.stop_circle_outlined + : Icons.play_circle_outline, + label: net.active ? libL10n.stop : libL10n.start, onTap: busy ? null : () => unawaited(_setActive(net, !net.active)), ), ], @@ -1254,9 +1253,9 @@ class _VirtNetworkCreateViewState extends ConsumerState icon: Icons.add_circle_outline, leading: widget.leading, actions: [ - Btn.icon( - text: libL10n.cancel, - icon: const Icon(Icons.close, size: 18), + BarAction( + icon: Icons.close, + label: libL10n.cancel, onTap: widget.onCancel, ), ], diff --git a/lib/view/page/virt/resources.dart b/lib/view/page/virt/resources.dart index 5ae9691ae0..5716b4bcc1 100644 --- a/lib/view/page/virt/resources.dart +++ b/lib/view/page/virt/resources.dart @@ -380,43 +380,27 @@ PreferredSizeWidget virtResourceBar({ int? position, int total = 0, VoidCallback? onSwitch, - List actions = const [], + List actions = const [], VoidCallback? onRefresh, }) { - return PreferredSize( - preferredSize: const Size.fromHeight(SessionTabBar.height), - // Pushed on a phone, the page is outside the tab's `SafeArea`, and the - // `Scaffold` leaves the status bar's inset to its bar. - child: WindowControlsInset( - child: SafeArea( - bottom: false, - child: SizedBox( - height: SessionTabBar.height, - child: Row( - children: [ - ?leading, - Expanded( - child: SessionSwitcherLabel( - name: name, - icon: icon, - position: position, - total: total, - onTap: onSwitch, - ), - ), - ...actions, - if (onRefresh != null) - Btn.icon( - text: libL10n.refresh, - icon: const Icon(Icons.refresh, size: 18), - onTap: onRefresh, - ), - const SizedBox(width: 7), - ], - ), - ), - ), + return SwitcherBar( + leading: leading, + switcher: SessionSwitcherLabel( + name: name, + icon: icon, + position: position, + total: total, + onTap: onSwitch, ), + actions: [ + ...actions, + if (onRefresh != null) + BarAction( + icon: Icons.refresh, + label: libL10n.refresh, + onTap: onRefresh, + ), + ], ); } diff --git a/lib/view/page/virt/storage.dart b/lib/view/page/virt/storage.dart index f2f5eb5b1f..6f0b1199cb 100644 --- a/lib/view/page/virt/storage.dart +++ b/lib/view/page/virt/storage.dart @@ -89,17 +89,17 @@ class _VirtPoolViewState extends ConsumerState : null, actions: [ if (pool != null && pool.active && caps.storageEdit) - Btn.icon( + BarAction( key: const ValueKey('pool:vol:new'), - text: l10n.virtVolNew, - icon: const Icon(Icons.add, size: 18), + icon: Icons.add, + label: l10n.virtVolNew, onTap: busy ? null : () => _openNew(pool), ), if (pool != null && caps.upload && virtPoolTakesMedia(pool)) - Btn.icon( + BarAction( key: const ValueKey('pool:upload'), - text: l10n.virtUploadIso, - icon: const Icon(Icons.upload, size: 18), + icon: Icons.upload, + label: l10n.virtUploadIso, onTap: busy ? null : () => unawaited(_upload(pool)), ), ], @@ -1104,9 +1104,9 @@ class _VirtPoolCreateViewState extends ConsumerState icon: Icons.add_circle_outline, leading: widget.leading, actions: [ - Btn.icon( - text: libL10n.cancel, - icon: const Icon(Icons.close, size: 18), + BarAction( + icon: Icons.close, + label: libL10n.cancel, onTap: widget.onCancel, ), ], diff --git a/lib/view/page/virt/tab.dart b/lib/view/page/virt/tab.dart index ec1e2fc4d4..8816d51bb0 100644 --- a/lib/view/page/virt/tab.dart +++ b/lib/view/page/virt/tab.dart @@ -18,7 +18,6 @@ import 'package:server_box/view/page/virt/guest.dart'; import 'package:server_box/view/page/virt/hardware.dart'; import 'package:server_box/view/page/virt/host_error.dart'; import 'package:server_box/view/page/virt/resources.dart'; -import 'package:server_box/view/widget/folding_bar.dart'; import 'package:server_box/view/widget/pane_settings.dart'; import 'package:server_box/view/widget/progress_line.dart'; diff --git a/lib/view/widget/folding_bar.dart b/lib/view/widget/folding_bar.dart deleted file mode 100644 index 3ab2a115f9..0000000000 --- a/lib/view/widget/folding_bar.dart +++ /dev/null @@ -1,118 +0,0 @@ -import 'package:fl_lib/fl_lib.dart'; -import 'package:material_ui/material_ui.dart'; - -/// One icon button of a [FoldingBar]. -final class BarAction { - const BarAction({ - this.key, - required this.icon, - required this.label, - required this.onTap, - this.color, - this.loading = false, - }); - - /// On the button while it is in the bar. - final Key? key; - final IconData icon; - - /// Its tooltip in the bar, its text in the menu. - final String label; - final VoidCallback onTap; - final Color? color; - - /// A spinner in place of the icon, and no tap: for a refresh that is - /// running, which is said where it was asked for rather than by a line - /// somewhere else on the page. - final bool loading; - - static const _spinner = SizedBox.square( - dimension: 18, - child: Padding( - padding: EdgeInsets.all(1.5), - child: CircularProgressIndicator(strokeWidth: 2), - ), - ); - - Widget buildIcon() => loading ? _spinner : Icon(icon, size: 18, color: color); -} - -/// A bar's label and its icon buttons, in however much width the bar has. -/// -/// The label keeps [labelMinWidth]; the buttons get the rest, as many as fit, -/// and those that do not are items of a menu behind a last button. So a -/// column dragged narrow shows what the bar is about and every action still, -/// rather than a label squeezed past its counter and chevron into an -/// overflow. -class FoldingBar extends StatelessWidget { - const FoldingBar({ - super.key, - required this.label, - required this.actions, - this.labelMinWidth = 104, - }); - - final Widget label; - final List actions; - - /// Enough for a `SessionSwitcherLabel`'s counter, chevron and a few - /// letters of its name. - final double labelMinWidth; - - /// A `Btn.icon` of an 18pt icon: the icon and 7 of padding each side. - static const slot = 18.0 + 7 * 2; - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final room = constraints.maxWidth - labelMinWidth; - final fits = room.isFinite ? (room / slot).floor() : actions.length; - final folds = fits < actions.length; - // The menu button takes one of the slots. - final shown = folds ? actions.take(fits > 0 ? fits - 1 : 0) : actions; - final folded = folds ? actions.skip(shown.length).toList() : const []; - return Row( - children: [ - Expanded(child: label), - for (final a in shown) - if (a.loading) - // The slot a button takes, so the bar does not shift. - Tooltip( - key: a.key, - message: a.label, - child: const Padding( - padding: EdgeInsets.all(7), - child: BarAction._spinner, - ), - ) - else - Btn.icon( - key: a.key, - text: a.label, - icon: Icon(a.icon, size: 18, color: a.color), - onTap: a.onTap, - ), - if (folded.isNotEmpty) - ContextMenuButton( - tooltip: libL10n.more, - actions: () => [ - for (final a in folded) - ContextMenuAction( - text: a.label, - icon: a.icon, - enabled: !a.loading, - onTap: a.onTap, - ), - ], - child: const Padding( - padding: EdgeInsets.all(7), - child: Icon(Icons.more_vert, size: 18), - ), - ), - ], - ); - }, - ); - } -} diff --git a/lib/view/widget/nav_rail.dart b/lib/view/widget/nav_rail.dart index 6478517a6d..1f591e3149 100644 --- a/lib/view/widget/nav_rail.dart +++ b/lib/view/widget/nav_rail.dart @@ -271,12 +271,10 @@ class _AppNavRailState extends State // from nothing. shadowColor: shadow.withValues(alpha: shadow.a * open), surfaceTintColor: Colors.transparent, - // Whether the shadow is drawn at all, and how far it is cast. - // Read off the same value as the two above, so that nothing - // outlives the panel: at zero `PhysicalModel` skips the shadow - // outright, where a panel left at three points of elevation - // held one for the last of the closing. - elevation: railTheme.elevation ?? (3 * open), + // None unless the theme asks for one: the panel is flat, as + // the rest of the app is. A theme's elevation is held whole + // while the shadow's colour above fades it in and out. + elevation: railTheme.elevation ?? 0, child: Column( children: [ Expanded( diff --git a/lib/view/widget/tag_switcher.dart b/lib/view/widget/tag_switcher.dart new file mode 100644 index 0000000000..b46964b9f8 --- /dev/null +++ b/lib/view/widget/tag_switcher.dart @@ -0,0 +1,78 @@ +import 'package:fl_lib/fl_lib.dart'; +import 'package:icons_plus/icons_plus.dart'; +import 'package:material_ui/material_ui.dart'; + +/// The tag a list is filtered by, as the switcher of a [SwitcherBar]: "All" +/// or `#tag`, which of the tags it is, and the sheet of the others. +/// +/// [onTap] null is a label rather than a way anywhere — for a list with no +/// tags and nothing to say about where they come from. +SessionSwitcherLabel tagSwitcherLabel({ + required List tags, + required String current, + required VoidCallback? onTap, +}) { + final at = tags.indexOf(current); + return SessionSwitcherLabel( + name: current.isEmpty ? libL10n.all : '#$current', + // Counting from 1, and null on "all" — which is not one of the tags but + // the absence of a choice among them, so it shows the icon instead. + position: at < 0 ? null : at + 1, + total: tags.length, + icon: MingCute.hashtag_line, + onTap: onTap, + ); +} + +/// The tags, as rows. The same sheet the session switchers open, for the +/// same reason: a strip of them would be as wide as the names happened to be. +/// +/// [emptyTip] says where tags come from, for a sheet that would otherwise be +/// one row saying "All" — which reads as a broken filter rather than as an +/// empty one. +Future showTagSheet( + BuildContext context, { + required List tags, + required String current, + required ValueChanged onPick, + String? emptyTip, +}) async { + await showRowsSheet( + context, + rows: (ctx) { + void pick(String tag) { + Navigator.of(ctx).pop(); + onPick(tag); + } + + return [ + SheetChoiceTile( + icon: MingCute.hashtag_line, + title: libL10n.all, + selected: current.isEmpty, + onTap: () => pick(TagSwitcher.kDefaultTag), + ), + const Divider(height: 1), + if (tags.isEmpty && emptyTip != null) + Padding( + padding: const EdgeInsets.fromLTRB(17, 17, 17, 27), + child: Text( + emptyTip, + style: UIs.textGrey, + textAlign: TextAlign.center, + ), + ) + else + for (final tag in tags) + // The same shape as the row above it: the mark, then the name. + // The mark is the `#`, so the name does not carry one as well. + SheetChoiceTile( + icon: MingCute.hashtag_line, + title: tag, + selected: tag == current, + onTap: () => pick(tag), + ), + ]; + }, + ); +} diff --git a/lib/view/widget/terminal_status_sheet.dart b/lib/view/widget/terminal_status_sheet.dart index 38b82d9b2b..a2d27a932c 100644 --- a/lib/view/widget/terminal_status_sheet.dart +++ b/lib/view/widget/terminal_status_sheet.dart @@ -196,6 +196,31 @@ final class _Mark extends StatelessWidget { /// Opens [showTerminalStatusSheet] for a tab's session, coloured by its most /// urgent state. Nothing while no program reports anything. +BarAction terminalStatusAction({ + required ValueListenable session, + String? Function(TmuxPaneId pane)? paneLabel, +}) => BarAction.listen( + listenable: session, + build: (_) { + final status = session.value?.status; + if (status == null) return null; + return BarAction.listen( + listenable: status, + build: (context) { + if (!status.hasReports) return null; + return BarAction( + icon: MingCute.task_line, + label: l10n.programStatus, + color: status.headline?.colorIn(Theme.of(context).colorScheme), + onTap: () => + showTerminalStatusSheet(context, status, paneLabel: paneLabel), + ); + }, + ); + }, +); + +/// [terminalStatusAction] on its own. final class TerminalStatusButton extends StatelessWidget { const TerminalStatusButton({super.key, required this.session, this.paneLabel}); @@ -203,31 +228,6 @@ final class TerminalStatusButton extends StatelessWidget { final String? Function(TmuxPaneId pane)? paneLabel; @override - Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: session, - builder: (context, session, _) { - if (session == null) return const SizedBox.shrink(); - final status = session.status; - return ListenableBuilder( - listenable: status, - builder: (context, _) { - if (!status.hasReports) return const SizedBox.shrink(); - final color = status.headline?.colorIn( - Theme.of(context).colorScheme, - ); - return Btn.icon( - text: l10n.programStatus, - icon: Icon(MingCute.task_line, size: 18, color: color), - onTap: () => showTerminalStatusSheet( - context, - status, - paneLabel: paneLabel, - ), - ); - }, - ); - }, - ); - } + Widget build(BuildContext context) => + terminalStatusAction(session: session, paneLabel: paneLabel).button(); } diff --git a/packages/fl_lib b/packages/fl_lib index 5c3f324dc2..6b19f7ece6 160000 --- a/packages/fl_lib +++ b/packages/fl_lib @@ -1 +1 @@ -Subproject commit 5c3f324dc2fea87006954651d4f3759e8e39bfe6 +Subproject commit 6b19f7ece67cc2e2b41e4410509ef68919a8c66f diff --git a/test/unit/app/folding_bar_test.dart b/test/unit/app/folding_bar_test.dart deleted file mode 100644 index 9aed3e507c..0000000000 --- a/test/unit/app/folding_bar_test.dart +++ /dev/null @@ -1,116 +0,0 @@ -import 'package:fl_lib/fl_lib.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:material_ui/material_ui.dart'; -import 'package:server_box/view/widget/folding_bar.dart'; - -/// [FoldingBar]: the label keeps its width, the buttons fold into a menu, -/// and nothing overflows down to the narrowest list column. -void main() { - final taps = []; - final actions = [ - for (final (icon, label) in [ - (Icons.search, 'Search'), - (Icons.refresh, 'Refresh'), - (Icons.add, 'New'), - ]) - BarAction(icon: icon, label: label, onTap: () => taps.add(label)), - ]; - - Future pump(WidgetTester tester, double width) => tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Align( - alignment: Alignment.topLeft, - child: SizedBox( - width: width, - height: 40, - child: FoldingBar( - label: const SessionSwitcherLabel( - name: 'pve', - position: 1, - total: 2, - onTap: _noop, - ), - actions: actions, - ), - ), - ), - ), - ), - ); - - setUp(taps.clear); - - testWidgets('a button is as wide as the slot it is counted as', (tester) async { - await pump(tester, 600); - expect(tester.getSize(find.byType(Btn).first).width, FoldingBar.slot); - }); - - testWidgets('with room, every button; narrow, the rest behind a menu', ( - tester, - ) async { - await pump(tester, 600); - expect(find.byType(Btn), findsNWidgets(3)); - expect(find.byIcon(Icons.more_vert), findsNothing); - - // Just enough for all three beside the label. - await pump(tester, 104 + 3 * FoldingBar.slot); - expect(find.byType(Btn), findsNWidgets(3)); - expect(find.byIcon(Icons.more_vert), findsNothing); - - // Short of the third slot: the menu takes the second, so one button and - // the menu for the other two. - await pump(tester, 104 + 3 * FoldingBar.slot - 1); - expect(find.byType(Btn), findsOneWidget); - expect(find.byIcon(Icons.more_vert), findsOneWidget); - - // The narrowest the list column drags to (`minListWidth`): room for one - // slot, which the menu takes. - await pump(tester, 160); - expect(tester.takeException(), isNull); - expect(find.byType(Btn), findsNothing); - expect(find.byIcon(Icons.more_vert), findsOneWidget); - await tester.tap(find.byIcon(Icons.more_vert)); - await tester.pumpAndSettle(); - await tester.tap(find.text('New')); - await tester.pumpAndSettle(); - expect(taps, ['New']); - }); - - testWidgets('a loading action is a spinner in its slot, and not a tap', ( - tester, - ) async { - taps.clear(); - var refreshed = 0; - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SizedBox( - width: 400, - height: 40, - child: FoldingBar( - label: const Text('pve'), - actions: [ - BarAction( - icon: Icons.refresh, - label: 'Refresh', - onTap: () => refreshed++, - loading: true, - ), - ], - ), - ), - ), - ), - ); - // One frame, not `pumpAndSettle`: a spinner never settles. - await tester.pump(); - expect(find.byType(CircularProgressIndicator), findsOneWidget); - expect(find.byIcon(Icons.refresh), findsNothing); - await tester.tap(find.byType(CircularProgressIndicator)); - await tester.pump(); - expect(refreshed, 0); - }); -} - -void _noop() {} diff --git a/test/unit/remote_desktop/remote_desktop_viewer_test.dart b/test/unit/remote_desktop/remote_desktop_viewer_test.dart index 226b4aefc0..7ee954e8d4 100644 --- a/test/unit/remote_desktop/remote_desktop_viewer_test.dart +++ b/test/unit/remote_desktop/remote_desktop_viewer_test.dart @@ -7,7 +7,6 @@ import 'package:server_box/data/model/server/remote_desktop.dart'; import 'package:server_box/view/page/remote_desktop/frame_decoder.dart'; import 'package:server_box/view/page/remote_desktop/geometry.dart'; import 'package:server_box/view/page/remote_desktop/input.dart'; -import 'package:server_box/view/page/remote_desktop/viewer.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -40,11 +39,6 @@ void main() { expect((vnc.code, vnc.extended), (0xffff, false)); }); - test('viewer toolbar switches to compact layout on narrow screens', () { - expect(remoteDesktopUsesCompactToolbar(420), isTrue); - expect(remoteDesktopUsesCompactToolbar(900), isFalse); - }); - test('losing focus releases all remotely held keys', () { var releases = 0; final sent = <(int, bool)>[]; diff --git a/test/widget/benchmark_resume_test.dart b/test/widget/benchmark_resume_test.dart index 69c1c062c3..d1748baf2f 100644 --- a/test/widget/benchmark_resume_test.dart +++ b/test/widget/benchmark_resume_test.dart @@ -483,11 +483,11 @@ void main() { expect(find.byType(BenchmarkRunPage), findsOneWidget); expect(find.text('web'), findsWidgets); - // The list column's bar button, not the run form's Start: with no run in + // The list column's button, not the run form's Start: with no run in // flight the form is on screen beside it and carries the same icon. await tester.tap( find.descendant( - of: find.byType(CustomAppBar), + of: find.byType(SideBarActions), matching: find.byIcon(Icons.play_arrow), ), ); diff --git a/test/widget/session_tab_bar_test.dart b/test/widget/session_tab_bar_test.dart index 61fc71581a..082b2d289e 100644 --- a/test/widget/session_tab_bar_test.dart +++ b/test/widget/session_tab_bar_test.dart @@ -18,7 +18,7 @@ void main() { void Function(int)? onTap, void Function(int)? onClose, String? Function(int)? detailOf, - List sessionActions = const [], + List sessionActions = const [], }) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(390, 800); @@ -80,7 +80,13 @@ void main() { // The shortest name there is, so a row left at `MainAxisSize.max` — which // would take everything the actions had not claimed, around 350 — is // unmistakable next to one that hugs its contents. - await pump(tester, index: 3, sessionActions: const [Icon(Icons.abc)]); + await pump( + tester, + index: 3, + sessionActions: [ + BarAction(icon: Icons.abc, label: 'abc', onTap: () {}), + ], + ); final ink = tester.getSize(find.byType(InkWell).first); expect(ink.width, lessThan(200)); From 17e9f64133dda4b130b1131324432ac6937f3a7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:35:02 +0800 Subject: [PATCH 4/4] chore: bump fl_lib --- packages/fl_lib | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fl_lib b/packages/fl_lib index 6b19f7ece6..f3bb6d4f90 160000 --- a/packages/fl_lib +++ b/packages/fl_lib @@ -1 +1 @@ -Subproject commit 6b19f7ece67cc2e2b41e4410509ef68919a8c66f +Subproject commit f3bb6d4f90e8c1ef679acad37d105d2c37beca1a