dissolve app/ into repo root (D-056)

Single Flutter package at the repo root. All code, tests, assets,
and platform directories moved from app/ to root. Package renamed
from clide_app to clide — all imports rewritten. Merged pubspec
combines core (ffi) and app (flutter, yaml, xterm) dependencies.
Makefile simplified: no APP_PRESENT conditionals, no cd, no daemon
lifecycle. 317 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 00:37:20 +02:00
co-authored by Claude Opus 4.6
parent a526c5b9b7
commit 46329700d5
394 changed files with 978 additions and 1090 deletions
+105
View File
@@ -0,0 +1,105 @@
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/widgets/src/clide_text.dart';
import 'package:flutter/widgets.dart';
enum ClideButtonVariant { normal, primary, subtle }
class ClideButton extends StatefulWidget {
const ClideButton({
super.key,
required this.label,
required this.onPressed,
this.variant = ClideButtonVariant.normal,
this.padding = const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
this.semanticLabel,
this.semanticHint,
});
final String label;
final VoidCallback? onPressed;
final ClideButtonVariant variant;
final EdgeInsetsGeometry padding;
/// Overrides [label] for screen readers (use when the visible label is
/// an icon-only glyph or a noun that reads oddly when announced).
final String? semanticLabel;
/// Screen-reader hint describing the button's effect. Optional.
final String? semanticHint;
@override
State<ClideButton> createState() => _ClideButtonState();
}
class _ClideButtonState extends State<ClideButton> {
bool _hovered = false;
bool _pressed = false;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final enabled = widget.onPressed != null;
Color bg;
Color fg;
switch (widget.variant) {
case ClideButtonVariant.normal:
bg = _pressed
? tokens.buttonActiveBackground
: _hovered
? tokens.buttonHoverBackground
: tokens.buttonBackground;
fg = tokens.buttonForeground;
case ClideButtonVariant.primary:
bg = _pressed
? tokens.panelActiveBorder
: _hovered
? tokens.buttonActiveBackground
: tokens.buttonActiveBackground;
fg = tokens.globalBackground;
case ClideButtonVariant.subtle:
bg = _hovered
? tokens.listItemHoverBackground
: tokens.listItemBackground;
fg = tokens.listItemForeground;
}
return Semantics(
button: true,
enabled: enabled,
label: widget.semanticLabel ?? widget.label,
hint: widget.semanticHint,
onTap: enabled ? widget.onPressed : null,
excludeSemantics: true,
child: MouseRegion(
cursor:
enabled ? SystemMouseCursors.click : SystemMouseCursors.forbidden,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTapDown: (_) => setState(() => _pressed = true),
onTapCancel: () => setState(() => _pressed = false),
onTapUp: (_) {
setState(() => _pressed = false);
widget.onPressed?.call();
},
child: Container(
padding: widget.padding,
decoration: BoxDecoration(
color: bg,
border: Border.all(color: tokens.buttonBorder, width: 1),
borderRadius: BorderRadius.circular(3),
),
child: ClideText(
widget.label,
color: fg,
fontWeight: widget.variant == ClideButtonVariant.primary
? FontWeight.w600
: FontWeight.w500,
),
),
),
),
);
}
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:flutter/widgets.dart';
class ClideDivider extends StatelessWidget {
const ClideDivider({
super.key,
this.axis = Axis.horizontal,
this.thickness = 1,
});
final Axis axis;
final double thickness;
@override
Widget build(BuildContext context) {
final color = ClideTheme.of(context).surface.dividerColor;
return Container(
width: axis == Axis.vertical ? thickness : null,
height: axis == Axis.horizontal ? thickness : null,
color: color,
);
}
}
+57
View File
@@ -0,0 +1,57 @@
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:flutter/widgets.dart';
/// Stateless painter producing a single-color icon. Every icon in
/// [icons/] subclasses this; widgets wrap with [ClideIcon] for size +
/// color-from-theme.
abstract class ClideIconPainter {
const ClideIconPainter();
/// Paint the icon into a unit square (0,0 .. 1,1).
void paint(Canvas canvas, Color color);
}
class ClideIcon extends StatelessWidget {
const ClideIcon(
this.painter, {
super.key,
this.size = 14,
this.color,
});
final ClideIconPainter painter;
final double size;
final Color? color;
@override
Widget build(BuildContext context) {
final resolved = color ?? ClideTheme.of(context).surface.globalForeground;
return SizedBox(
width: size,
height: size,
child: CustomPaint(
size: Size(size, size),
painter: _IconPainterAdapter(painter: painter, color: resolved),
),
);
}
}
class _IconPainterAdapter extends CustomPainter {
_IconPainterAdapter({required this.painter, required this.color});
final ClideIconPainter painter;
final Color color;
@override
void paint(Canvas canvas, Size size) {
canvas.save();
canvas.scale(size.width, size.height);
painter.paint(canvas, color);
canvas.restore();
}
@override
bool shouldRepaint(covariant _IconPainterAdapter old) =>
old.painter != painter || old.color != color;
}
+115
View File
@@ -0,0 +1,115 @@
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/widgets/src/clide_icon.dart';
import 'package:clide/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<ClideIconRailItem> items;
final String? activeId;
final ValueChanged<String> onSelect;
@override
State<ClideIconRail> createState() => _ClideIconRailState();
}
class _ClideIconRailState extends State<ClideIconRail> {
String? _hoveredId;
@override
Widget build(BuildContext context) {
return MouseRegion(
onExit: (_) => setState(() => _hoveredId = null),
child: SizedBox(
width: double.infinity,
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: 10, vertical: 4),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: active
? tokens.tabActiveBorder
: const Color(0x00000000),
width: 2,
),
),
),
child: ClideIcon(item.icon, size: 16, color: color),
),
),
),
),
);
}
}
+166
View File
@@ -0,0 +1,166 @@
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/src/clide_text.dart';
import 'package:clide/widgets/src/typography.dart';
import 'package:flutter/widgets.dart';
class ClidePalette extends StatefulWidget {
const ClidePalette({super.key});
@override
State<ClidePalette> createState() => _ClidePaletteState();
}
class _ClidePaletteState extends State<ClidePalette> {
final _input = TextEditingController();
final _focus = FocusNode();
@override
void initState() {
super.initState();
_focus.requestFocus();
}
@override
void dispose() {
_input.dispose();
_focus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
final tokens = ClideTheme.of(context).surface;
return ListenableBuilder(
listenable: kernel.palette,
builder: (ctx, _) {
if (!kernel.palette.isOpen) return const SizedBox.shrink();
final filtered = kernel.palette.filtered();
return Positioned(
top: 60,
left: 0,
right: 0,
child: Center(
child: Container(
width: 480,
constraints: const BoxConstraints(maxHeight: 360),
decoration: BoxDecoration(
color: tokens.dropdownBackground,
border: Border.all(color: tokens.dropdownBorder),
borderRadius: BorderRadius.circular(6),
boxShadow: const [
BoxShadow(
color: Color(0x40000000),
blurRadius: 12,
offset: Offset(0, 4),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(8),
child: EditableText(
controller: _input,
focusNode: _focus,
style: TextStyle(
fontFamily: clideMonoFamily,
fontSize: clideFontMono,
color: tokens.dropdownForeground,
),
cursorColor: tokens.globalFocus,
backgroundCursorColor: tokens.globalFocus,
maxLines: 1,
onChanged: (v) => kernel.palette.setFilter(v),
onSubmitted: (_) {
if (filtered.isNotEmpty) {
kernel.palette.invoke(filtered.first.command);
_input.clear();
}
},
),
),
Flexible(
child: ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: filtered.length,
itemBuilder: (ctx, i) {
final cmd = filtered[i];
return _PaletteItem(
title: cmd.title ?? cmd.command,
command: cmd.command,
binding: cmd.defaultBinding,
onTap: () {
kernel.palette.invoke(cmd.command);
_input.clear();
},
);
},
),
),
],
),
),
),
);
},
);
}
}
class _PaletteItem extends StatefulWidget {
const _PaletteItem({
required this.title,
required this.command,
required this.onTap,
this.binding,
});
final String title;
final String command;
final String? binding;
final VoidCallback onTap;
@override
State<_PaletteItem> createState() => _PaletteItemState();
}
class _PaletteItemState extends State<_PaletteItem> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
onTap: widget.onTap,
child: Container(
color: _hover ? tokens.listItemHoverBackground : null,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
Expanded(
child: ClideText(
widget.title,
color: tokens.listItemForeground,
),
),
if (widget.binding != null)
ClideText(
widget.binding!,
fontSize: clideFontCaption,
fontFamily: clideMonoFamily,
color: tokens.globalTextMuted,
),
],
),
),
),
);
}
}
+183
View File
@@ -0,0 +1,183 @@
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:flutter/widgets.dart';
import 'clide_divider.dart';
import 'clide_icon.dart';
import 'clide_text.dart';
import 'icons/x.dart';
import 'typography.dart';
/// Shared chrome for any pane that sits in a tab or split: a title
/// strip at the top, an optional close button, and the pane body
/// underneath. `ClidePtyView`, diff views, canvas tabs, graph tabs —
/// everything with a "pane header" surface reuses this.
class ClidePaneChrome extends StatelessWidget {
const ClidePaneChrome({
super.key,
required this.title,
required this.child,
this.subtitle,
this.leading,
this.onClose,
this.trailing,
});
/// Primary label in the header — typically the pane kind + an
/// abbreviated path / session name (`terminal — ~/clide`).
final String title;
/// Optional secondary line (cwd hint, session id, status).
final String? subtitle;
/// Icon or badge drawn before the title.
final Widget? leading;
/// Main pane content.
final Widget child;
/// If provided, renders an `x` close button on the right. Primary
/// Claude panes deliberately pass `null` so the user can't hide the
/// primary (D-041).
final VoidCallback? onClose;
/// Extra trailing widgets (status indicator, menu button, etc.).
/// Drawn before the close button when both are present.
final List<Widget>? trailing;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return ColoredBox(
color: tokens.panelBackground,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_Header(
title: title,
subtitle: subtitle,
leading: leading,
onClose: onClose,
trailing: trailing,
),
const ClideDivider(),
Expanded(child: child),
],
),
);
}
}
class _CloseButton extends StatefulWidget {
const _CloseButton({required this.onPressed});
final VoidCallback onPressed;
@override
State<_CloseButton> createState() => _CloseButtonState();
}
class _CloseButtonState extends State<_CloseButton> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Semantics(
button: true,
label: 'Close pane',
onTap: widget.onPressed,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onPressed,
child: Container(
width: 20,
height: 20,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _hover ? tokens.tabCloseHover : null,
),
child: ClideIcon(
const CloseIcon(),
size: 10,
color: tokens.panelHeaderForeground,
),
),
),
),
);
}
}
class _Header extends StatelessWidget {
const _Header({
required this.title,
required this.subtitle,
required this.leading,
required this.onClose,
required this.trailing,
});
final String title;
final String? subtitle;
final Widget? leading;
final VoidCallback? onClose;
final List<Widget>? trailing;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Semantics(
container: true,
explicitChildNodes: true,
label: 'pane header: $title',
child: ColoredBox(
color: tokens.panelHeader,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
if (leading != null) ...[leading!, const SizedBox(width: 6)],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ClideText(
title,
fontSize: clideFontCaption,
color: tokens.panelHeaderForeground,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (subtitle != null)
ClideText(
subtitle!,
fontSize: clideFontCaption,
muted: true,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
if (trailing != null) ...trailing!.map(
(w) => Padding(
padding: const EdgeInsets.only(left: 6),
child: w,
),
),
if (onClose != null)
Padding(
padding: const EdgeInsets.only(left: 6),
child: _CloseButton(onPressed: onClose!),
),
],
),
),
),
);
}
}
+105
View File
@@ -0,0 +1,105 @@
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:clide/widgets/src/typography.dart';
import 'package:flutter/widgets.dart';
import 'package:xterm/xterm.dart';
/// Theme-aware terminal view. Wraps xterm.dart's [TerminalView] with
/// clide token bindings, JetBrainsMono as the face, and a Semantics
/// wrapper that exposes the pane as a live region with the terminal's
/// aria label.
///
/// Callers provide the [Terminal] model; hooking its `onOutput` to an
/// IPC `pane.write` call and feeding `pane.output` event bytes into
/// `terminal.write()` is the consumer's job (typically a builtin
/// extension — see `builtin.terminal` / `builtin.claude`).
class ClidePtyView extends StatelessWidget {
const ClidePtyView({
super.key,
required this.terminal,
this.label,
this.focusNode,
this.autofocus = false,
this.fontSize = clideFontMono,
});
final Terminal terminal;
/// A11y label — typically the pane title ("terminal — ~/repo",
/// "claude — primary", …).
final String? label;
final FocusNode? focusNode;
final bool autofocus;
final double fontSize;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Semantics(
label: label,
textField: true,
multiline: true,
focusable: true,
liveRegion: true,
child: ColoredBox(
color: tokens.panelBackground,
child: TerminalView(
terminal,
focusNode: focusNode,
autofocus: autofocus,
theme: _buildTheme(tokens),
textStyle: TerminalStyle(
fontSize: fontSize,
fontFamily: clideMonoFamily,
fontFamilyFallback: clideMonoFamilyFallback,
),
padding: const EdgeInsets.all(8),
backgroundOpacity: 1,
cursorType: TerminalCursorType.block,
),
),
);
}
}
/// Derive an xterm [TerminalTheme] from our surface tokens.
///
/// Foreground / background pull from the editor tokens so the terminal
/// visually matches the rest of the IDE. The 16-color ANSI palette is
/// chosen to read well on our current two bundled themes
/// (summer-night + whatever else ships); a future pass lets themes
/// override the palette directly in their YAML.
TerminalTheme _buildTheme(SurfaceTokens t) {
// Selection tint derived from the focus accent at 40% alpha — no
// dedicated token yet; revisit when the theme layer grows an
// editor.selection.* token family.
final selection = t.globalFocus.withAlpha(0x66);
return TerminalTheme(
cursor: t.globalForeground,
selection: selection,
foreground: t.globalForeground,
background: t.panelBackground,
// ANSI palette — reasonable defaults tuned for dark themes. The
// bright variants are the same hue with higher luminance.
black: const Color(0xFF1b1d23),
red: const Color(0xFFe06c75),
green: const Color(0xFF98c379),
yellow: const Color(0xFFe5c07b),
blue: const Color(0xFF61afef),
magenta: const Color(0xFFc678dd),
cyan: const Color(0xFF56b6c2),
white: const Color(0xFFabb2bf),
brightBlack: const Color(0xFF5c6370),
brightRed: const Color(0xFFff7b85),
brightGreen: const Color(0xFFabd486),
brightYellow: const Color(0xFFffd89a),
brightBlue: const Color(0xFF82c5ff),
brightMagenta: const Color(0xFFdb8fe4),
brightCyan: const Color(0xFF6fcbd6),
brightWhite: const Color(0xFFffffff),
searchHitBackground: const Color(0xFFffeb8c),
searchHitBackgroundCurrent: const Color(0xFFffd54a),
searchHitForeground: const Color(0xFF1b1d23),
);
}
+54
View File
@@ -0,0 +1,54 @@
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:flutter/widgets.dart';
/// Thin themed scrollbar. Tier-0 shell; more refined scrolling (velocity
/// multiplier, keyboard nav) lands with the editor.
class ClideScrollbar extends StatelessWidget {
const ClideScrollbar({
super.key,
required this.controller,
required this.child,
this.axis = Axis.vertical,
});
final ScrollController controller;
final Widget child;
final Axis axis;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return ScrollbarTheme(
slider: tokens.scrollbarSlider,
sliderHover: tokens.scrollbarSliderHover,
track: tokens.scrollbarTrack,
child: RawScrollbar(
controller: controller,
thumbColor: tokens.scrollbarSlider,
thickness: 8,
radius: const Radius.circular(4),
child: child,
),
);
}
}
class ScrollbarTheme extends InheritedWidget {
const ScrollbarTheme({
super.key,
required this.slider,
required this.sliderHover,
required this.track,
required super.child,
});
final Color slider;
final Color sliderHover;
final Color track;
@override
bool updateShouldNotify(ScrollbarTheme old) =>
slider != old.slider ||
sliderHover != old.sliderHover ||
track != old.track;
}
+94
View File
@@ -0,0 +1,94 @@
import 'dart:math' as math;
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:flutter/widgets.dart';
class ClideSpine extends StatefulWidget {
const ClideSpine({
super.key,
required this.label,
required this.onExpand,
this.side = SpineSide.left,
this.badgeCount = 0,
});
final String label;
final VoidCallback onExpand;
final SpineSide side;
final int badgeCount;
static const double width = 12;
@override
State<ClideSpine> createState() => _ClideSpineState();
}
enum SpineSide { left, right }
class _ClideSpineState extends State<ClideSpine> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final borderSide = BorderSide(color: tokens.dividerColor);
return Semantics(
button: true,
label: '${widget.label} — click to expand',
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onExpand,
child: Container(
width: ClideSpine.width,
decoration: BoxDecoration(
color: _hovered ? tokens.sidebarItemHover : tokens.sidebarBackground,
border: Border(
left: widget.side == SpineSide.right ? borderSide : BorderSide.none,
right: widget.side == SpineSide.left ? borderSide : BorderSide.none,
),
),
child: Stack(
children: [
Center(
child: Transform.rotate(
angle: widget.side == SpineSide.left ? -math.pi / 2 : math.pi / 2,
child: Text(
widget.label,
style: TextStyle(
fontSize: 9,
color: tokens.globalTextMuted,
letterSpacing: 0.5,
),
maxLines: 1,
overflow: TextOverflow.clip,
),
),
),
if (widget.badgeCount > 0)
Positioned(
top: 4,
left: 0,
right: 0,
child: Center(
child: Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: tokens.statusInfo,
shape: BoxShape.circle,
),
),
),
),
],
),
),
),
),
);
}
}
+42
View File
@@ -0,0 +1,42 @@
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:flutter/widgets.dart';
/// Themed container. Replaces Material's Card / Scaffold body surfaces
/// for clide widgets — pulls color, border, and padding from the
/// current theme's surface tokens.
class ClideSurface extends StatelessWidget {
const ClideSurface({
super.key,
this.child,
this.color,
this.border,
this.padding = EdgeInsets.zero,
this.width,
this.height,
this.borderRadius,
});
final Widget? child;
final Color? color;
final Color? border;
final EdgeInsetsGeometry padding;
final double? width;
final double? height;
final BorderRadius? borderRadius;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Container(
width: width,
height: height,
padding: padding,
decoration: BoxDecoration(
color: color ?? tokens.panelBackground,
border: border == null ? null : Border.all(color: border!, width: 1),
borderRadius: borderRadius,
),
child: child,
);
}
}
+126
View File
@@ -0,0 +1,126 @@
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/widgets/src/clide_icon.dart';
import 'package:clide/widgets/src/clide_text.dart';
import 'package:flutter/widgets.dart';
@immutable
class ClideTabItem {
const ClideTabItem({
required this.id,
required this.title,
this.icon,
});
final String id;
final String title;
final ClideIconPainter? icon;
}
class ClideTabBar extends StatelessWidget {
const ClideTabBar({
super.key,
required this.items,
required this.activeId,
required this.onSelect,
this.height = 28,
this.semanticContainerLabel,
});
final List<ClideTabItem> items;
final String? activeId;
final ValueChanged<String> onSelect;
final double height;
/// Optional container-level label for screen readers (e.g. "Sidebar tabs").
final String? semanticContainerLabel;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Semantics(
container: true,
label: semanticContainerLabel,
explicitChildNodes: true,
child: Container(
height: height,
color: tokens.tabBarBackground,
child: Row(
children: [
for (final item in items)
_Tab(
item: item,
active: item.id == activeId,
onTap: () => onSelect(item.id),
),
],
),
),
);
}
}
class _Tab extends StatefulWidget {
const _Tab({required this.item, required this.active, required this.onTap});
final ClideTabItem item;
final bool active;
final VoidCallback onTap;
@override
State<_Tab> createState() => _TabState();
}
class _TabState extends State<_Tab> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final bg = widget.active
? tokens.tabActive
: (_hovered ? tokens.tabInactive : tokens.tabInactive);
final fg = widget.active
? tokens.tabActiveForeground
: tokens.tabInactiveForeground;
return Semantics(
button: true,
selected: widget.active,
label: widget.item.title,
onTap: widget.onTap,
excludeSemantics: true,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: bg,
border: Border(
bottom: BorderSide(
color: widget.active
? tokens.tabActiveBorder
: const Color(0x00000000),
width: 2,
),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.item.icon != null) ...[
ClideIcon(widget.item.icon!, size: 12, color: fg),
const SizedBox(width: 6),
],
ClideText(widget.item.title, color: fg),
],
),
),
),
),
);
}
}
+55
View File
@@ -0,0 +1,55 @@
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/widgets/src/typography.dart';
import 'package:flutter/widgets.dart';
/// Theme-aware Text. Defaults pull from the global foreground token
/// and [clideUiDefaultWeight].
///
/// The UI font family is deliberately **not** set on this widget — it
/// inherits from the ambient `DefaultTextStyle` which `_AppRoot`
/// provides ([clideUiFamily] in real runs). Goldens rely on Alchemist
/// injecting Ahem for deterministic metrics; hard-coding a family here
/// would override that and break pixel determinism per D-024.
class ClideText extends StatelessWidget {
const ClideText(
this.data, {
super.key,
this.color,
this.fontSize = 14,
this.fontFamily,
this.fontWeight,
this.muted = false,
this.maxLines,
this.overflow,
this.textAlign,
});
final String data;
final Color? color;
final double fontSize;
final String? fontFamily;
final FontWeight? fontWeight;
final bool muted;
final int? maxLines;
final TextOverflow? overflow;
final TextAlign? textAlign;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final resolved =
color ?? (muted ? tokens.globalTextMuted : tokens.globalForeground);
return Text(
data,
maxLines: maxLines,
overflow: overflow,
textAlign: textAlign,
style: TextStyle(
color: resolved,
fontSize: fontSize,
fontFamily: fontFamily,
fontWeight: fontWeight,
),
);
}
}
+84
View File
@@ -0,0 +1,84 @@
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/widgets/src/clide_text.dart';
import 'package:flutter/widgets.dart';
/// Very small tooltip — hover a child to reveal a label. Uses an
/// OverlayEntry to draw above everything else without Material.
class ClideTooltip extends StatefulWidget {
const ClideTooltip({
super.key,
required this.message,
required this.child,
this.showDelay = const Duration(milliseconds: 500),
});
final String message;
final Widget child;
final Duration showDelay;
@override
State<ClideTooltip> createState() => _ClideTooltipState();
}
class _ClideTooltipState extends State<ClideTooltip> {
OverlayEntry? _entry;
bool _hovering = false;
void _show() {
final overlay = Overlay.maybeOf(context);
if (overlay == null) return;
_entry?.remove();
final box = context.findRenderObject() as RenderBox?;
if (box == null) return;
final offset = box.localToGlobal(Offset(0, box.size.height + 4));
_entry = OverlayEntry(
builder: (ctx) {
final tokens = ClideTheme.of(ctx).surface;
return Positioned(
left: offset.dx,
top: offset.dy,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: tokens.tooltipBackground,
border: Border.all(color: tokens.tooltipBorder),
borderRadius: BorderRadius.circular(3),
),
child: ClideText(widget.message, color: tokens.tooltipForeground),
),
);
},
);
overlay.insert(_entry!);
}
void _hide() {
_entry?.remove();
_entry = null;
}
@override
void dispose() {
_hide();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Semantics(
tooltip: widget.message,
child: MouseRegion(
onEnter: (_) async {
_hovering = true;
await Future<void>.delayed(widget.showDelay);
if (mounted && _hovering) _show();
},
onExit: (_) {
_hovering = false;
_hide();
},
child: widget.child,
),
);
}
}
+21
View File
@@ -0,0 +1,21 @@
import 'package:clide/widgets/src/clide_icon.dart';
import 'package:flutter/widgets.dart';
class CheckIcon extends ClideIconPainter {
const CheckIcon();
@override
void paint(Canvas canvas, Color color) {
final p = Paint()
..color = color
..strokeWidth = 0.14
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
final path = Path()
..moveTo(0.18, 0.52)
..lineTo(0.42, 0.74)
..lineTo(0.82, 0.30);
canvas.drawPath(path, p);
}
}
+40
View File
@@ -0,0 +1,40 @@
import 'package:clide/widgets/src/clide_icon.dart';
import 'package:flutter/widgets.dart';
class ChevronRightIcon extends ClideIconPainter {
const ChevronRightIcon();
@override
void paint(Canvas canvas, Color color) {
final p = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 0.12
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
final path = Path()
..moveTo(0.36, 0.22)
..lineTo(0.66, 0.50)
..lineTo(0.36, 0.78);
canvas.drawPath(path, p);
}
}
class ChevronDownIcon extends ClideIconPainter {
const ChevronDownIcon();
@override
void paint(Canvas canvas, Color color) {
final p = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 0.12
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
final path = Path()
..moveTo(0.22, 0.36)
..lineTo(0.50, 0.66)
..lineTo(0.78, 0.36);
canvas.drawPath(path, p);
}
}
+11
View File
@@ -0,0 +1,11 @@
import 'package:clide/widgets/src/clide_icon.dart';
import 'package:flutter/widgets.dart';
class DotIcon extends ClideIconPainter {
const DotIcon();
@override
void paint(Canvas canvas, Color color) {
canvas.drawCircle(const Offset(0.5, 0.5), 0.18, Paint()..color = color);
}
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:clide/widgets/src/clide_icon.dart';
import 'package:flutter/widgets.dart';
class FolderIcon extends ClideIconPainter {
const FolderIcon();
@override
void paint(Canvas canvas, Color color) {
final p = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 0.08;
final path = Path()
..moveTo(0.08, 0.30)
..lineTo(0.40, 0.30)
..lineTo(0.48, 0.22)
..lineTo(0.92, 0.22)
..lineTo(0.92, 0.80)
..lineTo(0.08, 0.80)
..close();
canvas.drawPath(path, p);
}
}
+38
View File
@@ -0,0 +1,38 @@
import 'dart:math' as math;
import 'package:clide/widgets/src/clide_icon.dart';
import 'package:flutter/widgets.dart';
class GearIcon extends ClideIconPainter {
const GearIcon();
@override
void paint(Canvas canvas, Color color) {
final p = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 0.08
..strokeCap = StrokeCap.round;
const center = Offset(0.5, 0.5);
const teeth = 8;
const innerR = 0.25;
const outerR = 0.40;
final path = Path();
for (var i = 0; i < teeth; i++) {
final a1 = (i / teeth) * 2 * math.pi;
final a2 = ((i + 0.5) / teeth) * 2 * math.pi;
final p1 = center + Offset(math.cos(a1) * innerR, math.sin(a1) * innerR);
final p2 = center + Offset(math.cos(a1) * outerR, math.sin(a1) * outerR);
final p3 = center + Offset(math.cos(a2) * outerR, math.sin(a2) * outerR);
final p4 = center + Offset(math.cos(a2) * innerR, math.sin(a2) * innerR);
path
..moveTo(p1.dx, p1.dy)
..lineTo(p2.dx, p2.dy)
..lineTo(p3.dx, p3.dy)
..lineTo(p4.dx, p4.dy);
}
canvas.drawPath(path, p);
canvas.drawCircle(center, 0.12, p);
}
}
+27
View File
@@ -0,0 +1,27 @@
import 'dart:ui';
import 'package:clide/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);
}
}
+73
View File
@@ -0,0 +1,73 @@
import 'dart:ui' as ui;
import 'package:clide/widgets/src/clide_icon.dart';
class PhosphorIconPainter extends ClideIconPainter {
const PhosphorIconPainter(this.codePoint, {this.family = 'Phosphor'});
final int codePoint;
final String family;
@override
void paint(ui.Canvas canvas, ui.Color color) {
final builder = ui.ParagraphBuilder(
ui.ParagraphStyle(fontFamily: family, fontSize: 1.0, height: 1.0, textAlign: ui.TextAlign.center),
)
..pushStyle(ui.TextStyle(color: color, fontFamily: family))
..addText(String.fromCharCode(codePoint));
final paragraph = builder.build()..layout(const ui.ParagraphConstraints(width: 1.0));
final dy = (1.0 - paragraph.height) / 2;
canvas.drawParagraph(paragraph, ui.Offset(0, dy));
}
@override
bool operator ==(Object other) => other is PhosphorIconPainter && other.codePoint == codePoint && other.family == family;
@override
int get hashCode => Object.hash(codePoint, family);
}
abstract class PhosphorIcons {
static const folder = PhosphorIconPainter(0xe24a);
static const fileText = PhosphorIconPainter(0xe23a);
static const gitBranch = PhosphorIconPainter(0xe278);
static const gitCommit = PhosphorIconPainter(0xe27a);
static const gitDiff = PhosphorIconPainter(0xe27c);
static const gitPullRequest = PhosphorIconPainter(0xe282);
static const magnifyingGlass = PhosphorIconPainter(0xe30c);
static const terminal = PhosphorIconPainter(0xe47e);
static const terminalWindow = PhosphorIconPainter(0xeae8);
static const code = PhosphorIconPainter(0xe1bc);
static const codeBlock = PhosphorIconPainter(0xeafe);
static const pencilSimple = PhosphorIconPainter(0xe3b4);
static const eye = PhosphorIconPainter(0xe220);
static const eyeSlash = PhosphorIconPainter(0xe224);
static const arrowsOutSimple = PhosphorIconPainter(0xe0a6);
static const arrowsInSimple = PhosphorIconPainter(0xe09e);
static const list = PhosphorIconPainter(0xe2f0);
static const listChecks = PhosphorIconPainter(0xeadc);
static const gear = PhosphorIconPainter(0xe270);
static const puzzlePiece = PhosphorIconPainter(0xe596);
static const keyboard = PhosphorIconPainter(0xe2d8);
static const palette = PhosphorIconPainter(0xe6c8);
static const warning = PhosphorIconPainter(0xe4e0);
static const warningCircle = PhosphorIconPainter(0xe4e2);
static const check = PhosphorIconPainter(0xe182);
static const checkCircle = PhosphorIconPainter(0xe184);
static const caretLeft = PhosphorIconPainter(0xe138);
static const caretRight = PhosphorIconPainter(0xe13a);
static const caretDown = PhosphorIconPainter(0xe136);
static const caretUp = PhosphorIconPainter(0xe13c);
static const graph = PhosphorIconPainter(0xeb58);
static const treeStructure = PhosphorIconPainter(0xe67c);
static const image = PhosphorIconPainter(0xe2ca);
static const link = PhosphorIconPainter(0xe2e2);
static const chatCircle = PhosphorIconPainter(0xe168);
static const robot = PhosphorIconPainter(0xe762);
static const ticket = PhosphorIconPainter(0xe490);
static const lightbulb = PhosphorIconPainter(0xe2dc);
static const notepad = PhosphorIconPainter(0xe63e);
static const bookOpen = PhosphorIconPainter(0xe0e6);
static const xMark = PhosphorIconPainter(0xe4f6);
static const circlesFour = PhosphorIconPainter(0xe190);
}
+31
View File
@@ -0,0 +1,31 @@
import 'package:clide/widgets/src/clide_icon.dart';
import 'package:flutter/widgets.dart';
/// Plug-shaped connection icon. Used by the ipc-status statusbar item
/// and by future connection-state affordances.
class PlugIcon extends ClideIconPainter {
const PlugIcon();
@override
void paint(Canvas canvas, Color color) {
final p = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 0.10
..strokeCap = StrokeCap.round;
// body
final body = Path()
..moveTo(0.30, 0.30)
..lineTo(0.60, 0.30)
..lineTo(0.60, 0.55)
..arcToPoint(const Offset(0.30, 0.55),
radius: const Radius.circular(0.15), clockwise: false)
..close();
canvas.drawPath(body, p);
// cord
canvas.drawLine(const Offset(0.45, 0.60), const Offset(0.45, 0.88), p);
// prongs
canvas.drawLine(const Offset(0.38, 0.18), const Offset(0.38, 0.30), p);
canvas.drawLine(const Offset(0.52, 0.18), const Offset(0.52, 0.30), p);
}
}
+17
View File
@@ -0,0 +1,17 @@
import 'dart:ui';
import 'package:clide/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);
}
}
+20
View File
@@ -0,0 +1,20 @@
import 'dart:ui';
import 'package:clide/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);
}
}
+28
View File
@@ -0,0 +1,28 @@
import 'dart:ui';
import 'package:clide/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);
}
}
+17
View File
@@ -0,0 +1,17 @@
import 'package:clide/widgets/src/clide_icon.dart';
import 'package:flutter/widgets.dart';
class CloseIcon extends ClideIconPainter {
const CloseIcon();
@override
void paint(Canvas canvas, Color color) {
final p = Paint()
..color = color
..strokeWidth = 0.10
..strokeCap = StrokeCap.round;
canvas
..drawLine(const Offset(0.22, 0.22), const Offset(0.78, 0.78), p)
..drawLine(const Offset(0.78, 0.22), const Offset(0.22, 0.78), p);
}
}
+78
View File
@@ -0,0 +1,78 @@
/// Typography constants shared across widgets.
///
/// Two bundled families:
///
/// - [clideUiFamily] — Josefin Sans, the application-wide UI face.
/// Shipped as a variable font (weights 100-700) + italic companion;
/// default weight is [clideUiDefaultWeight] (Light / `w300`).
/// - [clideMonoFamily] — JetBrains Mono, for terminal panes, diff
/// views, code editors, and any other monospace surface.
///
/// Fallback chains exist for web builds + harnesses where the bundled
/// asset isn't picked up (rare, but possible during `flutter test` if
/// asset fonts aren't declared in the harness).
library;
import 'package:flutter/widgets.dart' show FontWeight;
// ---------------------------------------------------------------------------
// UI face — Josefin Sans
// ---------------------------------------------------------------------------
/// The bundled application UI family. Always resolved first.
const String clideUiFamily = 'JosefinSans';
/// Default weight for UI text. Josefin Sans reads well at Light; the
/// rest of the design adjusts contrast and size to stay legible.
const FontWeight clideUiDefaultWeight = FontWeight.w300;
/// System fallback chain for the UI face. Sans-serif humanist faces
/// that sit close to Josefin's proportions, ordered by platform.
const List<String> clideUiFamilyFallback = [
// User system install of Josefin, if any.
'Josefin Sans',
// Platform humanist sans defaults.
'Inter',
'Helvetica Neue',
'Helvetica',
'Arial',
'sans-serif',
];
// ---------------------------------------------------------------------------
// Monospace face — JetBrains Mono
// ---------------------------------------------------------------------------
/// The bundled monospace family. Always resolved first.
const String clideMonoFamily = 'JetBrainsMono';
// ---------------------------------------------------------------------------
// Type scale — semantic sizes. Widgets inherit from the ambient
// DefaultTextStyle (set at the app root). Only override when the
// semantic role genuinely differs from body text. Prefer these
// constants over bare numbers so the scale stays coherent.
// ---------------------------------------------------------------------------
const double clideFontBody = 15;
const double clideFontCaption = 14;
const double clideFontMono = 14;
/// System fallback chain. Ordered by platform prevalence + quality of
/// programming-ligature / box-drawing coverage.
const List<String> clideMonoFamilyFallback = [
// macOS
'SF Mono',
'Menlo',
'Monaco',
// Linux — user system install under the canonical PostScript name
'JetBrains Mono',
'Fira Code',
'Hack',
'DejaVu Sans Mono',
'Liberation Mono',
// Windows
'Cascadia Code',
'Consolas',
// Last resort
'monospace',
];