25 private const int DefaultTabHeight = 32;
26 private const int DefaultPreferredTabWidth = 132;
27 private const float DefaultReorderDragThresholdPx = 6f;
29 private readonly List<TabPageWidget> _tabs = [];
30 private readonly Dictionary<TabPageWidget, TabHeaderWidget> _headers = [];
32 private readonly
View? _view;
36 private int _selectedIndex = -1;
37 private int _tabRowCount = 1;
38 private int _displayedRowCount = 1;
39 private int _displayedColumnCount = 1;
40 private int _tabHeight = DefaultTabHeight;
41 private int _preferredTabWidth = DefaultPreferredTabWidth;
42 private int _baseZOrder;
43 private float _tabReorderDragThresholdPx = DefaultReorderDragThresholdPx;
44 private bool _autoExpandRows;
45 private bool _isTabReorderingEnabled;
46 private bool _isShown;
47 private bool _disposed;
49 private Color _normalTabColor = Color.FromArgb(255, 60, 60, 68);
50 private Color _hoverTabColor = Color.FromArgb(255, 78, 78, 88);
51 private Color _pressedTabColor = Color.FromArgb(255, 42, 42, 48);
52 private Color _selectedTabColor = Color.FromArgb(255, 48, 89, 142);
53 private Color _tabTextColor = Color.White;
75 string? nickname =
null)
78 ArgumentNullException.ThrowIfNull(view);
96 string? nickname =
null)
99 ArgumentNullException.ThrowIfNull(sceneLayer);
101 _sceneLayer = sceneLayer;
115 return new Rectangle(
116 (
int)MathF.Round(position.X),
117 (
int)MathF.Round(position.Y),
144 public IReadOnlyList<TabPageWidget>
Tabs => _tabs.AsReadOnly();
155 ? _tabs[_selectedIndex]
167 throw new ArgumentOutOfRangeException(nameof(value),
"The tab height must be at least 20 pixels.");
169 if (_tabHeight == value)
190 throw new ArgumentOutOfRangeException(nameof(value),
"At least one tab row is required.");
192 if (_tabRowCount == value)
195 _tabRowCount = value;
210 get => _autoExpandRows;
213 if (_autoExpandRows == value)
216 _autoExpandRows = value;
226 get => _preferredTabWidth;
230 throw new ArgumentOutOfRangeException(nameof(value),
"The preferred tab width must be at least 48 pixels.");
232 if (_preferredTabWidth == value)
235 _preferredTabWidth = value;
255 get => _isTabReorderingEnabled;
258 if (_isTabReorderingEnabled == value)
261 _isTabReorderingEnabled = value;
263 foreach (TabHeaderWidget header
in _headers.Values)
264 header.IsDragEnabled = value;
273 get => _tabReorderDragThresholdPx;
277 throw new ArgumentOutOfRangeException(nameof(value),
"The tab reordering threshold cannot be negative.");
279 if (Math.Abs(_tabReorderDragThresholdPx - value) <
float.Epsilon)
282 _tabReorderDragThresholdPx = value;
284 foreach (TabHeaderWidget header
in _headers.Values)
285 header.DragThresholdPx = value;
297 char? mnemonic =
null,
298 string? nickname =
null)
300 ObjectDisposedException.ThrowIf(_disposed,
this);
301 ValidateTitle(title);
302 ValidateMnemonic(mnemonic);
305 Rectangle initialContentBounds =
new(
316 ?
new TabHeaderWidget(
RenderSurfaceHost, _view!, initialHeaderBounds, title, $
"{page.Nickname}.header")
317 :
new TabHeaderWidget(
RenderSurfaceHost, _sceneLayer!, initialHeaderBounds, title, $
"{page.Nickname}.header");
319 page.TitleChanged += OnPageTitleChanged;
320 page.Disposing += OnPageDisposing;
321 header.Invoked += OnHeaderInvoked;
322 header.ReorderDragged += OnHeaderDragged;
323 header.ReorderDragEnded += OnHeaderDragEnded;
324 header.Disposing += OnHeaderDisposing;
329 _headers.Add(page, header);
331 Add(page, Vector2.Zero);
332 Add(header, Vector2.Zero);
334 if (_selectedIndex < 0)
338 UpdateSelectedTabVisuals();
339 UpdatePageVisibility();
351 bool dispose =
false)
353 ArgumentNullException.ThrowIfNull(page);
354 return RemoveTabCore(page, dispose, raiseSelectionChanged:
true);
364 ObjectDisposedException.ThrowIf(_disposed,
this);
366 if (index < 0 || index >= _tabs.Count)
367 throw new ArgumentOutOfRangeException(nameof(index));
369 if (_selectedIndex == index)
372 _selectedIndex = index;
373 UpdateSelectedTabVisuals();
374 UpdatePageVisibility();
387 ArgumentNullException.ThrowIfNull(page);
389 int index = _tabs.IndexOf(page);
391 throw new ArgumentException(
"The page is not owned by this tab control.", nameof(page));
405 ObjectDisposedException.ThrowIf(_disposed,
this);
407 if (fromIndex < 0 || fromIndex >= _tabs.Count)
408 throw new ArgumentOutOfRangeException(nameof(fromIndex));
410 if (toIndex < 0 || toIndex >= _tabs.Count)
411 throw new ArgumentOutOfRangeException(nameof(toIndex));
413 if (fromIndex == toIndex)
419 _tabs.RemoveAt(fromIndex);
420 _tabs.Insert(toIndex, page);
421 _selectedIndex = selectedPage is
null ? -1 : _tabs.IndexOf(selectedPage);
438 ArgumentNullException.ThrowIfNull(page);
440 int fromIndex = _tabs.IndexOf(page);
442 throw new ArgumentException(
"The page is not owned by this tab control.", nameof(page));
444 return MoveTab(fromIndex, toIndex);
453 ObjectDisposedException.ThrowIf(_disposed,
this);
455 Rectangle bounds =
Bounds;
456 (_displayedRowCount, _displayedColumnCount) = ResolveGrid(bounds);
457 Rectangle contentBounds = CreateContentBounds(bounds, _displayedRowCount);
459 for (
int index = 0; index < _tabs.Count; index++)
462 TabHeaderWidget header = _headers[page];
463 Rectangle headerBounds = CreateHeaderBounds(bounds, index, _displayedColumnCount);
465 header.SetBounds(headerBounds);
466 page.HeaderBounds = headerBounds;
467 page.SetContentBounds(contentBounds);
469 SetLocalOffset(header,
new Vector2(headerBounds.Left - bounds.Left, headerBounds.Top - bounds.Top));
470 SetLocalOffset(page,
new Vector2(contentBounds.Left - bounds.Left, contentBounds.Top - bounds.Top));
490 _normalTabColor = normal;
491 _hoverTabColor = hover;
492 _pressedTabColor = pressed;
493 _selectedTabColor = selected;
494 UpdateSelectedTabVisuals();
506 _tabTextColor = color;
508 foreach (TabHeaderWidget header
in _headers.Values)
509 header.SetTextColor(color);
521 _baseZOrder = zOrder;
537 page.TitleChanged -= OnPageTitleChanged;
538 page.Disposing -= OnPageDisposing;
540 TabHeaderWidget header = _headers[page];
541 header.Invoked -= OnHeaderInvoked;
542 header.ReorderDragged -= OnHeaderDragged;
543 header.ReorderDragEnded -= OnHeaderDragEnded;
544 header.Disposing -= OnHeaderDisposing;
560 UpdatePageVisibility();
567 base.ProcessHidden();
573 base.ProcessActivated();
581 base.OnKeyboardInput(args);
582 HandleKeyboardInput(args);
588 HandleKeyboardInput(args);
591 private void InitializeInput()
598 private bool RemoveTabCore(TabPageWidget page,
600 bool raiseSelectionChanged)
602 int removedIndex = _tabs.IndexOf(page);
603 if (removedIndex < 0)
607 TabHeaderWidget header = _headers[page];
609 page.TitleChanged -= OnPageTitleChanged;
610 page.Disposing -= OnPageDisposing;
611 header.Invoked -= OnHeaderInvoked;
612 header.ReorderDragged -= OnHeaderDragged;
613 header.ReorderDragEnded -= OnHeaderDragEnded;
614 header.Disposing -= OnHeaderDisposing;
616 _tabs.RemoveAt(removedIndex);
617 _headers.Remove(page);
627 if (_tabs.Count == 0)
631 else if (ReferenceEquals(previouslySelected, page))
633 _selectedIndex = Math.Min(removedIndex, _tabs.Count - 1);
637 _selectedIndex = previouslySelected is
null ? 0 : _tabs.IndexOf(previouslySelected);
641 UpdateSelectedTabVisuals();
642 UpdatePageVisibility();
644 if (raiseSelectionChanged &&
SelectedTab is not
null && !ReferenceEquals(previouslySelected,
SelectedTab))
650 private void OnPageTitleChanged(TabPageWidget page)
652 if (_headers.TryGetValue(page, out TabHeaderWidget? header))
653 header.SetText(page.Title);
656 private void OnPageDisposing(
object? sender,
659 if (!_disposed && sender is TabPageWidget page)
660 RemoveTabCore(page, dispose:
true, raiseSelectionChanged:
true);
663 private void OnHeaderDisposing(
object? sender,
666 if (_disposed || sender is not TabHeaderWidget header)
669 TabPageWidget? page = _headers.FirstOrDefault(pair => ReferenceEquals(pair.Value, header)).Key;
670 if (page is not
null)
671 RemoveTabCore(page, dispose:
true, raiseSelectionChanged:
true);
674 private void OnHeaderInvoked(TabHeaderWidget header)
676 TabPageWidget? page = _headers.FirstOrDefault(pair => ReferenceEquals(pair.Value, header)).Key;
677 if (page is not
null)
681 private void OnHeaderDragged(TabHeaderWidget header,
682 WidgetDragEventArgs args,
688 TabPageWidget? page = _headers.FirstOrDefault(pair => ReferenceEquals(pair.Value, header)).Key;
692 int fromIndex = _tabs.IndexOf(page);
693 int targetIndex = GetHeaderIndexAt(args.CurrentScreenPositionPx, view);
695 if (targetIndex >= 0 && targetIndex != fromIndex)
696 MoveTab(fromIndex, targetIndex);
699 private void OnHeaderDragEnded(TabHeaderWidget _)
704 private int GetHeaderIndexAt(PointF screenPositionPx,
707 Point screenPosition = Point.Round(screenPositionPx);
709 for (
int index = 0; index < _tabs.Count; index++)
711 TabHeaderWidget header = _headers[_tabs[index]];
712 if (header.HitTest(view, screenPosition))
721 private void HandleKeyboardInput(WidgetKeyboardEventArgs args)
726 for (
int index = 0; index < _tabs.Count; index++)
728 if (!MatchesMnemonic(_tabs[index].Mnemonic, args.Key))
737 private void UpdateSelectedTabVisuals()
739 for (
int index = 0; index < _tabs.Count; index++)
741 TabHeaderWidget header = _headers[_tabs[index]];
742 bool selected = index == _selectedIndex;
745 selected ? _selectedTabColor : _normalTabColor,
746 selected ? _selectedTabColor : _hoverTabColor,
747 selected ? _selectedTabColor : _pressedTabColor);
748 header.SetTextColor(_tabTextColor);
752 private void UpdatePageVisibility()
754 for (
int index = 0; index < _tabs.Count; index++)
756 TabPageWidget page = _tabs[index];
757 bool isSelected = index == _selectedIndex;
759 if (_isShown && isSelected)
764 else if (!isSelected || _isShown)
771 private void ApplyZOrder()
773 foreach (TabPageWidget page
in _tabs)
775 page.SetPageZOrder(_baseZOrder);
776 _headers[page].SetHeaderZOrder(_baseZOrder + 100);
780 private (
int Rows,
int Columns) ResolveGrid(Rectangle bounds)
784 int columns = Math.Max(1, (
int)Math.Ceiling(_tabs.Count / (
double)
TabRowCount));
788 int autoColumns = _tabs.Count == 0
791 int autoRows = Math.Max(1, (
int)Math.Ceiling(_tabs.Count / (
double)autoColumns));
793 return (autoRows, autoColumns);
796 private Rectangle CreateHeaderBounds(Rectangle bounds,
800 int row = index / columns;
801 int column = index % columns;
802 int left = bounds.Left + column * bounds.Width / columns;
803 int right = bounds.Left + (column + 1) * bounds.Width / columns;
812 private Rectangle CreateContentBounds(Rectangle bounds,
815 int contentTop = bounds.Top + rowCount *
TabHeight;
821 Math.Max(1, bounds.Bottom - contentTop));
824 private static Rectangle ValidateBounds(Rectangle bounds)
826 ValidateSize(bounds.Size);
830 private static void ValidateSize(
Size size)
832 if (size.Width <= 0 || size.Height <= DefaultTabHeight)
834 throw new ArgumentOutOfRangeException(
837 "Tab-control bounds must be positive and taller than one tab row.");
841 private void ValidateMnemonic(
char? mnemonic)
843 if (mnemonic is not
char value)
846 if (_tabs.Any(tab => tab.Mnemonic is
char existing &&
847 char.ToUpperInvariant(existing) ==
char.ToUpperInvariant(value)))
849 throw new ArgumentException($
"Duplicate tab mnemonic '{value}'.", nameof(mnemonic));
853 private static void ValidateTitle(
string title)
855 ArgumentException.ThrowIfNullOrWhiteSpace(title);
858 private static bool MatchesMnemonic(
char? mnemonic,
861 return mnemonic is
char character &&
863 key <= char.MaxValue &&
864 char.ToUpperInvariant(character) ==
char.ToUpperInvariant((
char)key);
867 private sealed class TabHeaderWidget : DraggableWidgetBase
869 private Color _normalColor = Color.FromArgb(255, 60, 60, 68);
870 private Color _hoverColor = Color.FromArgb(255, 78, 78, 88);
871 private Color _pressedColor = Color.FromArgb(255, 42, 42, 48);
876 internal event Action<TabHeaderWidget>? Invoked;
881 internal event Action<TabHeaderWidget, WidgetDragEventArgs, View>? ReorderDragged;
886 internal event Action<TabHeaderWidget>? ReorderDragEnded;
888 private View? _dragView;
889 private bool _disposed;
899 internal TabHeaderWidget(RenderSurfaceHostBase renderSurfaceHost,
906 ArgumentNullException.ThrowIfNull(view);
908 Background = CreateBackground(renderSurfaceHost, view, bounds, nickname);
909 Label = CreateLabel(renderSurfaceHost, view, bounds, text);
910 CompleteInitialization();
921 internal TabHeaderWidget(RenderSurfaceHostBase renderSurfaceHost,
928 ArgumentNullException.ThrowIfNull(sceneLayer);
930 Background = CreateBackground(renderSurfaceHost, sceneLayer, bounds, nickname);
931 Label = CreateLabel(renderSurfaceHost, sceneLayer, bounds, text);
932 CompleteInitialization();
938 internal DirectRectangle Background {
get; }
943 internal TextBlock Label {
get; }
949 ? Background.ScreenBounds
950 : Background.WorldBounds;
956 internal void SetBounds(Rectangle bounds)
958 if (bounds.Width <= 0 || bounds.Height <= 0)
959 throw new ArgumentOutOfRangeException(nameof(bounds));
963 Background.ScreenBounds = bounds;
964 Label.ScreenBounds = bounds;
968 Background.WorldBounds = bounds;
969 Label.WorldBounds = bounds;
979 internal void SetText(
string text)
990 internal void SetColors(Color normal,
994 _normalColor = normal;
996 _pressedColor = pressed;
997 UpdateBackgroundColor(normal);
1004 internal void SetTextColor(Color color)
1006 Label.SetColors(color, Color.Transparent);
1013 internal void SetHeaderZOrder(
int zOrder)
1015 Background.ZOrder = zOrder;
1016 Label.ZOrder = zOrder + 1;
1020 protected override Vector2 ConvertScreenDeltaToPositionDelta(Vector2 totalScreenDeltaPx,
1023 return Vector2.Zero;
1027 protected override void OnPointerEnter(WidgetPointerEventArgs args)
1029 base.OnPointerEnter(args);
1030 UpdateBackgroundColor(_hoverColor);
1034 protected override void OnPointerLeave(WidgetPointerEventArgs args)
1036 base.OnPointerLeave(args);
1037 UpdateBackgroundColor(_normalColor);
1041 protected override void OnPointerDown(WidgetPointerEventArgs args)
1043 base.OnPointerDown(args);
1045 if (args.IsPrimaryButton)
1047 _dragView = args.View;
1048 UpdateBackgroundColor(_pressedColor);
1053 protected override void OnPointerUp(WidgetPointerEventArgs args)
1055 base.OnPointerUp(args);
1056 UpdateBackgroundColor(_hoverColor);
1060 protected override void OnPointerClick(WidgetPointerEventArgs args)
1062 base.OnPointerClick(args);
1064 if (!args.IsPrimaryButton)
1067 args.Handled =
true;
1068 Invoked?.Invoke(
this);
1072 protected override void OnKeyboardInput(WidgetKeyboardEventArgs args)
1074 base.OnKeyboardInput(args);
1076 if (args.KeyAction !=
KeyAction.Pressed || args.Key is not 13 and not 32)
1079 args.Handled =
true;
1080 Invoked?.Invoke(
this);
1084 protected override void OnDragged(WidgetDragEventArgs args)
1086 base.OnDragged(args);
1087 if (_dragView is not
null)
1088 ReorderDragged?.Invoke(
this, args, _dragView);
1092 protected override void OnDragEnded(WidgetDragEventArgs args)
1094 base.OnDragEnded(args);
1095 ReorderDragEnded?.Invoke(
this);
1100 public override void Dispose()
1108 ReorderDragged =
null;
1109 ReorderDragEnded =
null;
1112 private void CompleteInitialization()
1123 private void UpdateBackgroundColor(Color color)
1125 Background.SetColor(color);
1126 Background.SetPosition(Background.GetPosition());
1129 private static DirectRectangle CreateBackground(RenderSurfaceHostBase renderSurfaceHost,
1134 return new DirectRectangle(Color.FromArgb(255, 60, 60, 68),
1138 $
"{nickname}.background")
1140 .SetBorderColor(Color.FromArgb(255, 150, 150, 160))
1141 .SetStrokeWidth(1.5f)
1142 .SetCornerRadius(5f);
1145 private static DirectRectangle CreateBackground(RenderSurfaceHostBase renderSurfaceHost,
1150 return new DirectRectangle(Color.FromArgb(255, 60, 60, 68),
1154 $
"{nickname}.background")
1156 .SetBorderColor(Color.FromArgb(255, 150, 150, 160))
1157 .SetStrokeWidth(1.5f)
1158 .SetCornerRadius(5f);
1161 private static TextBlock CreateLabel(RenderSurfaceHostBase renderSurfaceHost,
1166 return new TextBlock(renderSurfaceHost, view, bounds)
1168 .SetFont(SKTypeface.Default, 16f, minSize: 10f)
1169 .SetColors(SKColors.White, SKColors.Transparent)
1170 .SetAlignment(SKTextAlign.Center, TextBlock.VerticalAlign.Center)
1171 .EnableWrapping(
false);
1174 private static TextBlock CreateLabel(RenderSurfaceHostBase renderSurfaceHost,
1179 return new TextBlock(renderSurfaceHost, sceneLayer, view:
null, worldBounds: bounds)
1181 .SetFont(SKTypeface.Default, 16f, minSize: 10f)
1182 .SetColors(SKColors.White, SKColors.Transparent)
1183 .SetAlignment(SKTextAlign.Center, TextBlock.VerticalAlign.Center)
1184 .EnableWrapping(
false);