251 lines
8.8 KiB
C#
251 lines
8.8 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="UsageThresholdDrag"/> so it stays testable.
|
|
/// Values are written back through TwoWay bindings while dragging; <see cref="CommitCommand"/>
|
|
/// 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.
|
|
/// </summary>
|
|
public sealed class UsageGaugeBar : Control, ICustomHitTest
|
|
{
|
|
/// <summary>How close the pointer has to be to grab a marker.</summary>
|
|
private const double GrabRadiusPx = 12;
|
|
|
|
private const double TrackHeightPx = 10;
|
|
private const double MarkerWidthPx = 2;
|
|
|
|
public static readonly StyledProperty<double> PercentProperty =
|
|
AvaloniaProperty.Register<UsageGaugeBar, double>(nameof(Percent));
|
|
|
|
public static readonly StyledProperty<bool> IsWarnProperty =
|
|
AvaloniaProperty.Register<UsageGaugeBar, bool>(nameof(IsWarn));
|
|
|
|
public static readonly StyledProperty<int?> SoftPctProperty =
|
|
AvaloniaProperty.Register<UsageGaugeBar, int?>(
|
|
nameof(SoftPct), defaultBindingMode: BindingMode.TwoWay);
|
|
|
|
public static readonly StyledProperty<int?> HardPctProperty =
|
|
AvaloniaProperty.Register<UsageGaugeBar, int?>(
|
|
nameof(HardPct), defaultBindingMode: BindingMode.TwoWay);
|
|
|
|
public static readonly StyledProperty<int?> GatePctProperty =
|
|
AvaloniaProperty.Register<UsageGaugeBar, int?>(
|
|
nameof(GatePct), defaultBindingMode: BindingMode.TwoWay);
|
|
|
|
public static readonly StyledProperty<IBrush?> TrackBrushProperty =
|
|
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(TrackBrush));
|
|
|
|
public static readonly StyledProperty<IBrush?> FillBrushProperty =
|
|
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(FillBrush));
|
|
|
|
public static readonly StyledProperty<IBrush?> WarnFillBrushProperty =
|
|
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(WarnFillBrush));
|
|
|
|
public static readonly StyledProperty<IBrush?> SoftMarkerBrushProperty =
|
|
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(SoftMarkerBrush));
|
|
|
|
public static readonly StyledProperty<IBrush?> HardMarkerBrushProperty =
|
|
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(HardMarkerBrush));
|
|
|
|
public static readonly StyledProperty<IBrush?> GateMarkerBrushProperty =
|
|
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(GateMarkerBrush));
|
|
|
|
public static readonly StyledProperty<ICommand?> CommitCommandProperty =
|
|
AvaloniaProperty.Register<UsageGaugeBar, ICommand?>(nameof(CommitCommand));
|
|
|
|
static UsageGaugeBar()
|
|
{
|
|
AffectsRender<UsageGaugeBar>(
|
|
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;
|
|
}
|