add a reader chrome: back/forward, pin, and edit-pencil
The markdown and decision sidebar readers gain a shared action bar. A new lib/builtin/shared/reader_chrome.dart provides ReaderHistory (browser-style back/forward stack — push truncates forward), a ReaderHistoryMixin that also holds a single pin slot, and a ReaderActionBar widget. Both readers push to history only on external selection; back/forward and jump-to-pin reload in-place without re-publishing a selection (no bus churn / no decision-tab re-trigger). The edit pencil opens the current doc in the editor (editor.open) — the markdown path, or the decision's file_path. T-189, T-190, T-191. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/builtin/decisions/src/decision_colors.dart';
|
||||
import 'package:clide/builtin/shared/reader_chrome.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -13,7 +14,7 @@ class DecisionDetailView extends StatefulWidget {
|
||||
State<DecisionDetailView> createState() => _DecisionDetailViewState();
|
||||
}
|
||||
|
||||
class _DecisionDetailViewState extends State<DecisionDetailView> {
|
||||
class _DecisionDetailViewState extends State<DecisionDetailView> with ReaderHistoryMixin<String, DecisionDetailView> {
|
||||
Map<String, Object?>? _decision;
|
||||
bool _loading = false;
|
||||
StreamSubscription<Message>? _sub;
|
||||
@@ -38,6 +39,7 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Load [id] and push it onto the history stack (external navigation).
|
||||
Future<void> _load(String id) async {
|
||||
setState(() => _loading = true);
|
||||
final kernel = ClideKernel.of(context);
|
||||
@@ -50,6 +52,48 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
|
||||
_loading = false;
|
||||
_decision = resp.ok ? resp.data : null;
|
||||
});
|
||||
if (resp.ok) historyPush(id);
|
||||
}
|
||||
|
||||
/// Load [id] WITHOUT pushing onto the history stack (back/forward nav).
|
||||
Future<void> _loadInPlace(String id) async {
|
||||
setState(() => _loading = true);
|
||||
final kernel = ClideKernel.of(context);
|
||||
final resp = await kernel.ipc.request('pql.decisions.read', args: {'id': id});
|
||||
if (!mounted) return;
|
||||
if (resp.ok) {
|
||||
kernel.messages.publish('builtin.decisions', 'focus', {'id': id});
|
||||
}
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_decision = resp.ok ? resp.data : null;
|
||||
});
|
||||
}
|
||||
|
||||
void _onBack() {
|
||||
final entry = historyBack();
|
||||
if (entry != null) _loadInPlace(entry);
|
||||
}
|
||||
|
||||
void _onForward() {
|
||||
final entry = historyForward();
|
||||
if (entry != null) _loadInPlace(entry);
|
||||
}
|
||||
|
||||
void _onPin() {
|
||||
pinCurrent();
|
||||
}
|
||||
|
||||
void _onJumpToPin() {
|
||||
final entry = jumpToPin();
|
||||
if (entry != null) _loadInPlace(entry);
|
||||
}
|
||||
|
||||
void _onEdit() {
|
||||
final filePath = _decision?['file_path'] as String?;
|
||||
if (filePath == null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
unawaited(kernel.ipc.request('editor.open', args: {'path': filePath}));
|
||||
}
|
||||
|
||||
void _navigateToRecord(BuildContext context, String id) {
|
||||
@@ -80,63 +124,80 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
|
||||
final body = d['body'] as String?;
|
||||
final refs = (d['refs'] as List?)?.cast<Map<String, Object?>>() ?? const [];
|
||||
final typeColor = typeColors.forType(type);
|
||||
final filePath = d['file_path'] as String?;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: tokens.panelBorder),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ClideTooltip(
|
||||
message: type ?? 'confirmed',
|
||||
child: Container(width: 10, height: 10, decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ClideText(id, fontSize: clideFontSmall, color: typeColor, fontFamily: clideMonoFamily),
|
||||
const Spacer(),
|
||||
if (domain != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: tokens.panelBorder, borderRadius: BorderRadius.circular(3)),
|
||||
child: ClideText(domain, fontSize: clideFontBadge, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
return ClidePaneChrome(
|
||||
title: id,
|
||||
subtitle: title,
|
||||
trailing: [
|
||||
ReaderActionBar(
|
||||
canGoBack: canGoBack,
|
||||
canGoForward: canGoForward,
|
||||
hasPinned: hasPinned,
|
||||
onBack: canGoBack ? _onBack : null,
|
||||
onForward: canGoForward ? _onForward : null,
|
||||
onPin: _decision != null ? _onPin : null,
|
||||
onJumpToPin: hasPinned ? _onJumpToPin : null,
|
||||
onEdit: filePath != null ? _onEdit : null,
|
||||
),
|
||||
],
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: tokens.panelBorder),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ClideTooltip(
|
||||
message: type ?? 'confirmed',
|
||||
child: Container(width: 10, height: 10, decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ClideText(title, fontSize: 15, fontWeight: FontWeight.w500),
|
||||
if (date != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
ClideText(date, muted: true, fontSize: clideFontSmall, fontFamily: clideMonoFamily),
|
||||
],
|
||||
if (status != null && status != 'active') ...[
|
||||
const SizedBox(width: 8),
|
||||
ClideText(id, fontSize: clideFontSmall, color: typeColor, fontFamily: clideMonoFamily),
|
||||
const Spacer(),
|
||||
if (domain != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: tokens.panelBorder, borderRadius: BorderRadius.circular(3)),
|
||||
child: ClideText(domain, fontSize: clideFontBadge, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_StatusBadge(status: status, tokens: tokens),
|
||||
ClideText(title, fontSize: 15, fontWeight: FontWeight.w500),
|
||||
if (date != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
ClideText(date, muted: true, fontSize: clideFontSmall, fontFamily: clideMonoFamily),
|
||||
],
|
||||
if (status != null && status != 'active') ...[
|
||||
const SizedBox(height: 8),
|
||||
_StatusBadge(status: status, tokens: tokens),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (body != null && body.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
ClideMarkdown(body, onRecordTap: (id) => _navigateToRecord(context, id)),
|
||||
if (body != null && body.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
ClideMarkdown(body, onRecordTap: (id) => _navigateToRecord(context, id)),
|
||||
],
|
||||
if (refs.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
ClideText('CROSS-REFERENCES', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
const SizedBox(height: 6),
|
||||
for (final ref in refs) _RefCard(ref: ref, tokens: tokens),
|
||||
],
|
||||
],
|
||||
if (refs.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
ClideText('CROSS-REFERENCES', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
const SizedBox(height: 6),
|
||||
for (final ref in refs) _RefCard(ref: ref, tokens: tokens),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/builtin/shared/reader_chrome.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -11,7 +12,7 @@ class MarkdownViewer extends StatefulWidget {
|
||||
State<MarkdownViewer> createState() => _MarkdownViewerState();
|
||||
}
|
||||
|
||||
class _MarkdownViewerState extends State<MarkdownViewer> {
|
||||
class _MarkdownViewerState extends State<MarkdownViewer> with ReaderHistoryMixin<String, MarkdownViewer> {
|
||||
String? _path;
|
||||
String? _content;
|
||||
String? _error;
|
||||
@@ -34,7 +35,26 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Load [path] and push it onto the history stack (external navigation).
|
||||
Future<void> _loadFile(String path) async {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final resp = await kernel.ipc.request('files.read', args: {'path': path});
|
||||
if (!mounted) return;
|
||||
if (resp.ok) {
|
||||
kernel.messages.publish('builtin.markdown', 'focus', {'path': path});
|
||||
setState(() {
|
||||
_path = path;
|
||||
_content = resp.data['content'] as String? ?? '';
|
||||
_error = null;
|
||||
});
|
||||
historyPush(path);
|
||||
} else {
|
||||
setState(() => _error = resp.error?.message);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load [path] WITHOUT pushing onto the history stack (back/forward nav).
|
||||
Future<void> _loadFileInPlace(String path) async {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final resp = await kernel.ipc.request('files.read', args: {'path': path});
|
||||
if (!mounted) return;
|
||||
@@ -50,6 +70,35 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
|
||||
}
|
||||
}
|
||||
|
||||
void _onBack() {
|
||||
final entry = historyBack();
|
||||
if (entry != null) _loadFileInPlace(entry);
|
||||
}
|
||||
|
||||
void _onForward() {
|
||||
final entry = historyForward();
|
||||
if (entry != null) _loadFileInPlace(entry);
|
||||
}
|
||||
|
||||
void _onPin() {
|
||||
pinCurrent();
|
||||
}
|
||||
|
||||
void _onJumpToPin() {
|
||||
final entry = jumpToPin();
|
||||
if (entry != null) {
|
||||
// Navigate directly without re-pushing (pin jump is a quick-return).
|
||||
_loadFileInPlace(entry);
|
||||
}
|
||||
}
|
||||
|
||||
void _onEdit() {
|
||||
final p = _path;
|
||||
if (p == null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
unawaited(kernel.ipc.request('editor.open', args: {'path': p}));
|
||||
}
|
||||
|
||||
void _navigateToRecord(BuildContext context, String id) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
if (id.toLowerCase().endsWith('.md')) {
|
||||
@@ -76,6 +125,18 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
|
||||
return ClidePaneChrome(
|
||||
title: _path ?? 'viewer',
|
||||
subtitle: '${_content!.split('\n').length} lines',
|
||||
trailing: [
|
||||
ReaderActionBar(
|
||||
canGoBack: canGoBack,
|
||||
canGoForward: canGoForward,
|
||||
hasPinned: hasPinned,
|
||||
onBack: canGoBack ? _onBack : null,
|
||||
onForward: canGoForward ? _onForward : null,
|
||||
onPin: _path != null ? _onPin : null,
|
||||
onJumpToPin: hasPinned ? _onJumpToPin : null,
|
||||
onEdit: _path != null ? _onEdit : null,
|
||||
),
|
||||
],
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideMarkdown(_content!, onRecordTap: (id) => _navigateToRecord(context, id)),
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
/// Shared action-bar chrome for sidebar reader widgets (T-189, T-190, T-191).
|
||||
///
|
||||
/// Provides:
|
||||
/// - [ReaderHistory] — back/forward history stack with browser semantics.
|
||||
/// - [ReaderActionBar] — the visible action bar widget: back, forward, pin,
|
||||
/// edit pencil. Plugs into a [ClidePaneChrome] via its `trailing:` slot.
|
||||
///
|
||||
/// Usage: mix [ReaderHistoryMixin] into a [State] to get back/forward/pin state
|
||||
/// management, then put a [ReaderActionBar] in [ClidePaneChrome.trailing].
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// History model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Back/forward history stack. [T] is the entry type (String path for
|
||||
/// markdown, String id for decisions).
|
||||
///
|
||||
/// Standard browser semantics:
|
||||
/// - [push] appends at [_index+1] and truncates any forward entries.
|
||||
/// - [back]/[forward] adjust the index without re-pushing.
|
||||
/// - [canGoBack]/[canGoForward] drive the enabled state of the buttons.
|
||||
class ReaderHistory<T> {
|
||||
final List<T> _stack = [];
|
||||
int _index = -1;
|
||||
|
||||
bool get canGoBack => _index > 0;
|
||||
bool get canGoForward => _index < _stack.length - 1;
|
||||
|
||||
T? get current => _index >= 0 && _index < _stack.length ? _stack[_index] : null;
|
||||
|
||||
/// Push a new entry, truncating any forward history.
|
||||
void push(T entry) {
|
||||
if (_index >= 0 && _stack[_index] == entry) {
|
||||
// Same entry as current — don't push a duplicate.
|
||||
return;
|
||||
}
|
||||
// Truncate forward history.
|
||||
if (_index < _stack.length - 1) {
|
||||
_stack.removeRange(_index + 1, _stack.length);
|
||||
}
|
||||
_stack.add(entry);
|
||||
_index = _stack.length - 1;
|
||||
}
|
||||
|
||||
/// Move back one step. Returns the entry now current, or null.
|
||||
T? back() {
|
||||
if (!canGoBack) return null;
|
||||
_index--;
|
||||
return _stack[_index];
|
||||
}
|
||||
|
||||
/// Move forward one step. Returns the entry now current, or null.
|
||||
T? forward() {
|
||||
if (!canGoForward) return null;
|
||||
_index++;
|
||||
return _stack[_index];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mixin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Mix into a [State] that owns a [ReaderHistory] and an optional pin.
|
||||
///
|
||||
/// The concrete state must call [historyPush] whenever it loads a new
|
||||
/// entry (NOT when navigating back/forward — those call [historyBack] /
|
||||
/// [historyForward] and then load the returned entry without re-pushing).
|
||||
mixin ReaderHistoryMixin<T, W extends StatefulWidget> on State<W> {
|
||||
final ReaderHistory<T> _history = ReaderHistory<T>();
|
||||
T? _pinned;
|
||||
|
||||
bool get canGoBack => _history.canGoBack;
|
||||
bool get canGoForward => _history.canGoForward;
|
||||
bool get hasPinned => _pinned != null;
|
||||
T? get pinnedEntry => _pinned;
|
||||
|
||||
/// Record that the reader is now showing [entry]. Must be called AFTER
|
||||
/// setState has been applied so the action-bar buttons rebuild.
|
||||
void historyPush(T entry) {
|
||||
setState(() => _history.push(entry));
|
||||
}
|
||||
|
||||
/// Navigate back. Returns the entry to load, or null if already at start.
|
||||
T? historyBack() {
|
||||
final entry = _history.back();
|
||||
if (entry != null) setState(() {});
|
||||
return entry;
|
||||
}
|
||||
|
||||
/// Navigate forward. Returns the entry to load, or null if at end.
|
||||
T? historyForward() {
|
||||
final entry = _history.forward();
|
||||
if (entry != null) setState(() {});
|
||||
return entry;
|
||||
}
|
||||
|
||||
/// Set or replace the pin with the current entry.
|
||||
void pinCurrent() {
|
||||
final cur = _history.current;
|
||||
if (cur == null) return;
|
||||
setState(() => _pinned = cur);
|
||||
}
|
||||
|
||||
/// Returns the pinned entry, or null if none set.
|
||||
T? jumpToPin() {
|
||||
return _pinned;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action bar widget
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A row of reader-chrome action buttons: back, forward, pin (set/jump), edit.
|
||||
///
|
||||
/// Designed to plug into [ClidePaneChrome.trailing]. All callbacks are
|
||||
/// optional — pass null to hide/disable the corresponding button.
|
||||
class ReaderActionBar extends StatelessWidget {
|
||||
const ReaderActionBar({
|
||||
super.key,
|
||||
required this.canGoBack,
|
||||
required this.canGoForward,
|
||||
required this.hasPinned,
|
||||
required this.onBack,
|
||||
required this.onForward,
|
||||
required this.onPin,
|
||||
required this.onJumpToPin,
|
||||
required this.onEdit,
|
||||
});
|
||||
|
||||
final bool canGoBack;
|
||||
final bool canGoForward;
|
||||
final bool hasPinned;
|
||||
final VoidCallback? onBack;
|
||||
final VoidCallback? onForward;
|
||||
|
||||
/// Called when the pin button is tapped (set / replace current pin).
|
||||
final VoidCallback? onPin;
|
||||
|
||||
/// Called when the jump-to-pin affordance is tapped.
|
||||
final VoidCallback? onJumpToPin;
|
||||
|
||||
/// Called when the edit pencil is tapped. Pass null to hide the button.
|
||||
final VoidCallback? onEdit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ActionButton(
|
||||
painter: PhosphorIcons.caretLeft,
|
||||
tooltip: 'Back',
|
||||
enabled: canGoBack,
|
||||
onTap: canGoBack ? onBack : null,
|
||||
tokens: tokens,
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
_ActionButton(
|
||||
painter: PhosphorIcons.caretRight,
|
||||
tooltip: 'Forward',
|
||||
enabled: canGoForward,
|
||||
onTap: canGoForward ? onForward : null,
|
||||
tokens: tokens,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_PinButton(
|
||||
hasPinned: hasPinned,
|
||||
onPin: onPin,
|
||||
onJumpToPin: onJumpToPin,
|
||||
tokens: tokens,
|
||||
),
|
||||
if (onEdit != null) ...[
|
||||
const SizedBox(width: 2),
|
||||
_ActionButton(
|
||||
painter: PhosphorIcons.pencilSimple,
|
||||
tooltip: 'Edit in editor',
|
||||
enabled: true,
|
||||
onTap: onEdit,
|
||||
tokens: tokens,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private button widgets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _ActionButton extends StatelessWidget {
|
||||
const _ActionButton({
|
||||
required this.painter,
|
||||
required this.tooltip,
|
||||
required this.enabled,
|
||||
required this.onTap,
|
||||
required this.tokens,
|
||||
});
|
||||
|
||||
final ClideIconPainter painter;
|
||||
final String tooltip;
|
||||
final bool enabled;
|
||||
final VoidCallback? onTap;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: tooltip,
|
||||
enabled: enabled,
|
||||
onTap: onTap,
|
||||
excludeSemantics: true,
|
||||
child: ClideTappable(
|
||||
onTap: onTap,
|
||||
tooltip: tooltip,
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: hovered && enabled ? tokens.sidebarItemHover : null,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: ClideIcon(
|
||||
painter,
|
||||
size: 11,
|
||||
color: enabled ? tokens.panelHeaderForeground : tokens.globalTextMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pin button: when [hasPinned] is true it shows the pin "filled" and the
|
||||
/// button activates jump-to-pin; a long-press (or secondary tap) sets a new
|
||||
/// pin. When [hasPinned] is false the single tap sets the pin.
|
||||
///
|
||||
/// For simplicity (and to keep the widget API flat): a single tap ALWAYS sets
|
||||
/// the pin (replacing the previous one), while the adjacent jump-to-pin uses a
|
||||
/// separate Tappable rendered as a small indicator badge.
|
||||
class _PinButton extends StatelessWidget {
|
||||
const _PinButton({
|
||||
required this.hasPinned,
|
||||
required this.onPin,
|
||||
required this.onJumpToPin,
|
||||
required this.tokens,
|
||||
});
|
||||
|
||||
final bool hasPinned;
|
||||
final VoidCallback? onPin;
|
||||
final VoidCallback? onJumpToPin;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// The pin-set button (always shown, sets/replaces the pin).
|
||||
Semantics(
|
||||
button: true,
|
||||
label: hasPinned ? 'Replace pin' : 'Pin current',
|
||||
onTap: onPin,
|
||||
excludeSemantics: true,
|
||||
child: ClideTappable(
|
||||
onTap: onPin,
|
||||
tooltip: hasPinned ? 'Replace pin' : 'Pin current',
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: hovered ? tokens.sidebarItemHover : null,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: ClideIcon(
|
||||
PhosphorIcons.link,
|
||||
size: 11,
|
||||
color: hasPinned ? tokens.globalFocus : tokens.panelHeaderForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Jump-to-pin affordance — only visible when a pin is set.
|
||||
if (hasPinned) ...[
|
||||
const SizedBox(width: 1),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'Jump to pin',
|
||||
onTap: onJumpToPin,
|
||||
excludeSemantics: true,
|
||||
child: ClideTappable(
|
||||
onTap: onJumpToPin,
|
||||
tooltip: 'Jump to pin',
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: hovered ? tokens.sidebarItemHover : tokens.panelBorder,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: ClideText(
|
||||
'↩',
|
||||
fontSize: 9,
|
||||
color: tokens.globalFocus,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user