Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/LageBuch.App.Shared/Theme/Styles.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,33 @@
<Style Selector="TabControl">
<Setter Property="TabStripPlacement" Value="Left" />
<Setter Property="Padding" Value="0" />
<!-- Avalonia's default Left-placement items panel is a vertical WrapPanel with no scroll
wrapper at all: once the rail runs out of height it wraps into a second column overlapping
the first two tabs with the last two (reported live on a short window — WASSERFÖRDERUNG
rendered beside AUFBAU, ABBAU beside ETB), and forcing a single-column panel alone just
turns that into a silent, unreachable clip instead (confirmed via a headless test: no
ScrollViewer anywhere wraps PART_ItemsPresenter, so a short window drops the last tabs off
the bottom edge with no way back). This is a single-column "dispatch rail" by design (fixed
172px-wide TabItems below) with only one placement ever used in this app, so replace the
whole template with a purpose-built one: single-column items panel, wrapped in its own
ScrollViewer, laid out beside the content host. -->
<Setter Property="ItemsPanel">
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical" />
</ItemsPanelTemplate>
</Setter>
<Setter Property="Template">
<ControlTemplate>
<Grid ColumnDefinitions="Auto,*">
<ScrollViewer Grid.Column="0" HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<ItemsPresenter Name="PART_ItemsPresenter" ItemsPanel="{TemplateBinding ItemsPanel}" />
</ScrollViewer>
<ContentPresenter Grid.Column="1" Name="PART_SelectedContentHost"
Content="{TemplateBinding SelectedContent}"
ContentTemplate="{TemplateBinding SelectedContentTemplate}" />
</Grid>
</ControlTemplate>
</Setter>
</Style>
<Style Selector="TabControl /template/ ItemsPresenter#PART_ItemsPresenter">
<Setter Property="Margin" Value="0" />
Expand Down
81 changes: 81 additions & 0 deletions tests/LageBuch.Acceptance.Tests/ModuleTabStripLayoutTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using Avalonia.VisualTree;
using LageBuch.App.Shared.Views;
using LageBuch.AppLogic;
using LageBuch.AppLogic.Services;
using LageBuch.AppLogic.ViewModels;

namespace LageBuch.Acceptance.Tests;

// The left "dispatch sidebar" module tab strip (AUFBAU, ETB, ..., WASSERFÖRDERUNG, ABBAU),
// TabStripPlacement="Left" per Theme/Styles.axaml. On a short window, the last tabs wrapped into
// a second column overlapping the first ones instead of scrolling -- reported live, screenshot
// showed WASSERFÖRDERUNG rendered directly beside AUFBAU and ABBAU beside ETB.
public class ModuleTabStripLayoutTests
{
// CanHost=true so the header includes the "IM NETZWERK FREIGEBEN" row, matching the reported
// screenshot exactly (a NoopIncidentHostController hides that row, understating header height).
private sealed class HostableController : IIncidentHostController
{
public bool CanHost => true;
public bool IsHosting => false;
public string? ShareHint => null;
public string? SharePin => null;
public Task StartAsync(LocalIncidentSession session) => Task.CompletedTask;
public Task StopAsync() => Task.CompletedTask;
}

[AvaloniaFact]
public void Tabs_stay_in_a_single_column_top_to_bottom_on_a_short_window()
{
var clock = new FixedClock();
var session = LocalIncidentSession.StartNew(new FakeStore(), clock,
new LageBuch.Domain.SessionOperator("Müller", "FFB 12/1"), "/x.fwincident",
Array.Empty<(string, bool)>(), Array.Empty<(string, bool)>());
var vm = new IncidentWorkspaceViewModel(session, clock, new ManualTicker(),
WorkspaceRenderHelper.MasterData(), new FakeDialogs(), new NoopAlarmService(),
new HostableController());

// Matches the reported "relative small screen": a modest laptop-class height, well under
// what 11 fixed-MinHeight-50 TabItems need stacked below the full header.
var window = new Window { Content = new IncidentWorkspaceView { DataContext = vm }, Width = 1366, Height = 550 };
window.Show();
Dispatcher.UIThread.RunJobs();

var tabItems = window.GetVisualDescendants().OfType<TabItem>()
.Select(t => t.TranslatePoint(new Point(0, 0), window)!.Value.Y)
.ToList();

Assert.True(tabItems.Count >= 10, $"Expected all module tabs to be found, got {tabItems.Count}.");
for (var i = 1; i < tabItems.Count; i++)
{
Assert.True(tabItems[i] > tabItems[i - 1],
$"Tab at index {i} (y={tabItems[i]}) is not below the previous tab (y={tabItems[i - 1]}) -- " +
"the tab strip wrapped into a second column instead of staying single-column.");
}

// Not wrapping alone isn't enough -- the last tabs must still be reachable by scrolling
// the rail into view, not merely pushed off-screen with no way back.
var lastTab = window.GetVisualDescendants().OfType<TabItem>().Last(); // ABBAU
var railScrollViewer = window.GetVisualDescendants().OfType<ScrollViewer>()
.FirstOrDefault(sv => sv.GetVisualDescendants().OfType<TabItem>().Any());
Assert.NotNull(railScrollViewer);
Assert.True(railScrollViewer!.Extent.Height > railScrollViewer.Viewport.Height,
"Test premise: the rail must actually need scrolling at this window height " +
$"(extent={railScrollViewer.Extent.Height}, viewport={railScrollViewer.Viewport.Height}).");

railScrollViewer.Offset = new Vector(0, railScrollViewer.Extent.Height - railScrollViewer.Viewport.Height);
Dispatcher.UIThread.RunJobs();
var lastTabY = lastTab.TranslatePoint(new Point(0, 0), window)!.Value.Y;
Assert.InRange(lastTabY, 0, window.Bounds.Height);

var dir = Path.Combine(Path.GetTempPath(), "lagebuch-shots");
Directory.CreateDirectory(dir);
using var frame = window.CaptureRenderedFrame()!;
frame.SavePng(Path.Combine(dir, "module-tab-strip-small-screen-fixed.png"));
}
}
Loading