From 2be8ba5d6658b06c233f1e65d39c1c7a6cbd3606 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 30 Sep 2026 10:25:17 +0200 Subject: [PATCH] feat(ui): folders slide open and closed, rows below follow --- src/ClaudeDo.Ui/Design/IslandStyles.axaml | 13 --- .../Islands/ListFolderNavItemViewModel.cs | 2 - .../Islands/ListsIslandViewModel.cs | 1 - src/ClaudeDo.Ui/Views/Controls/ExpandPanel.cs | 99 +++++++++++++++++++ .../Views/Islands/ListsIslandView.axaml | 16 +-- 5 files changed, 108 insertions(+), 23 deletions(-) create mode 100644 src/ClaudeDo.Ui/Views/Controls/ExpandPanel.cs diff --git a/src/ClaudeDo.Ui/Design/IslandStyles.axaml b/src/ClaudeDo.Ui/Design/IslandStyles.axaml index cfb61d9a..ce7348e8 100644 --- a/src/ClaudeDo.Ui/Design/IslandStyles.axaml +++ b/src/ClaudeDo.Ui/Design/IslandStyles.axaml @@ -867,19 +867,6 @@ - - - diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListFolderNavItemViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListFolderNavItemViewModel.cs index 98121452..39cf4941 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListFolderNavItemViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListFolderNavItemViewModel.cs @@ -18,8 +18,6 @@ public sealed partial class ListFolderNavItemViewModel : ViewModelBase [ObservableProperty] private string _iconKey = "Folder"; /// Hex colour for name + guide line; null = theme default. [ObservableProperty] private string? _color; - /// Set on the first user toggle so the expand animation skips folders that load already open. - [ObservableProperty] private bool _animateExpand; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowCount))] private bool _isCollapsed; diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs index dc9afa95..d327b1c6 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs @@ -730,7 +730,6 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable private void ToggleFolder(ListFolderNavItemViewModel? folder) { if (folder is null) return; - folder.AnimateExpand = true; folder.IsCollapsed = !folder.IsCollapsed; if (_folderCollapseCts.TryGetValue(folder.Id, out var previous)) previous.Cancel(); var cts = new CancellationTokenSource(); diff --git a/src/ClaudeDo.Ui/Views/Controls/ExpandPanel.cs b/src/ClaudeDo.Ui/Views/Controls/ExpandPanel.cs new file mode 100644 index 00000000..824ccce5 --- /dev/null +++ b/src/ClaudeDo.Ui/Views/Controls/ExpandPanel.cs @@ -0,0 +1,99 @@ +using System; +using System.Threading; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; +using Avalonia.Styling; + +namespace ClaudeDo.Ui.Views.Controls; + +/// +/// Accordion host: animates its height between 0 and the child's natural height, so content +/// below slides instead of jumping. The first value (from the binding) applies without animation. +/// +public sealed class ExpandPanel : Decorator +{ + private static readonly TimeSpan Duration = TimeSpan.FromMilliseconds(180); + + public static readonly StyledProperty IsExpandedProperty = + AvaloniaProperty.Register(nameof(IsExpanded)); + + /// 0 = collapsed, 1 = fully open; drives the measured height. + public static readonly StyledProperty ProgressProperty = + AvaloniaProperty.Register(nameof(Progress)); + + private CancellationTokenSource? _cts; + private bool _arranged; + + static ExpandPanel() + { + AffectsMeasure(ProgressProperty); + ClipToBoundsProperty.OverrideDefaultValue(true); + } + + /// Whether the content is shown. + public bool IsExpanded + { + get => GetValue(IsExpandedProperty); + set => SetValue(IsExpandedProperty, value); + } + + /// Current open fraction, animated between 0 and 1. + public double Progress + { + get => GetValue(ProgressProperty); + set => SetValue(ProgressProperty, value); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == IsExpandedProperty) + Animate(IsExpanded ? 1 : 0); + else if (change.Property == ProgressProperty && Child is { } child) + child.IsVisible = Progress > 0; + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + if (Child is { } child) child.IsVisible = Progress > 0; + } + + private void Animate(double to) + { + _cts?.Cancel(); + var from = Progress; + Progress = to; + if (!_arranged || from == to) return; + + _cts = new CancellationTokenSource(); + var animation = new Animation + { + Duration = Duration, + Easing = new CubicEaseOut(), + Children = + { + new KeyFrame { Cue = new Cue(0), Setters = { new Setter(ProgressProperty, from) } }, + new KeyFrame { Cue = new Cue(1), Setters = { new Setter(ProgressProperty, to) } }, + }, + }; + _ = animation.RunAsync(this, _cts.Token); + } + + protected override Size MeasureOverride(Size availableSize) + { + if (Child is not { } child) return default; + child.Measure(availableSize.WithHeight(double.PositiveInfinity)); + return new Size(child.DesiredSize.Width, child.DesiredSize.Height * Progress); + } + + protected override Size ArrangeOverride(Size finalSize) + { + _arranged = true; + // Full natural height, top-aligned; ClipToBounds cuts off what isn't revealed yet. + Child?.Arrange(new Rect(0, 0, finalSize.Width, Child.DesiredSize.Height)); + return finalSize; + } +} diff --git a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml index 077037d5..e0c89532 100644 --- a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml @@ -3,6 +3,7 @@ xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands" xmlns:converters="using:ClaudeDo.Ui.Converters" xmlns:loc="using:ClaudeDo.Ui.Localization" + xmlns:ctl="using:ClaudeDo.Ui.Views.Controls" x:Class="ClaudeDo.Ui.Views.Islands.ListsIslandView" x:DataType="vm:ListsIslandViewModel"> - - - - + + + + + +