Skip to content

Commit af37e17

Browse files
authored
fix: DISASM view is too slow (#2168)
1 parent 424217c commit af37e17

9 files changed

Lines changed: 124 additions & 94 deletions

src/Spice86/App.axaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,12 @@
1212
<semi:DockSemiTheme Locale="en-US" />
1313
</Application.Styles>
1414

15+
<Application.Resources>
16+
<ResourceDictionary>
17+
<ResourceDictionary.MergedDictionaries>
18+
<ResourceInclude Source="avares://Spice86/Views/Styles/DisassemblyResources.axaml" />
19+
</ResourceDictionary.MergedDictionaries>
20+
</ResourceDictionary>
21+
</Application.Resources>
22+
1523
</Application>

src/Spice86/ViewModels/DisassemblyViewModel.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,9 @@ private void EvaluateOperands() {
396396

397397
/// <inheritdoc/>
398398
public void OnVisibleRangeChanged(int firstVisibleIndex, int lastVisibleIndex) {
399+
if (_firstVisibleIndex == firstVisibleIndex && _lastVisibleIndex == lastVisibleIndex) {
400+
return;
401+
}
399402
_firstVisibleIndex = firstVisibleIndex;
400403
_lastVisibleIndex = lastVisibleIndex;
401404
if (_pauseHandler.IsPaused) {

src/Spice86/ViewModels/Services/ExpressionEvaluationService.cs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,24 @@ namespace Spice86.ViewModels.Services;
1818
public class ExpressionEvaluationService {
1919
private readonly BreakpointConditionCompiler _compiler;
2020

21+
// Cache compiled Func<uint> by expression string.
22+
// Expression.Compile() invokes the JIT and can take 2-10ms per call; caching makes
23+
// repeated evaluations (across instructions and scroll events) effectively free.
24+
private readonly Dictionary<string, Func<uint>> _compiledValueCache = new();
25+
2126
public ExpressionEvaluationService(State state, IMemory memory) {
2227
_compiler = new BreakpointConditionCompiler(state, memory);
2328
}
2429

30+
private Func<uint> GetOrCompileValue(string expression) {
31+
if (_compiledValueCache.TryGetValue(expression, out Func<uint>? cached)) {
32+
return cached;
33+
}
34+
Func<uint> compiled = _compiler.CompileValue(expression);
35+
_compiledValueCache[expression] = compiled;
36+
return compiled;
37+
}
38+
2539
/// <summary>
2640
/// Evaluates all register and memory operands of an instruction and returns syntax-colored segments.
2741
/// Immediate and branch operands are skipped since they are already visible in the disassembly text.
@@ -58,7 +72,7 @@ private void EvaluateRegisterOperand(List<FormattedTextToken> segments, Instruct
5872
if (expression == null) {
5973
return;
6074
}
61-
uint value = _compiler.CompileValue(expression)();
75+
uint value = GetOrCompileValue(expression)();
6276
if (segments.Count > 0) {
6377
AddSeparator(segments);
6478
}
@@ -75,7 +89,7 @@ private void EvaluateMemoryOperand(List<FormattedTextToken> segments, Instructio
7589

7690
private void EvaluateLeaOperand(List<FormattedTextToken> segments, Instruction instruction) {
7791
string addressExpression = BuildAddressExpressionCore(instruction);
78-
uint value = _compiler.CompileValue(addressExpression)();
92+
uint value = GetOrCompileValue(addressExpression)();
7993
if (segments.Count > 0) {
8094
AddSeparator(segments);
8195
}
@@ -87,7 +101,7 @@ private void EvaluateIndirectMemoryOperand(List<FormattedTextToken> segments, In
87101
if (memoryExpression == null) {
88102
return;
89103
}
90-
uint value = _compiler.CompileValue(memoryExpression)();
104+
uint value = GetOrCompileValue(memoryExpression)();
91105
if (segments.Count > 0) {
92106
AddSeparator(segments);
93107
}

src/Spice86/Views/Behaviors/DisassemblyScrollBehavior.cs

Lines changed: 6 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,13 @@ namespace Spice86.Views.Behaviors;
88
using Spice86.Shared.Emulator.Memory;
99
using Spice86.ViewModels;
1010

11-
using System.Timers;
11+
1212

1313
/// <summary>
1414
/// Attached behavior for handling scrolling in the disassembly view.
1515
/// This behavior encapsulates the UI-specific scrolling logic to improve separation of concerns.
1616
/// </summary>
1717
public class DisassemblyScrollBehavior {
18-
private const int AnimationFramesPerSecond = 60;
19-
2018
// Attached property for enabling the behavior
2119
public static readonly AttachedProperty<bool> IsEnabledProperty = AvaloniaProperty.RegisterAttached<DisassemblyScrollBehavior, Control, bool>("IsEnabled");
2220

@@ -26,9 +24,6 @@ public class DisassemblyScrollBehavior {
2624
// Static field to track if we're currently processing a scroll operation
2725
private static bool _isScrollingInProgress;
2826

29-
// Configuration properties for smooth scrolling
30-
private static readonly TimeSpan AnimationDuration = TimeSpan.FromMilliseconds(250);
31-
3227
// Static constructor to register property changed handlers
3328
static DisassemblyScrollBehavior() {
3429
IsEnabledProperty.Changed.AddClassHandler<Control>(OnIsEnabledChanged);
@@ -141,7 +136,7 @@ public static void ScrollToAddress(ListBox listBox, uint targetAddress) {
141136
}
142137

143138
// Scroll to the target item
144-
Dispatcher.UIThread.Post(() => ScrollToPosition(listBox, scrollViewer, targetIndex), DispatcherPriority.Loaded);
139+
Dispatcher.UIThread.Post(() => ScrollToPosition(listBox, scrollViewer, targetIndex), DispatcherPriority.Normal);
145140
}
146141
} finally {
147142
// Release the lock
@@ -188,55 +183,9 @@ private static void ScrollToPosition(ListBox listBox, ScrollViewer scrollViewer,
188183
double maxOffset = Math.Max(0, scrollViewer.Extent.Height - viewportHeight);
189184
double finalOffset = Math.Min(targetOffset, maxOffset);
190185

191-
// Use smooth scrolling with configurable parameters
192-
AnimateSmoothScroll(scrollViewer, finalOffset);
193-
}
194-
195-
private static void AnimateSmoothScroll(ScrollViewer scrollViewer, double targetOffsetY) {
196-
// Get the current offset
197-
double startOffsetY = scrollViewer.Offset.Y;
198-
199-
// If we're already at the target, no need to animate
200-
if (Math.Abs(startOffsetY - targetOffsetY) < 0.1) {
201-
return;
202-
}
203-
204-
// Ensure we're on the UI thread
205-
if (!Dispatcher.UIThread.CheckAccess()) {
206-
Dispatcher.UIThread.Post(() => AnimateSmoothScroll(scrollViewer, targetOffsetY));
207-
208-
return;
209-
}
210-
211-
// Use a timer to animate the scroll
212-
var timer = new Timer(1000.0 / AnimationFramesPerSecond);
213-
int currentFrame = 0;
214-
int totalFrames = (int)(AnimationDuration.TotalMilliseconds / (1000.0 / AnimationFramesPerSecond));
215-
216-
timer.Elapsed += (_, _) => {
217-
// Calculate progress (0.0 to 1.0)
218-
currentFrame++;
219-
double progress = Math.Min(1.0, currentFrame / (double)totalFrames);
220-
221-
// Apply easing function
222-
double easedProgress = progress < 0.5 ? 4 * progress * progress * progress : 1 - Math.Pow(-2 * progress + 2, 3) / 2;
223-
224-
// Calculate new offset
225-
double newOffsetY = startOffsetY + (targetOffsetY - startOffsetY) * easedProgress;
226-
227-
// Apply the new offset on the UI thread
228-
Dispatcher.UIThread.Post(() => {
229-
scrollViewer.Offset = new Vector(scrollViewer.Offset.X, newOffsetY);
230-
});
231-
232-
// Stop the timer when animation is complete
233-
if (progress >= 1.0) {
234-
timer.Stop();
235-
timer.Dispose();
236-
}
237-
};
238-
239-
// Start the timer
240-
timer.Start();
186+
// Instant scroll: set the offset directly.
187+
// Smooth animation was removed because each render frame of SelectableTextBlock rows with
188+
// inline Runs is too expensive on Linux (Skia/FreeType), turning a 250ms animation into 20s.
189+
scrollViewer.Offset = new Vector(scrollViewer.Offset.X, finalOffset);
241190
}
242191
}

src/Spice86/Views/Converters/FormattedTextOffsetsConverter.cs

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,30 @@ namespace Spice86.Views.Converters;
1010
using System;
1111
using System.Collections.Generic;
1212
using System.Globalization;
13+
using System.Runtime.CompilerServices;
1314

1415
/// <summary>
1516
/// Converts a list of <see cref="FormattedTextToken"/> to an <see cref="InlineCollection"/>.
17+
/// Results are cached per list instance so that container recycling in the virtualized disassembly
18+
/// list reuses the same <see cref="InlineCollection"/> objects instead of rebuilding them on every
19+
/// scroll. The cache is invalidated automatically when the application theme changes.
1620
/// </summary>
1721
public class FormattedTextOffsetsConverter : IValueConverter {
22+
private sealed class CachedEntry {
23+
public CachedEntry(InlineCollection inlines, int version) {
24+
Inlines = inlines;
25+
Version = version;
26+
}
27+
28+
public InlineCollection Inlines { get; }
29+
public int Version { get; }
30+
}
31+
32+
// Keyed by List<FormattedTextToken> instance identity (ConditionalWeakTable uses reference equality).
33+
// Each DebuggerLineViewModel owns exactly one List instance for DisassemblyTextOffsets, so this
34+
// gives a per-instruction cache with correct lifetime (entry is collected when the VM is GC'd).
35+
private static readonly ConditionalWeakTable<List<FormattedTextToken>, CachedEntry> _cache = new();
36+
1837
/// <summary>
1938
/// Converts a list of <see cref="FormattedTextToken"/> to an <see cref="InlineCollection"/>.
2039
/// </summary>
@@ -27,17 +46,24 @@ public class FormattedTextOffsetsConverter : IValueConverter {
2746
return new InlineCollection();
2847
}
2948

30-
InlineCollection inlines = new();
49+
int currentVersion = FormatterTextKindToBrushConverter.BrushCacheVersion;
50+
if (_cache.TryGetValue(textOffsets, out CachedEntry? cached) && cached.Version == currentVersion) {
51+
return cached.Inlines;
52+
}
53+
54+
InlineCollection inlines = BuildInlines(textOffsets);
55+
_cache.AddOrUpdate(textOffsets, new CachedEntry(inlines, currentVersion));
56+
return inlines;
57+
}
3158

59+
private static InlineCollection BuildInlines(List<FormattedTextToken> textOffsets) {
60+
InlineCollection inlines = new();
3261
foreach (FormattedTextToken textOffset in textOffsets) {
33-
Run run = new() {
62+
inlines.Add(new Run {
3463
Text = textOffset.Text,
35-
};
36-
run.Bind(TextElement.ForegroundProperty,
37-
FormatterTextKindToBrushConverter.GetDynamicResourceExtension(textOffset.Kind));
38-
inlines.Add(run);
64+
Foreground = FormatterTextKindToBrushConverter.GetBrush(textOffset.Kind),
65+
});
3966
}
40-
4167
return inlines;
4268
}
4369

src/Spice86/Views/Converters/FormatterTextKindToBrushConverter.cs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
namespace Spice86.Views.Converters;
22

3+
using Avalonia;
34
using Avalonia.Markup.Xaml.MarkupExtensions;
5+
using Avalonia.Media;
6+
using Avalonia.Styling;
47

58
using Iced.Intel;
69

@@ -30,6 +33,43 @@ public static class FormatterTextKindToBrushConverter {
3033
{FormatterTextKind.Text, "DisassemblyTextBrush"},
3134
};
3235

36+
// Cache of resolved brushes to avoid per-Run dynamic resource subscriptions.
37+
// Invalidated when the theme changes.
38+
private static readonly Dictionary<FormatterTextKind, IBrush> BrushCache = new();
39+
private static ThemeVariant? _cachedTheme;
40+
41+
/// <summary>
42+
/// Incremented each time the brush cache is cleared due to a theme change.
43+
/// Used by <see cref="FormattedTextOffsetsConverter"/> to invalidate its own cache.
44+
/// </summary>
45+
internal static int BrushCacheVersion { get; private set; }
46+
47+
/// <summary>
48+
/// Gets a brush for the specified formatter text kind, resolved from application resources.
49+
/// The result is cached per theme variant to avoid repeated lookups.
50+
/// </summary>
51+
public static IBrush GetBrush(FormatterTextKind kind) {
52+
if (Application.Current is null) {
53+
return Brushes.White;
54+
}
55+
ThemeVariant currentTheme = Application.Current.ActualThemeVariant;
56+
if (currentTheme != _cachedTheme) {
57+
BrushCache.Clear();
58+
BrushCacheVersion++;
59+
_cachedTheme = currentTheme;
60+
}
61+
if (BrushCache.TryGetValue(kind, out IBrush? cached)) {
62+
return cached;
63+
}
64+
if (ResourceKeys.TryGetValue(kind, out string? resourceKey) &&
65+
Application.Current.TryGetResource(resourceKey, currentTheme, out object? resource) &&
66+
resource is IBrush brush) {
67+
BrushCache[kind] = brush;
68+
return brush;
69+
}
70+
return Brushes.White;
71+
}
72+
3373
/// <summary>
3474
/// Gets a resource for the specified formatter text kind.
3575
/// </summary>

src/Spice86/Views/DisassemblyView.axaml

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,6 @@
1717
x:DataType="viewModels:IDisassemblyViewModel">
1818
<UserControl.Resources>
1919
<ResourceDictionary>
20-
<ResourceDictionary.MergedDictionaries>
21-
<ResourceInclude Source="avares://Spice86/Views/Styles/DisassemblyResources.axaml" />
22-
</ResourceDictionary.MergedDictionaries>
2320
<converters:SegmentedAddressConverter x:Key="SegmentedAddressConverter" />
2421
<converters:FormattedTextOffsetsConverter x:Key="FormattedTextOffsetsConverter" />
2522
<converters:BreakpointColorConverter x:Key="BreakpointColorConverter" />
@@ -129,7 +126,6 @@
129126
ScrollViewer.HorizontalScrollBarVisibility="Auto"
130127
ScrollViewer.VerticalScrollBarVisibility="Visible"
131128
SelectedItem="{Binding SelectedDebuggerLine}"
132-
Grid.IsSharedSizeScope="True"
133129
behaviors:DisassemblyScrollBehavior.IsEnabled="True"
134130
behaviors:DisassemblyScrollBehavior.TargetAddress="{Binding CurrentInstructionAddress}">
135131
<ListBox.ContextMenu>
@@ -178,24 +174,20 @@
178174
behaviors:InstructionPointerBehavior.IsEnabled="True">
179175
<ContentControl.Styles>
180176
<Style Selector="SelectableTextBlock">
181-
<Setter Property="Foreground"
182-
Value="{Binding RelativeSource={RelativeSource AncestorType=ContentControl}, Path=Foreground}" />
183-
<Setter Property="Background"
184-
Value="{Binding RelativeSource={RelativeSource AncestorType=ContentControl}, Path=Background}" />
185177
<Setter Property="behaviors:UseParentListBoxContextMenuBehavior.UseParentContextMenu" Value="True" />
186178
</Style>
187179
</ContentControl.Styles>
188180

189181
<Grid Margin="0">
190182
<Grid.ColumnDefinitions>
191-
<ColumnDefinition Width="Auto" SharedSizeGroup="Breakpoint" />
192-
<ColumnDefinition Width="Auto" SharedSizeGroup="JumpLines" />
193-
<ColumnDefinition Width="Auto" SharedSizeGroup="Address" />
194-
<ColumnDefinition Width="Auto" SharedSizeGroup="Bytes" />
183+
<ColumnDefinition Width="Auto" />
184+
<ColumnDefinition Width="Auto" />
185+
<ColumnDefinition Width="Auto" />
186+
<ColumnDefinition Width="Auto" />
195187
<ColumnDefinition Width="*" />
196-
<ColumnDefinition Width="Auto" SharedSizeGroup="EvalOperands" />
197-
<ColumnDefinition Width="Auto" SharedSizeGroup="BranchTarget" />
198-
<ColumnDefinition Width="Auto" SharedSizeGroup="Function" />
188+
<ColumnDefinition Width="Auto" />
189+
<ColumnDefinition Width="Auto" />
190+
<ColumnDefinition Width="Auto" />
199191
</Grid.ColumnDefinitions>
200192
<!-- Breakpoint indicator -->
201193
<Border Grid.Column="0"

src/Spice86/Views/DisassemblyView.axaml.cs

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public partial class DisassemblyView : UserControl {
1616
private IDisassemblyViewModel? _viewModel;
1717
private bool _isAttachedToVisualTree;
1818
private ScrollViewer? _scrollViewer;
19+
private ListBox? _listBox;
1920

2021
/// <summary>
2122
/// Initializes a new instance of the <see cref="DisassemblyView"/> class.
@@ -68,14 +69,14 @@ private void DisassemblyView_DetachedFromVisualTree(object? sender, Avalonia.Vis
6869
}
6970

7071
private void SubscribeToScrollViewer() {
71-
ListBox? listBox = this.FindControl<ListBox>("DisassemblyListBox");
72-
if (listBox == null) {
72+
_listBox = this.FindControl<ListBox>("DisassemblyListBox");
73+
if (_listBox == null) {
7374
return;
7475
}
75-
_scrollViewer = listBox.FindDescendantOfType<ScrollViewer>();
76+
_scrollViewer = _listBox.FindDescendantOfType<ScrollViewer>();
7677
if (_scrollViewer != null) {
7778
_scrollViewer.ScrollChanged += OnScrollChanged;
78-
ReportVisibleRange(_scrollViewer, listBox.ItemCount);
79+
ReportVisibleRange(_scrollViewer, _listBox.ItemCount);
7980
}
8081
}
8182

@@ -84,17 +85,14 @@ private void UnsubscribeFromScrollViewer() {
8485
_scrollViewer.ScrollChanged -= OnScrollChanged;
8586
_scrollViewer = null;
8687
}
88+
_listBox = null;
8789
}
8890

8991
private void OnScrollChanged(object? sender, ScrollChangedEventArgs e) {
90-
if (sender is not ScrollViewer scrollViewer || _viewModel == null) {
92+
if (sender is not ScrollViewer scrollViewer || _viewModel == null || _listBox == null) {
9193
return;
9294
}
93-
ListBox? listBox = this.FindControl<ListBox>("DisassemblyListBox");
94-
if (listBox == null) {
95-
return;
96-
}
97-
ReportVisibleRange(scrollViewer, listBox.ItemCount);
95+
ReportVisibleRange(scrollViewer, _listBox.ItemCount);
9896
}
9997

10098
private void ReportVisibleRange(ScrollViewer scrollViewer, int itemCount) {

0 commit comments

Comments
 (0)