Merge branch 'main' into worktree-diff-side-by-side
# Conflicts: # src/ClaudeDo.Ui/CLAUDE.md
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user