using System; using System.Windows.Input; using Avalonia; using Avalonia.Controls; using Avalonia.Data; using Avalonia.Input; using Avalonia.Media; using Avalonia.Rendering; using ClaudeDo.Ui.ViewModels.Modals; namespace ClaudeDo.Ui.Views.Controls; /// /// Usage bar with three draggable stage markers: soft (throttle to 2 slots), hard (1 slot) and gate /// (queue paused). Positions are computed against the control's real width — no hardcoded track /// size — and the drag math lives in so it stays testable. /// Values are written back through TwoWay bindings while dragging; /// fires once on release, which is when the host persists them. /// A row without thresholds (plan-dependent scoped buckets) renders as a plain read-only bar. /// public sealed class UsageGaugeBar : Control, ICustomHitTest { /// How close the pointer has to be to grab a marker. private const double GrabRadiusPx = 12; private const double TrackHeightPx = 10; private const double MarkerWidthPx = 2; public static readonly StyledProperty PercentProperty = AvaloniaProperty.Register(nameof(Percent)); public static readonly StyledProperty IsWarnProperty = AvaloniaProperty.Register(nameof(IsWarn)); public static readonly StyledProperty SoftPctProperty = AvaloniaProperty.Register( nameof(SoftPct), defaultBindingMode: BindingMode.TwoWay); public static readonly StyledProperty HardPctProperty = AvaloniaProperty.Register( nameof(HardPct), defaultBindingMode: BindingMode.TwoWay); public static readonly StyledProperty GatePctProperty = AvaloniaProperty.Register( nameof(GatePct), defaultBindingMode: BindingMode.TwoWay); public static readonly StyledProperty TrackBrushProperty = AvaloniaProperty.Register(nameof(TrackBrush)); public static readonly StyledProperty FillBrushProperty = AvaloniaProperty.Register(nameof(FillBrush)); public static readonly StyledProperty WarnFillBrushProperty = AvaloniaProperty.Register(nameof(WarnFillBrush)); public static readonly StyledProperty SoftMarkerBrushProperty = AvaloniaProperty.Register(nameof(SoftMarkerBrush)); public static readonly StyledProperty HardMarkerBrushProperty = AvaloniaProperty.Register(nameof(HardMarkerBrush)); public static readonly StyledProperty GateMarkerBrushProperty = AvaloniaProperty.Register(nameof(GateMarkerBrush)); public static readonly StyledProperty CommitCommandProperty = AvaloniaProperty.Register(nameof(CommitCommand)); static UsageGaugeBar() { AffectsRender( PercentProperty, IsWarnProperty, SoftPctProperty, HardPctProperty, GatePctProperty, TrackBrushProperty, FillBrushProperty, WarnFillBrushProperty, SoftMarkerBrushProperty, HardMarkerBrushProperty, GateMarkerBrushProperty); } private UsageThresholdDrag.Stage? _dragging; public double Percent { get => GetValue(PercentProperty); set => SetValue(PercentProperty, value); } public bool IsWarn { get => GetValue(IsWarnProperty); set => SetValue(IsWarnProperty, value); } public int? SoftPct { get => GetValue(SoftPctProperty); set => SetValue(SoftPctProperty, value); } public int? HardPct { get => GetValue(HardPctProperty); set => SetValue(HardPctProperty, value); } public int? GatePct { get => GetValue(GatePctProperty); set => SetValue(GatePctProperty, value); } public IBrush? TrackBrush { get => GetValue(TrackBrushProperty); set => SetValue(TrackBrushProperty, value); } public IBrush? FillBrush { get => GetValue(FillBrushProperty); set => SetValue(FillBrushProperty, value); } public IBrush? WarnFillBrush { get => GetValue(WarnFillBrushProperty); set => SetValue(WarnFillBrushProperty, value); } public IBrush? SoftMarkerBrush { get => GetValue(SoftMarkerBrushProperty); set => SetValue(SoftMarkerBrushProperty, value); } public IBrush? HardMarkerBrush { get => GetValue(HardMarkerBrushProperty); set => SetValue(HardMarkerBrushProperty, value); } public IBrush? GateMarkerBrush { get => GetValue(GateMarkerBrushProperty); set => SetValue(GateMarkerBrushProperty, value); } public ICommand? CommitCommand { get => GetValue(CommitCommandProperty); set => SetValue(CommitCommandProperty, value); } private bool IsAdjustable => SoftPct is not null && HardPct is not null && GatePct is not null; // Custom hit test (point is in local coordinates): the control draws itself, so the whole // rectangle takes the pointer — not just the pixels the track happens to cover. public bool HitTest(Point point) => new Rect(Bounds.Size).Contains(point); public override void Render(DrawingContext context) { var width = Bounds.Width; var height = Bounds.Height; if (width <= 0 || height <= 0) return; var top = Math.Max(0, (height - TrackHeightPx) / 2); var trackHeight = Math.Min(TrackHeightPx, height); var radius = trackHeight / 2; // Transparent full-bounds fill keeps the grab area the whole control, not just the track. context.FillRectangle(Brushes.Transparent, new Rect(0, 0, width, height)); if (TrackBrush is { } track) context.DrawRectangle(track, null, new RoundedRect(new Rect(0, top, width, trackHeight), radius)); var fillWidth = width * Math.Clamp(Percent, 0, 100) / 100.0; var fill = IsWarn ? WarnFillBrush ?? FillBrush : FillBrush; if (fillWidth > 0 && fill is not null) context.DrawRectangle(fill, null, new RoundedRect(new Rect(0, top, fillWidth, trackHeight), radius)); DrawMarker(context, SoftPct, SoftMarkerBrush, width, height); DrawMarker(context, HardPct, HardMarkerBrush, width, height); DrawMarker(context, GatePct, GateMarkerBrush, width, height); } private static void DrawMarker(DrawingContext context, int? percent, IBrush? brush, double width, double height) { if (percent is not { } value || brush is null) return; var x = Math.Clamp(width * Math.Clamp(value, 0, 100) / 100.0 - MarkerWidthPx / 2, 0, Math.Max(0, width - MarkerWidthPx)); context.FillRectangle(brush, new Rect(x, 0, MarkerWidthPx, height)); } protected override void OnPointerPressed(PointerPressedEventArgs e) { base.OnPointerPressed(e); if (!IsAdjustable) return; var percent = PercentAt(e.GetPosition(this).X); _dragging = UsageThresholdDrag.Nearest( SoftPct!.Value, HardPct!.Value, GatePct!.Value, percent, GrabTolerancePercent()); if (_dragging is null) return; e.Pointer.Capture(this); ApplyDrag(_dragging.Value, percent); e.Handled = true; } protected override void OnPointerMoved(PointerEventArgs e) { base.OnPointerMoved(e); if (!IsAdjustable) return; var percent = PercentAt(e.GetPosition(this).X); if (_dragging is { } stage) { ApplyDrag(stage, percent); e.Handled = true; return; } var hover = UsageThresholdDrag.Nearest( SoftPct!.Value, HardPct!.Value, GatePct!.Value, percent, GrabTolerancePercent()); Cursor = new Cursor(hover is null ? StandardCursorType.Arrow : StandardCursorType.SizeWestEast); } protected override void OnPointerReleased(PointerReleasedEventArgs e) { base.OnPointerReleased(e); if (_dragging is null) return; _dragging = null; e.Pointer.Capture(null); e.Handled = true; if (CommitCommand is { } command && command.CanExecute(null)) command.Execute(null); } private void ApplyDrag(UsageThresholdDrag.Stage stage, double percent) { var (soft, hard, gate) = UsageThresholdDrag.Apply( SoftPct!.Value, HardPct!.Value, GatePct!.Value, stage, percent); SoftPct = soft; HardPct = hard; GatePct = gate; } private double PercentAt(double x) => Bounds.Width <= 0 ? 0 : Math.Clamp(x / Bounds.Width * 100.0, 0, 100); private double GrabTolerancePercent() => Bounds.Width <= 0 ? 0 : GrabRadiusPx / Bounds.Width * 100.0; }