Files
mmd-pdf/Controls/UiKit.cs
T
kua-agent 6610acfa44 feat: rebrand to MMD PDF, single corporate theme, and remove original text under an edit
- Services/PdfRedactText.cs: strip text whose origin falls inside a CoverAnnotation
  from the page content stream at save time, hooked into PdfBurn.DrawAnnotationsIntoDoc.
  Fixes edited values staying recoverable by text extraction.
- Themes/MMD.xaml replaces all thirteen themes; picker and accent strip removed; no dark mode.
- Rename KillerPDF -> MMD PDF across code, resources, packaging and locale strings; new icon.
- Remove the upstream author credit and the in-app install button.
2026-08-27 07:37:09 +02:00

580 lines
34 KiB
C#

using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Effects;
namespace MmdPdf
{
// WPF has no built-in animation for GridLength, so grid columns (the sidebar) can only snap.
// This drives a pixel-unit GridLength between From and To so a column glides instead.
// From/To/Easing MUST be dependency properties: starting the clock freezes/clones the
// timeline, and the clone only carries DPs - as plain CLR properties they were lost and
// the "animation" held a constant until the completion snap.
internal sealed class GridLengthAnimation : AnimationTimeline
{
public static readonly DependencyProperty FromProperty =
DependencyProperty.Register(nameof(From), typeof(GridLength), typeof(GridLengthAnimation));
public static readonly DependencyProperty ToProperty =
DependencyProperty.Register(nameof(To), typeof(GridLength), typeof(GridLengthAnimation));
public static readonly DependencyProperty EasingProperty =
DependencyProperty.Register(nameof(Easing), typeof(IEasingFunction), typeof(GridLengthAnimation));
public GridLength From { get => (GridLength)GetValue(FromProperty); set => SetValue(FromProperty, value); }
public GridLength To { get => (GridLength)GetValue(ToProperty); set => SetValue(ToProperty, value); }
public IEasingFunction? Easing { get => (IEasingFunction?)GetValue(EasingProperty); set => SetValue(EasingProperty, value); }
public override Type TargetPropertyType => typeof(GridLength);
protected override Freezable CreateInstanceCore() => new GridLengthAnimation();
public override object GetCurrentValue(object defaultOriginValue, object defaultDestinationValue, AnimationClock animationClock)
{
double p = animationClock.CurrentProgress ?? 0.0;
if (Easing is { } ease) p = ease.Ease(p);
return new GridLength(From.Value + (To.Value - From.Value) * p);
}
}
// Design tokens (fonts, radii, shadows) and code-built controls (buttons, checkboxes, fields, labels)
// for dialogs and tools. Tokens resolve from App.xaml's resource dictionary.
internal static class UiKit
{
// Mouse-wheel over ANY slider (on hover) nudges its value - one global class handler covers every
// slider in the app and all dialogs. Handled so the wheel doesn't also scroll an enclosing panel
// while the cursor is on the slider. Registered once when UiKit is first touched (early in startup).
static UiKit()
{
EventManager.RegisterClassHandler(typeof(Slider), UIElement.PreviewMouseWheelEvent,
new MouseWheelEventHandler(SliderWheelAdjust));
}
private static void SliderWheelAdjust(object sender, MouseWheelEventArgs e)
{
if (sender is not Slider s || !s.IsEnabled) return;
double step = s.SmallChange > 0 ? s.SmallChange : (s.Maximum - s.Minimum) / 20.0;
if (step <= 0) return;
s.Value = Math.Max(s.Minimum, Math.Min(s.Maximum, s.Value + (e.Delta > 0 ? step : -step)));
e.Handled = true;
}
// ---- token + theme accessors -------------------------------------------------------------
public static FontFamily UiFont => Res("UiFont", _uiFallback);
public static FontFamily MonoFont => Res("MonoFont", _monoFallback);
public static FontFamily IconFont => Res("IconFont", _iconFallback);
public static FontFamily WordmarkFont => Res("WordmarkFont", _wordmarkFallback);
public static FontFamily WordmarkFontPdf => Res("WordmarkFontPdf", _wordmarkPdfFallback);
private static readonly FontFamily _uiFallback = new("Segoe UI, Microsoft JhengHei UI, Nirmala UI");
private static readonly FontFamily _monoFallback = new("Consolas");
private static readonly FontFamily _iconFallback = new("Segoe MDL2 Assets");
private static readonly FontFamily _wordmarkFallback = new("Typewriter - a602 (dead postman 2004), Consolas");
private static readonly FontFamily _wordmarkPdfFallback = new("Typewriter - a602 (dead postman 2004), Consolas");
public static CornerRadius RadControl => Rad("RadControl", 3);
public static CornerRadius RadCard => Rad("RadCard", 6);
public static CornerRadius RadWindow => Rad("RadWindow", 7);
// Fresh shadow instances (cheap) matching App.xaml's Shadow* resources, for code that builds Effects.
public static DropShadowEffect ShadowText() => Shadow(3, 1, 0.6);
public static DropShadowEffect ShadowIcon() => Shadow(4, 1, 0.9);
public static DropShadowEffect ShadowBar() => Shadow(6, 3, Opacity("BarShadowOpacity", 0.38));
public static DropShadowEffect ShadowDialog() => Shadow(18, 3, Opacity("FlyoutShadowOpacity", 0.6));
// Active-theme brush by key, with a safe fallback so the kit never throws before the theme loads.
public static Brush Brush(string key, Brush? fallback = null)
=> Application.Current?.TryFindResource(key) as Brush ?? fallback ?? Brushes.Gray;
// ---- inline flyout -----------------------------------------------------------------------
// The style for small helpers that float ON the document itself (the form font-size
// stepper; future on-page controls): a translucent dark pill that reads over any page
// content without shouting. Slightly see-through at rest so the page underneath stays
// visible; hovering solidifies it (animated). Deliberately theme-independent - pages are
// usually white whatever the app theme, so one consistent dark pill (with fixed light
// text inside) reads best everywhere.
public const double InlineFlyoutRestOpacity = 0.85;
public static Border InlineFlyout(FrameworkElement content)
{
var b = new Border
{
CornerRadius = new CornerRadius(12),
Padding = new Thickness(7, 1, 7, 1),
BorderThickness = new Thickness(1),
Background = new SolidColorBrush(Color.FromArgb(0xCC, 0x16, 0x16, 0x16)),
BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0xFF, 0xFF, 0xFF)),
Effect = Shadow(10, 2, 0.25),
Child = content,
SnapsToDevicePixels = true,
};
b.MouseEnter += (_, _) => b.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(1.0, new Duration(TimeSpan.FromMilliseconds(110)))
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } });
b.MouseLeave += (_, _) => b.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(InlineFlyoutRestOpacity, new Duration(TimeSpan.FromMilliseconds(220)))
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } });
return b;
}
private static T Res<T>(string key, T fallback) where T : class
=> Application.Current?.TryFindResource(key) as T ?? fallback;
private static CornerRadius Rad(string key, double fb)
=> Application.Current?.TryFindResource(key) is CornerRadius c ? c : new CornerRadius(fb);
private static DropShadowEffect Shadow(double blur, double depth, double opacity)
=> new() { Color = Colors.Black, BlurRadius = blur, ShadowDepth = depth, Direction = 270, Opacity = opacity };
private static double Opacity(string key, double fallback)
=> Application.Current?.TryFindResource(key) is double value ? value : fallback;
// The default quick-color palette, shared by the annotate bars and the color picker's swatch row
// (the "UserSwatches" setting seeds from this). One source so the two can't drift.
public static readonly Color[] DefaultSwatches =
[
Color.FromRgb(0xE0, 0x3C, 0x3C), Color.FromRgb(0xE8, 0x7A, 0x1E), Color.FromRgb(0xF2, 0xC0, 0x1E),
Color.FromRgb(0x2E, 0xA5, 0x4C), Color.FromRgb(0x2E, 0x86, 0xDE), Color.FromRgb(0x8E, 0x5B, 0xD6),
Color.FromRgb(0xE0, 0x4A, 0x9A), Colors.Black, Colors.White
];
// ---- control factories -------------------------------------------------------------------
// Themed checkbox: rounded box with an accent check mark when checked. Replaces the per-dialog
// StyleCheckBox/ThemedCheckTemplate copies so every checkbox in the app is identical.
public static CheckBox CheckBox(string label) => new()
{
Content = new TextBlock { Text = label, TextWrapping = TextWrapping.Wrap },
Foreground = Brush("TextBrush"),
FontFamily = UiFont,
FontSize = 12,
Cursor = Cursors.Hand,
VerticalContentAlignment = VerticalAlignment.Center,
Template = CheckTemplate()
};
private static ControlTemplate CheckTemplate()
{
// DockPanel, not a horizontal StackPanel. A horizontal StackPanel measures its children
// at infinite width, so the label could never wrap however it was configured. Docking the
// box to the left leaves the label a real width to wrap inside (#223).
var row = new FrameworkElementFactory(typeof(DockPanel)) { Name = "root" };
var boxHost = new FrameworkElementFactory(typeof(Grid));
boxHost.SetValue(FrameworkElement.WidthProperty, 16.0);
boxHost.SetValue(FrameworkElement.HeightProperty, 16.0);
boxHost.SetValue(FrameworkElement.VerticalAlignmentProperty, VerticalAlignment.Center);
boxHost.SetValue(FrameworkElement.MarginProperty, new Thickness(0, 0, 8, 0));
boxHost.SetValue(DockPanel.DockProperty, Dock.Left);
var box = new FrameworkElementFactory(typeof(Border));
box.SetValue(Border.CornerRadiusProperty, RadControl);
box.SetValue(Border.BorderThicknessProperty, new Thickness(1));
box.SetValue(Border.BorderBrushProperty, Brush("CardBorderBrush"));
box.SetValue(Border.BackgroundProperty, Brush("RadioWellBrush"));
boxHost.AppendChild(box);
var sunkenDark = new FrameworkElementFactory(typeof(Border));
sunkenDark.SetValue(UIElement.IsHitTestVisibleProperty, false);
sunkenDark.SetResourceReference(Border.BorderBrushProperty, "BevelDarkBrush");
sunkenDark.SetResourceReference(Border.BorderThicknessProperty, "CheckSunkenDarkThickness");
boxHost.AppendChild(sunkenDark);
var sunkenLight = new FrameworkElementFactory(typeof(Border));
sunkenLight.SetValue(UIElement.IsHitTestVisibleProperty, false);
sunkenLight.SetResourceReference(Border.BorderBrushProperty, "BevelLightBrush");
sunkenLight.SetResourceReference(Border.BorderThicknessProperty, "CheckSunkenLightThickness");
boxHost.AppendChild(sunkenLight);
var check = new FrameworkElementFactory(typeof(TextBlock)) { Name = "chk" };
check.SetValue(TextBlock.TextProperty, ""); // Segoe MDL2 CheckMark
check.SetValue(TextBlock.FontFamilyProperty, IconFont);
check.SetValue(TextBlock.FontSizeProperty, 14.0);
check.SetValue(TextBlock.FontWeightProperty, FontWeights.Bold);
check.SetValue(TextBlock.ForegroundProperty, Brush("RadioAccent"));
check.SetValue(TextBlock.HorizontalAlignmentProperty, HorizontalAlignment.Center);
check.SetValue(TextBlock.VerticalAlignmentProperty, VerticalAlignment.Center);
check.SetValue(UIElement.VisibilityProperty, Visibility.Collapsed);
boxHost.AppendChild(check);
var content = new FrameworkElementFactory(typeof(ContentPresenter));
content.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center);
row.AppendChild(boxHost);
row.AppendChild(content);
var ct = new ControlTemplate(typeof(CheckBox)) { VisualTree = row };
var trig = new Trigger { Property = ToggleButton.IsCheckedProperty, Value = true };
trig.Setters.Add(new Setter(UIElement.VisibilityProperty, Visibility.Visible) { TargetName = "chk" });
ct.Triggers.Add(trig);
// Disabled state: dim the whole control (box + label) so it's obviously inactive.
var disabled = new Trigger { Property = UIElement.IsEnabledProperty, Value = false };
disabled.Setters.Add(new Setter(UIElement.OpacityProperty, 0.4) { TargetName = "root" });
ct.Triggers.Add(disabled);
return ct;
}
// Themed radio button with a clean horizontal layout (ring + accent dot + label), built from the
// theme brushes. Unlike the settings-panel ThemeRadio (a full-width vertical row), this lays out
// tightly for inline/horizontal use.
public static RadioButton Radio(string text) => new()
{
Content = text,
Foreground = Brush("TextBrush"),
FontFamily = UiFont,
FontSize = 12,
Cursor = Cursors.Hand,
VerticalContentAlignment = VerticalAlignment.Center,
Template = RadioTemplate()
};
private static ControlTemplate RadioTemplate()
{
var sp = new FrameworkElementFactory(typeof(StackPanel)) { Name = "root" };
sp.SetValue(StackPanel.OrientationProperty, Orientation.Horizontal);
sp.SetValue(Panel.BackgroundProperty, Brushes.Transparent);
var ring = new FrameworkElementFactory(typeof(Border)) { Name = "ring" };
ring.SetValue(Border.WidthProperty, 15.0);
ring.SetValue(Border.HeightProperty, 15.0);
ring.SetValue(Border.CornerRadiusProperty, new CornerRadius(7.5));
ring.SetValue(Border.BorderThicknessProperty, new Thickness(1.5));
ring.SetValue(Border.BorderBrushProperty, Brush("DimTextBrush"));
ring.SetValue(Border.BackgroundProperty, Brush("RadioWellBrush"));
ring.SetValue(Border.VerticalAlignmentProperty, VerticalAlignment.Center);
ring.SetValue(Border.MarginProperty, new Thickness(0, 1, 7, 0)); // +1 top settles it against the text optical center
var dot = new FrameworkElementFactory(typeof(Border)) { Name = "dot" };
dot.SetValue(Border.WidthProperty, 7.0);
dot.SetValue(Border.HeightProperty, 7.0);
dot.SetValue(Border.CornerRadiusProperty, new CornerRadius(3.5));
dot.SetValue(Border.BackgroundProperty, Brush("RadioAccent"));
dot.SetValue(Border.HorizontalAlignmentProperty, HorizontalAlignment.Center);
dot.SetValue(Border.VerticalAlignmentProperty, VerticalAlignment.Center);
dot.SetValue(UIElement.VisibilityProperty, Visibility.Collapsed);
ring.AppendChild(dot);
var cp = new FrameworkElementFactory(typeof(ContentPresenter));
cp.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center);
sp.AppendChild(ring);
sp.AppendChild(cp);
var ct = new ControlTemplate(typeof(RadioButton)) { VisualTree = sp };
var on = new Trigger { Property = ToggleButton.IsCheckedProperty, Value = true };
on.Setters.Add(new Setter(UIElement.VisibilityProperty, Visibility.Visible) { TargetName = "dot" });
on.Setters.Add(new Setter(Border.BorderBrushProperty, Brush("RadioAccent")) { TargetName = "ring" });
ct.Triggers.Add(on);
var off = new Trigger { Property = UIElement.IsEnabledProperty, Value = false };
off.Setters.Add(new Setter(UIElement.OpacityProperty, 0.4) { TargetName = "root" });
ct.Triggers.Add(off);
return ct;
}
// Themed single-line input, fully self-contained (templated from the theme brushes) so it renders
// correctly in ANY window without depending on a window-scoped XAML style. Kills the OS-default
// white box / blue focus + selection chrome.
public static TextBox Field(double width = double.NaN)
{
var tb = new TextBox
{
FontFamily = UiFont,
FontSize = 12,
Background = Brush("TextFieldBrush", Brush("BgCanvas")),
Foreground = Brush("TextBrush"),
BorderBrush = Brush("CardBorderBrush"),
BorderThickness = new Thickness(1),
Padding = new Thickness(6, 4, 6, 4),
CaretBrush = Brush("TextBrush"),
SelectionBrush = Brush("RowSelectedBrush"),
SelectionTextBrush = Brush("TextBrush"),
Template = FieldTemplate()
};
if (!double.IsNaN(width)) tb.Width = width;
return tb;
}
private static ControlTemplate FieldTemplate()
{
var root = new FrameworkElementFactory(typeof(Grid));
var b = new FrameworkElementFactory(typeof(Border));
b.SetBinding(Border.BackgroundProperty, new Binding("Background") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
b.SetBinding(Border.BorderBrushProperty, new Binding("BorderBrush") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
b.SetBinding(Border.BorderThicknessProperty, new Binding("BorderThickness") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
b.SetValue(Border.CornerRadiusProperty, RadControl);
var sv = new FrameworkElementFactory(typeof(ScrollViewer)) { Name = "PART_ContentHost" };
sv.SetValue(Control.PaddingProperty, new Thickness(0));
b.AppendChild(sv);
root.AppendChild(b);
var dark = new FrameworkElementFactory(typeof(Border));
dark.SetValue(UIElement.IsHitTestVisibleProperty, false);
dark.SetValue(Border.CornerRadiusProperty, RadControl);
dark.SetResourceReference(Border.BorderBrushProperty, "BevelDarkBrush");
dark.SetResourceReference(Border.BorderThicknessProperty, "CheckSunkenDarkThickness");
root.AppendChild(dark);
var light = new FrameworkElementFactory(typeof(Border));
light.SetValue(UIElement.IsHitTestVisibleProperty, false);
light.SetValue(Border.CornerRadiusProperty, RadControl);
light.SetResourceReference(Border.BorderBrushProperty, "BevelLightBrush");
light.SetResourceReference(Border.BorderThicknessProperty, "CheckSunkenLightThickness");
root.AppendChild(light);
return new ControlTemplate(typeof(TextBox)) { VisualTree = root };
}
// Themed PasswordBox matching Field(): our border/fill, no OS white box or blue focus chrome.
public static PasswordBox PasswordField(double width = double.NaN)
{
var pb = new PasswordBox
{
FontFamily = UiFont,
FontSize = 12,
Background = Brush("TextFieldBrush", Brush("BgCanvas")),
Foreground = Brush("TextBrush"),
BorderBrush = Brush("CardBorderBrush"),
BorderThickness = new Thickness(1),
Padding = new Thickness(6, 5, 6, 5),
CaretBrush = Brush("TextBrush"),
Template = PasswordFieldTemplate()
};
if (!double.IsNaN(width)) pb.Width = width;
return pb;
}
private static ControlTemplate PasswordFieldTemplate()
{
var root = new FrameworkElementFactory(typeof(Grid));
var b = new FrameworkElementFactory(typeof(Border));
b.SetBinding(Border.BackgroundProperty, new Binding("Background") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
b.SetBinding(Border.BorderBrushProperty, new Binding("BorderBrush") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
b.SetBinding(Border.BorderThicknessProperty, new Binding("BorderThickness") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
b.SetValue(Border.CornerRadiusProperty, RadControl);
var sv = new FrameworkElementFactory(typeof(ScrollViewer)) { Name = "PART_ContentHost" };
sv.SetValue(Control.PaddingProperty, new Thickness(0));
b.AppendChild(sv);
root.AppendChild(b);
var dark = new FrameworkElementFactory(typeof(Border));
dark.SetValue(UIElement.IsHitTestVisibleProperty, false);
dark.SetValue(Border.CornerRadiusProperty, RadControl);
dark.SetResourceReference(Border.BorderBrushProperty, "BevelDarkBrush");
dark.SetResourceReference(Border.BorderThicknessProperty, "CheckSunkenDarkThickness");
root.AppendChild(dark);
var light = new FrameworkElementFactory(typeof(Border));
light.SetValue(UIElement.IsHitTestVisibleProperty, false);
light.SetValue(Border.CornerRadiusProperty, RadControl);
light.SetResourceReference(Border.BorderBrushProperty, "BevelLightBrush");
light.SetResourceReference(Border.BorderThicknessProperty, "CheckSunkenLightThickness");
root.AppendChild(light);
return new ControlTemplate(typeof(PasswordBox)) { VisualTree = root };
}
// Wraps a dialog's document/preview pane with the family drop shadow: a SEPARATE sibling
// border underneath (content must never render through a bitmap effect or it loses
// ClearType), carrying the per-theme PaneShadowEffect - which is null on 98SE, so the
// classic theme stays flat. Dialogs read as mini main windows this way.
public static Grid PaneWithShadow(Border pane)
{
// Code-built preview panes used to carry their own hard-coded radius, so 98SE could
// never square them. The theme only supplies this override when it needs one.
if (Application.Current?.TryFindResource("PaneCornerRadiusValue") is double radius)
pane.CornerRadius = new CornerRadius(radius);
var shadow = new Border
{
Margin = pane.Margin,
CornerRadius = pane.CornerRadius,
IsHitTestVisible = false,
};
shadow.SetResourceReference(Border.BackgroundProperty, "BgCanvas");
shadow.SetResourceReference(UIElement.EffectProperty, "PaneShadowEffect");
var host = new Grid();
host.Children.Add(shadow);
host.Children.Add(pane);
// The main document pane uses these same four bevel rings. They are transparent and
// zero-width on modern themes, while 98SE gets its square two-stage classic recess.
var bevels = new Grid { Margin = pane.Margin, IsHitTestVisible = false };
Border Ring(string brushKey, string thicknessKey, bool inner = false)
{
var ring = new Border { CornerRadius = pane.CornerRadius };
ring.SetResourceReference(Border.BorderBrushProperty, brushKey);
ring.SetResourceReference(Border.BorderThicknessProperty, thicknessKey);
if (inner)
ring.SetResourceReference(FrameworkElement.MarginProperty, "PaneBevelInnerMargin");
return ring;
}
bevels.Children.Add(Ring("PaneBevelDarkBrush", "PaneBevelLightThickness"));
bevels.Children.Add(Ring("PaneBevelLightBrush", "PaneBevelDarkThickness"));
bevels.Children.Add(Ring("PaneBevelDark2Brush", "PaneBevel2LightThickness", inner: true));
bevels.Children.Add(Ring("PaneBevelLight2Brush", "PaneBevel2DarkThickness", inner: true));
host.Children.Add(bevels);
return host;
}
// A dialog section heading (e.g. "ROTATE", "PAGE NUMBERS").
public static TextBlock SectionHeader(string text) => new()
{
Text = text,
FontFamily = MonoFont,
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = Brush("TextBrush"),
Margin = new Thickness(0, 0, 0, 6),
Effect = ShadowText()
};
// A small secondary label sitting above/beside a field.
public static TextBlock GroupLabel(string text) => new()
{
Text = text,
FontFamily = UiFont,
FontSize = 11,
Foreground = Brush("MutedTextBrush"),
Margin = new Thickness(0, 0, 0, 2)
};
// Right-aligned row of dialog buttons with a consistent 8px gap. Pass buttons left-to-right.
public static StackPanel ButtonRow(params Button[] buttons)
{
var row = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right };
for (int i = 0; i < buttons.Length; i++)
{
if (i > 0) buttons[i].Margin = new Thickness(8, 0, 0, 0);
row.Children.Add(buttons[i]);
}
return row;
}
// A flat text "link" (e.g. "Reset all") with an accent hover, for low-emphasis dialog actions.
public static TextBlock LinkLabel(string text, Action onClick)
{
var link = new TextBlock
{
Text = text,
FontFamily = UiFont,
FontSize = 12,
Foreground = Brush("MutedTextBrush"),
Cursor = Cursors.Hand,
VerticalAlignment = VerticalAlignment.Center
};
link.MouseEnter += (_, _2) => link.Foreground = Brush("PrimaryBrush");
link.MouseLeave += (_, _2) => link.Foreground = Brush("MutedTextBrush");
link.MouseLeftButtonUp += (_, _2) => onClick();
return link;
}
// Dialog/popup buttons. accent==true is the primary (fills solid accent on hover); false is secondary.
public static Button Make(object content, bool accent)
{
if (MmdPdf.Services.ThemeManager.Current == MmdPdf.Services.Theme.SE98)
{
var button = Make(content, Brush("ChipFaceBrush"), Brush("ChipFaceBrush"),
Brush("TextBrush"), Brush("TextBrush"), Brushes.Transparent);
// Keep an already-open code-built surface attached to the live palette. The
// explicit-color factory below is also used by pre-theme startup dialogs, so the
// resource references belong here in the normal themed overload.
button.SetResourceReference(Control.BackgroundProperty, "ChipFaceBrush");
button.SetResourceReference(Control.ForegroundProperty, "TextBrush");
button.Template = BeveledButtonTemplate();
return button;
}
var themed = accent
? Make(content, Brush("SelectionBg"), Brush("PrimaryBrush"), Brush("SelectionFg"), Brush("OnPrimaryBrush"), Brush("PrimaryBrush"))
: Make(content, Brush("PaneBrush"), Brush("RowHoverBrush"), Brush("TextBrush"), Brush("TextBrush"), Brush("CardBorderBrush"));
// Make(object,bool) is used by long-lived annotation bars and modeless tool windows.
// A local brush value would preserve the palette that happened to be active when the
// control was constructed. Re-attach each state to resource keys so theme and accent
// changes repaint the existing button instead of leaving an old-colored island.
void ApplyRest()
{
themed.SetResourceReference(Control.BackgroundProperty, accent ? "SelectionBg" : "PaneBrush");
themed.SetResourceReference(Control.ForegroundProperty, accent ? "SelectionFg" : "TextBrush");
themed.SetResourceReference(Control.BorderBrushProperty, accent ? "PrimaryBrush" : "CardBorderBrush");
}
void ApplyHover()
{
themed.SetResourceReference(Control.BackgroundProperty, accent ? "PrimaryBrush" : "RowHoverBrush");
themed.SetResourceReference(Control.ForegroundProperty, accent ? "OnPrimaryBrush" : "TextBrush");
themed.SetResourceReference(Control.BorderBrushProperty, accent ? "PrimaryBrush" : "CardBorderBrush");
}
// These handlers are registered after the explicit-color factory's handlers, so the
// resource-backed values win and remain live for the current palette.
themed.MouseEnter += (_, _) => ApplyHover();
themed.MouseLeave += (_, _) => ApplyRest();
ApplyRest();
return themed;
}
private static ControlTemplate BeveledButtonTemplate()
{
var grid = new FrameworkElementFactory(typeof(Grid));
var face = new FrameworkElementFactory(typeof(Border)) { Name = "face" };
face.SetBinding(Border.BackgroundProperty, new Binding("Background") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
face.SetBinding(Border.PaddingProperty, new Binding("Padding") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
var cp = new FrameworkElementFactory(typeof(ContentPresenter));
cp.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Center);
cp.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center);
face.AppendChild(cp);
grid.AppendChild(face);
var light = new FrameworkElementFactory(typeof(Border)) { Name = "light" };
light.SetResourceReference(Border.BorderBrushProperty, "BevelLightBrush");
light.SetResourceReference(Border.BorderThicknessProperty, "ButtonBevelLightThickness");
grid.AppendChild(light);
var dark = new FrameworkElementFactory(typeof(Border)) { Name = "dark" };
dark.SetResourceReference(Border.BorderBrushProperty, "BevelDarkBrush");
dark.SetResourceReference(Border.BorderThicknessProperty, "ButtonBevelDarkThickness");
grid.AppendChild(dark);
var template = new ControlTemplate(typeof(Button)) { VisualTree = grid };
var pressed = new Trigger { Property = Button.IsPressedProperty, Value = true };
pressed.Setters.Add(new Setter(Border.BorderBrushProperty, Brush("BevelDarkBrush"), "light"));
pressed.Setters.Add(new Setter(Border.BorderBrushProperty, Brush("BevelLightBrush"), "dark"));
template.Triggers.Add(pressed);
return template;
}
// Explicit-color overload for pre-theme windows (startup/crash/About). border==null = borderless.
public static Button Make(object content, Brush normalBg, Brush hoverBg, Brush normalFg, Brush hoverFg, Brush? border = null)
{
var btn = new Button
{
Content = content,
Padding = new Thickness(18, 6, 18, 6),
Background = normalBg,
Foreground = normalFg,
BorderBrush = border ?? Brushes.Transparent,
BorderThickness = new Thickness(border == null ? 0 : 1),
Cursor = Cursors.Hand,
FontFamily = UiFont,
FontSize = 12,
FocusVisualStyle = null,
Template = ButtonTemplate(),
};
btn.MouseEnter += (_, _) => { btn.Background = hoverBg; btn.Foreground = hoverFg; };
btn.MouseLeave += (_, _) => { btn.Background = normalBg; btn.Foreground = normalFg; };
return btn;
}
internal static ControlTemplate ButtonTemplate()
{
var bf = new FrameworkElementFactory(typeof(Border)) { Name = "bd" };
bf.SetBinding(Border.BackgroundProperty, new Binding("Background") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
bf.SetBinding(Border.BorderBrushProperty, new Binding("BorderBrush") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
bf.SetBinding(Border.BorderThicknessProperty, new Binding("BorderThickness") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
bf.SetBinding(Border.PaddingProperty, new Binding("Padding") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
bf.SetValue(Border.CornerRadiusProperty, RadControl);
var cp = new FrameworkElementFactory(typeof(ContentPresenter));
cp.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Center);
cp.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center);
bf.AppendChild(cp);
var ct = new ControlTemplate(typeof(Button)) { VisualTree = bf };
var dis = new Trigger { Property = UIElement.IsEnabledProperty, Value = false };
dis.Setters.Add(new Setter(UIElement.OpacityProperty, 0.45) { TargetName = "bd" });
ct.Triggers.Add(dis);
return ct;
}
}
}