diff --git a/app/lib/app.dart b/app/lib/app.dart index c4d2974f..c46f72f6 100644 --- a/app/lib/app.dart +++ b/app/lib/app.dart @@ -82,7 +82,12 @@ class _RootShellState extends State<_RootShell> { color: tokens.globalBackground, child: DialogHost( router: widget.services.dialog, - child: const RootLayout(), + child: Stack( + children: [ + const Positioned.fill(child: RootLayout()), + const ClidePalette(), + ], + ), ), ), ), @@ -181,6 +186,16 @@ class SlotHost extends StatelessWidget { (t) => t.id == activeId, orElse: () => tabs.first, ); + + if (slot == Slots.sidebar) { + return _SidebarSlot( + tabs: tabs, + active: active, + activeId: activeId, + onSelect: (id) => kernel.panels.activateTab(slot, id), + ); + } + return Container( color: tokens.panelBackground, child: Column( @@ -202,7 +217,7 @@ class SlotHost extends StatelessWidget { ); } - String _resolveTitle(BuildContext context, TabContribution t) { + static String _resolveTitle(BuildContext context, TabContribution t) { final key = t.titleKey; final ns = t.i18nNamespace; if (key == null || ns == null) return t.title; @@ -214,6 +229,56 @@ class SlotHost extends StatelessWidget { } } +class _SidebarSlot extends StatelessWidget { + const _SidebarSlot({ + required this.tabs, + required this.active, + required this.activeId, + required this.onSelect, + }); + + final List tabs; + final TabContribution active; + final String activeId; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return Container( + color: tokens.sidebarBackground, + child: Column( + children: [ + Expanded(child: active.build(context)), + ClideIconRail( + items: [ + for (final t in tabs) + ClideIconRailItem( + id: t.id, + icon: _iconFor(t), + tooltip: SlotHost._resolveTitle(context, t), + ), + ], + activeId: activeId, + onSelect: onSelect, + ), + ], + ), + ); + } + + static ClideIconPainter _iconFor(TabContribution t) { + if (t.icon is ClideIconPainter) return t.icon as ClideIconPainter; + return switch (t.id) { + 'files.tree' => const FolderIcon(), + 'git.panel' => const GitBranchIcon(), + 'pql.panel' => const SearchIcon(), + 'problems.panel' => const WarningIcon(), + _ => const DotIcon(), + }; + } +} + class StatusbarHost extends StatelessWidget { const StatusbarHost({super.key}); diff --git a/app/lib/widgets/src/clide_icon_rail.dart b/app/lib/widgets/src/clide_icon_rail.dart new file mode 100644 index 00000000..046b6641 --- /dev/null +++ b/app/lib/widgets/src/clide_icon_rail.dart @@ -0,0 +1,119 @@ +import 'package:clide_app/kernel/src/theme/controller.dart'; +import 'package:clide_app/widgets/src/clide_icon.dart'; +import 'package:clide_app/widgets/src/clide_tooltip.dart'; +import 'package:flutter/widgets.dart'; + +class ClideIconRailItem { + const ClideIconRailItem({ + required this.id, + required this.icon, + required this.tooltip, + }); + + final String id; + final ClideIconPainter icon; + final String tooltip; +} + +class ClideIconRail extends StatefulWidget { + const ClideIconRail({ + super.key, + required this.items, + required this.activeId, + required this.onSelect, + }); + + final List items; + final String? activeId; + final ValueChanged onSelect; + + @override + State createState() => _ClideIconRailState(); +} + +class _ClideIconRailState extends State { + String? _hoveredId; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return MouseRegion( + onExit: (_) => setState(() => _hoveredId = null), + child: Container( + width: double.infinity, + decoration: BoxDecoration( + border: Border(top: BorderSide(color: tokens.dividerColor)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (final item in widget.items) + _RailButton( + item: item, + active: item.id == widget.activeId, + hovered: item.id == _hoveredId, + onHover: () => setState(() => _hoveredId = item.id), + onTap: () => widget.onSelect(item.id), + ), + ], + ), + ), + ); + } +} + +class _RailButton extends StatelessWidget { + const _RailButton({ + required this.item, + required this.active, + required this.hovered, + required this.onHover, + required this.onTap, + }); + + final ClideIconRailItem item; + final bool active; + final bool hovered; + final VoidCallback onHover; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final color = active + ? tokens.globalForeground + : hovered + ? tokens.sidebarForeground + : tokens.sidebarSectionHeader; + + return Semantics( + button: true, + selected: active, + label: item.tooltip, + child: ClideTooltip( + message: item.tooltip, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => onHover(), + child: GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: active + ? tokens.tabActiveBorder + : const Color(0x00000000), + width: 2, + ), + ), + ), + child: ClideIcon(item.icon, size: 16, color: color), + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/widgets/src/icons/git_branch.dart b/app/lib/widgets/src/icons/git_branch.dart new file mode 100644 index 00000000..65ea5a7e --- /dev/null +++ b/app/lib/widgets/src/icons/git_branch.dart @@ -0,0 +1,27 @@ +import 'dart:ui'; +import 'package:clide_app/widgets/src/clide_icon.dart'; + +class GitBranchIcon extends ClideIconPainter { + const GitBranchIcon(); + + @override + void paint(Canvas canvas, Color color) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 0.08 + ..strokeCap = StrokeCap.round; + // Trunk line + canvas.drawLine(const Offset(0.35, 0.2), const Offset(0.35, 0.8), paint); + // Branch line + canvas.drawLine(const Offset(0.65, 0.3), const Offset(0.65, 0.5), paint); + canvas.drawLine(const Offset(0.65, 0.5), const Offset(0.35, 0.6), paint); + // Dots at nodes + final dot = Paint() + ..color = color + ..style = PaintingStyle.fill; + canvas.drawCircle(const Offset(0.35, 0.2), 0.06, dot); + canvas.drawCircle(const Offset(0.35, 0.8), 0.06, dot); + canvas.drawCircle(const Offset(0.65, 0.3), 0.06, dot); + } +} diff --git a/app/lib/widgets/src/icons/search.dart b/app/lib/widgets/src/icons/search.dart new file mode 100644 index 00000000..31aaef91 --- /dev/null +++ b/app/lib/widgets/src/icons/search.dart @@ -0,0 +1,17 @@ +import 'dart:ui'; +import 'package:clide_app/widgets/src/clide_icon.dart'; + +class SearchIcon extends ClideIconPainter { + const SearchIcon(); + + @override + void paint(Canvas canvas, Color color) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 0.08 + ..strokeCap = StrokeCap.round; + canvas.drawCircle(const Offset(0.42, 0.42), 0.22, paint); + canvas.drawLine(const Offset(0.58, 0.58), const Offset(0.78, 0.78), paint); + } +} diff --git a/app/lib/widgets/src/icons/terminal_icon.dart b/app/lib/widgets/src/icons/terminal_icon.dart new file mode 100644 index 00000000..7353b6d5 --- /dev/null +++ b/app/lib/widgets/src/icons/terminal_icon.dart @@ -0,0 +1,20 @@ +import 'dart:ui'; +import 'package:clide_app/widgets/src/clide_icon.dart'; + +class TerminalIcon extends ClideIconPainter { + const TerminalIcon(); + + @override + void paint(Canvas canvas, Color color) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 0.08 + ..strokeCap = StrokeCap.round; + // Prompt chevron >_ + canvas.drawLine(const Offset(0.2, 0.3), const Offset(0.45, 0.5), paint); + canvas.drawLine(const Offset(0.45, 0.5), const Offset(0.2, 0.7), paint); + // Cursor line + canvas.drawLine(const Offset(0.5, 0.7), const Offset(0.8, 0.7), paint); + } +} diff --git a/app/lib/widgets/src/icons/warning.dart b/app/lib/widgets/src/icons/warning.dart new file mode 100644 index 00000000..babcdc07 --- /dev/null +++ b/app/lib/widgets/src/icons/warning.dart @@ -0,0 +1,28 @@ +import 'dart:ui'; +import 'package:clide_app/widgets/src/clide_icon.dart'; + +class WarningIcon extends ClideIconPainter { + const WarningIcon(); + + @override + void paint(Canvas canvas, Color color) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 0.08 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + final path = Path() + ..moveTo(0.5, 0.15) + ..lineTo(0.85, 0.8) + ..lineTo(0.15, 0.8) + ..close(); + canvas.drawPath(path, paint); + // Exclamation + canvas.drawLine(const Offset(0.5, 0.4), const Offset(0.5, 0.58), paint); + final dot = Paint() + ..color = color + ..style = PaintingStyle.fill; + canvas.drawCircle(const Offset(0.5, 0.68), 0.035, dot); + } +} diff --git a/app/lib/widgets/widgets.dart b/app/lib/widgets/widgets.dart index fb3f18a0..04068550 100644 --- a/app/lib/widgets/widgets.dart +++ b/app/lib/widgets/widgets.dart @@ -8,6 +8,8 @@ library; export 'src/clide_button.dart'; export 'src/clide_divider.dart'; export 'src/clide_icon.dart'; +export 'src/clide_icon_rail.dart'; +export 'src/clide_palette.dart'; export 'src/clide_pane_chrome.dart'; export 'src/clide_pty_view.dart'; export 'src/clide_scrollbar.dart'; @@ -22,4 +24,8 @@ export 'src/icons/dot.dart'; export 'src/icons/folder.dart'; export 'src/icons/gear.dart'; export 'src/icons/plug.dart'; +export 'src/icons/git_branch.dart'; +export 'src/icons/search.dart'; +export 'src/icons/terminal_icon.dart'; +export 'src/icons/warning.dart'; export 'src/icons/x.dart';