1197 lines
67 KiB
C#
1197 lines
67 KiB
C#
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Runtime.InteropServices;
|
|
using System.Text.Json;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Input;
|
|
using System.Windows.Interop;
|
|
using System.Windows.Media;
|
|
using System.Windows.Media.Animation;
|
|
using System.Windows.Media.Imaging;
|
|
using System.Windows.Shapes;
|
|
using Docnet.Core;
|
|
using Docnet.Core.Models;
|
|
using Microsoft.Win32;
|
|
using PdfSharpCore.Drawing;
|
|
using PdfSharpCore.Pdf;
|
|
using PdfSharpCore.Pdf.IO;
|
|
using KillerPDF.Services;
|
|
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
|
|
|
|
namespace KillerPDF
|
|
{
|
|
public partial class MainWindow
|
|
{
|
|
// ============================================================
|
|
// Picker state sync + appearance handlers. (The Settings panel itself retired 2026-07-31:
|
|
// every section moved to where the thing it configures lives - theme/language/view onto
|
|
// rail flyouts, toolbar onto the bar's right-click menu, sidebar side onto the sidebar's.)
|
|
// ============================================================
|
|
|
|
// Syncs every picker's radios and accent dots to live state before a flyout shows -
|
|
// exactly ONE sync implementation, shared by all the rail flyouts (Shell/RailFlyouts.cs).
|
|
private void SyncPickerState()
|
|
{
|
|
var cur = ThemeManager.Current;
|
|
ThemeDarkRadio.IsChecked = cur == Theme.Dark;
|
|
ThemeLightRadio.IsChecked = cur == Theme.Light;
|
|
ThemeHCRadio.IsChecked = cur == Theme.Black;
|
|
Theme98SERadio.IsChecked = cur == Theme.SE98;
|
|
ThemeBloodRadio.IsChecked = cur == Theme.Blood;
|
|
ThemeGreedRadio.IsChecked = cur == Theme.Greed;
|
|
ThemeCyanoticRadio.IsChecked = cur == Theme.Cyanotic;
|
|
ThemeEctoplasmRadio.IsChecked = cur == Theme.Ectoplasm;
|
|
ThemeDecayRadio.IsChecked = cur == Theme.Decay;
|
|
ThemeMourningRadio.IsChecked = cur == Theme.Mourning;
|
|
ThemeSepulchreRadio.IsChecked = cur == Theme.Sepulchre;
|
|
ThemeDeliriumRadio.IsChecked = cur == Theme.Delirium;
|
|
ThemeMalaiseRadio.IsChecked = cur == Theme.Malaise;
|
|
UpdateAccentStrip(animate: false);
|
|
// Sync language picker
|
|
var curLoc = KillerPDF.Services.LocaleManager.Current;
|
|
LangEnRadio.IsChecked = curLoc == KillerPDF.Services.Locale.EnUS;
|
|
LangCsRadio.IsChecked = curLoc == KillerPDF.Services.Locale.CsCZ;
|
|
LangEsRadio.IsChecked = curLoc == KillerPDF.Services.Locale.Es;
|
|
LangFrRadio.IsChecked = curLoc == KillerPDF.Services.Locale.Fr;
|
|
LangZhTWRadio.IsChecked = curLoc == KillerPDF.Services.Locale.ZhTW;
|
|
LangZhCNRadio.IsChecked = curLoc == KillerPDF.Services.Locale.ZhCN;
|
|
LangBnRadio.IsChecked = curLoc == KillerPDF.Services.Locale.Bn;
|
|
LangTrRadio.IsChecked = curLoc == KillerPDF.Services.Locale.TrTR;
|
|
LangDeRadio.IsChecked = curLoc == KillerPDF.Services.Locale.De;
|
|
LangJaRadio.IsChecked = curLoc == KillerPDF.Services.Locale.JaJP;
|
|
LangPlRadio.IsChecked = curLoc == KillerPDF.Services.Locale.PlPL;
|
|
LangHuRadio.IsChecked = curLoc == KillerPDF.Services.Locale.HuHU;
|
|
// Sync view mode radios. Against the PENDING mode while a fade-wrapped switch is in
|
|
// flight (_viewMode lags until the fade-out lands), so wheel-cycling with the flyout
|
|
// open moves the checkmark in step instead of one notch behind.
|
|
var vm = _pendingViewMode ?? _viewMode;
|
|
ViewSingleRadio.IsChecked = vm == ViewMode.Single;
|
|
ViewContinuousRadio.IsChecked = vm == ViewMode.Continuous;
|
|
ViewTwoPageRadio.IsChecked = vm == ViewMode.TwoPage;
|
|
ViewGridRadio.IsChecked = vm == ViewMode.Grid;
|
|
// (The toolbar picker lives on the toolbar's own right-click menu, and the sidebar
|
|
// side on the sidebar's - both build their checks fresh on open, nothing to sync here.)
|
|
}
|
|
|
|
// ── Quick fade in/out for the full-window overlay panels (Shortcuts/About) ──
|
|
private static void FadeOverlayIn(UIElement el)
|
|
{
|
|
el.BeginAnimation(UIElement.OpacityProperty, null);
|
|
el.Opacity = 0;
|
|
el.Visibility = Visibility.Visible;
|
|
el.BeginAnimation(UIElement.OpacityProperty,
|
|
new DoubleAnimation(0, 1, new Duration(TimeSpan.FromMilliseconds(110)))
|
|
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } });
|
|
}
|
|
|
|
private static void FadeOverlayOut(UIElement el)
|
|
{
|
|
if (el.Visibility != Visibility.Visible) return;
|
|
var anim = new DoubleAnimation(1, 0, new Duration(TimeSpan.FromMilliseconds(90)))
|
|
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseIn } };
|
|
anim.Completed += (_, _) =>
|
|
{
|
|
el.Visibility = Visibility.Collapsed;
|
|
el.BeginAnimation(UIElement.OpacityProperty, null);
|
|
el.Opacity = 1;
|
|
};
|
|
el.BeginAnimation(UIElement.OpacityProperty, anim);
|
|
}
|
|
|
|
// Fades an annotate (draw/text) settings bar out over ~90ms, then removes it from its parent -
|
|
// so the bar dissolves when its tool is deselected and crossfades when switching tools, matching
|
|
// the About/Settings overlays.
|
|
private static void FadeOutAndRemoveBar(Border? bar)
|
|
{
|
|
if (bar is null) return;
|
|
var anim = new DoubleAnimation(bar.Opacity, 0, new Duration(TimeSpan.FromMilliseconds(90)))
|
|
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseIn } };
|
|
anim.Completed += (_, _) =>
|
|
{
|
|
bar.BeginAnimation(UIElement.OpacityProperty, null);
|
|
// Release the 98SE scroller inset before the bar leaves its area, so the document
|
|
// slides back up to the pane top when the band disappears.
|
|
if (bar.Parent is Grid area) SyncSe98BarInset(bar, area, removing: true);
|
|
(bar.Parent as Panel)?.Children.Remove(bar);
|
|
};
|
|
bar.BeginAnimation(UIElement.OpacityProperty, anim);
|
|
}
|
|
|
|
// Collapses the visible annotate bar to a thin peek strip, or expands it back. Triggered by
|
|
// re-clicking the already-active tool, so a second click tucks the bar away instead of the
|
|
// old behavior of rebuilding it (which flickered).
|
|
private void ToggleAnnotBarMinimized()
|
|
{
|
|
var bar = _textSettingsBar ?? _drawSettingsBar ?? _cropConfirmBar;
|
|
if (bar is null) return;
|
|
_annotBarMinimized = !_annotBarMinimized;
|
|
bar.ClipToBounds = true;
|
|
const double peek = 13; // thin strip, just enough for the grip dots
|
|
if (_annotBarMinimized)
|
|
{
|
|
// Freeze the current width so collapsing the content can't shrink the bar to the dots and
|
|
// slide it to the corner - it stays a same-width strip in place.
|
|
bar.Width = bar.ActualWidth;
|
|
bar.Effect = null; // minimized strips never carry a drop shadow
|
|
_annotBarFullHeight = bar.ActualHeight > 0 ? bar.ActualHeight : bar.DesiredSize.Height;
|
|
var anim = new DoubleAnimation(_annotBarFullHeight, peek, new Duration(TimeSpan.FromMilliseconds(120)))
|
|
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseIn } };
|
|
anim.Completed += (_, _) =>
|
|
{
|
|
if (_annotBarContent is not null) _annotBarContent.Visibility = Visibility.Collapsed;
|
|
if (_annotBarDots is not null) _annotBarDots.Visibility = Visibility.Visible;
|
|
bar.ClipToBounds = false; // content is hidden now, nothing to clip
|
|
};
|
|
bar.BeginAnimation(FrameworkElement.HeightProperty, anim);
|
|
}
|
|
else
|
|
{
|
|
// Show the full content again before growing back, and let the width track content again.
|
|
bar.Width = double.NaN;
|
|
bar.Effect = AnnotBarShadow(); // restore the drop shadow on the expanded bar
|
|
if (_annotBarContent is not null) _annotBarContent.Visibility = Visibility.Visible;
|
|
if (_annotBarDots is not null) _annotBarDots.Visibility = Visibility.Collapsed;
|
|
double full = _annotBarFullHeight > 0 ? _annotBarFullHeight : bar.ActualHeight;
|
|
var anim = new DoubleAnimation(peek, full, new Duration(TimeSpan.FromMilliseconds(120)))
|
|
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } };
|
|
anim.Completed += (_, _) =>
|
|
{
|
|
bar.BeginAnimation(FrameworkElement.HeightProperty, null);
|
|
bar.Height = double.NaN; // back to auto so it tracks its content again
|
|
bar.ClipToBounds = false;
|
|
};
|
|
bar.BeginAnimation(FrameworkElement.HeightProperty, anim);
|
|
}
|
|
}
|
|
|
|
// Confirm-before-opening-links (About card footer). One key, positive sense - see the
|
|
// ConfirmLinksSetting comment in Links.cs. Writing the same value back on the init sync is
|
|
// a harmless no-op, so this needs no change guard.
|
|
private void LinkConfirmCheck_Toggled(object sender, RoutedEventArgs e)
|
|
=> App.SetSetting(ConfirmLinksSetting, LinkConfirmCheck.IsChecked == true ? "1" : "0");
|
|
|
|
// Privacy section (#146): don't remember recently opened files. Turning it ON also clears
|
|
// the existing list (matching the user's privacy expectation on a shared machine); the
|
|
// guard makes the settings-open sync a no-op so opening the panel never wipes anything.
|
|
private void NoRecentCheck_Toggled(object sender, RoutedEventArgs e)
|
|
{
|
|
bool off = NoRecentCheck.IsChecked == true;
|
|
if ((App.GetSetting(App.NoRecentFilesSetting) == "1") == off) return; // sync, not a user change
|
|
if (off)
|
|
{
|
|
App.SetSetting(App.NoRecentFilesSetting, "1");
|
|
App.ClearRecentFiles();
|
|
PopulateRecentFilesList(); // start screen hides its Recent box immediately
|
|
}
|
|
else
|
|
{
|
|
App.RemoveSetting(App.NoRecentFilesSetting);
|
|
}
|
|
}
|
|
|
|
// Invert document colors (#135): flips the display-only dark mode. Shared by the rail's
|
|
// moon toggle and Ctrl+I. The state is baked into rendered pixels, so flush the render
|
|
// caches and repaint IN PLACE - never through ApplyViewMode. The mode-switch rebuild
|
|
// re-laid-out the whole view and restored scroll approximately, so pages visibly
|
|
// shuffled before the new colors arrived. Invert changes pixels, never geometry:
|
|
// layout and scroll stay exactly where they are and only the bitmaps re-render.
|
|
private void ToggleDocInvert(bool on)
|
|
{
|
|
// Per pane: only the FOCUSED pane flips, so a split can read one document inverted
|
|
// beside a normal one. The moon lights for the focused pane; FocusPane re-syncs it.
|
|
if (ActiveViewer.DocInvert == on) return;
|
|
ActiveViewer.DocInvert = on;
|
|
App.SetSetting("DocInvert", on ? "1" : "0");
|
|
DocInvertBtn.Tag = on ? "on" : null; // lights the rail icon in the accent while active
|
|
RepaintForInvertChange(bothPanes: false); // per-pane toggle: the other pane is untouched
|
|
}
|
|
|
|
// Right-click on the moon: night-mode options. One checkable item - "Invert images too"
|
|
// (default off since the #135 carve-out; some scanned documents ARE one full-page image,
|
|
// where the carve-out makes night mode a no-op, so the old full inversion stays reachable).
|
|
// Built fresh on each open so the caption follows the active language.
|
|
private void DocInvertBtn_RightClick(object sender, MouseButtonEventArgs e)
|
|
{
|
|
var menu = MakeThemedMenu();
|
|
var mi = new System.Windows.Controls.MenuItem
|
|
{
|
|
Header = Loc("Str_InvertImagesToo"),
|
|
IsCheckable = true,
|
|
IsChecked = BitmapHelpers.DocInvertImages,
|
|
InputGestureText = "Shift+N", // right-aligned in the family MenuItem template
|
|
};
|
|
mi.Click += (_, _2) => ToggleInvertImages(!BitmapHelpers.DocInvertImages);
|
|
menu.Items.Add(mi);
|
|
menu.PlacementTarget = (UIElement)sender;
|
|
menu.IsOpen = true;
|
|
e.Handled = true;
|
|
}
|
|
|
|
private void ToggleInvertImages(bool on)
|
|
{
|
|
if (BitmapHelpers.DocInvertImages == on) return;
|
|
BitmapHelpers.DocInvertImages = on;
|
|
App.SetSetting("DocInvertImages", on ? "1" : "0");
|
|
// Only repaint when night mode is actually showing in either pane; otherwise it just
|
|
// takes effect the next time a moon is toggled on. Both panes: this option changes
|
|
// how EVERY inverted pane renders.
|
|
if (Viewer.DocInvert || ViewerB.DocInvert) RepaintForInvertChange(bothPanes: true);
|
|
}
|
|
|
|
// Shared by the moon toggle and its right-click option: the invert state is baked into
|
|
// rendered pixels, so flush the render caches and repaint IN PLACE - never through
|
|
// ApplyViewMode (see the comment above ToggleDocInvert).
|
|
private void RepaintForInvertChange(bool bothPanes)
|
|
{
|
|
// Per-pane toggle flushes and repaints ONLY the focused pane; the other pane's
|
|
// pixels are correct and repainting them read as a spurious refresh (2026-08-15).
|
|
// The images-too option still touches every inverted pane.
|
|
if (bothPanes) FlushAllRenderCaches();
|
|
else ActiveViewer.FlushOwnRenderCaches();
|
|
|
|
// The invert flag is GLOBAL, but everything below this block runs through the shared
|
|
// fields and so repaints only the FOCUSED pane - the other pane's already-painted
|
|
// tiles kept their old colors until any scroll or focus change forced a re-render,
|
|
// which read as one pane being inverted and scrolling the other pane inverting it
|
|
// (2026-08-01). Re-render it with its own session swapped in - the same
|
|
// WithOwnSession idiom the cross-pane tab drag uses. BEFORE the _doc guard: the
|
|
// focused pane being empty must not strand the other pane's stale pixels.
|
|
if (bothPanes && IsSplit)
|
|
{
|
|
var other = ReferenceEquals(ActiveViewer, Viewer) ? ViewerB : Viewer;
|
|
// PIXELS ONLY (RepaintPixelsExt, 2026-08-15): the full RenderActiveSessionExt ran
|
|
// Bootstrap/ShowEmptyState under the swapped session, and their Host chrome
|
|
// mutations kept wrecking the focused pane's sidebar - replaced thumbnails when
|
|
// the other pane held a doc, cleared and disabled the sidebar when it was empty.
|
|
other.WithOwnSession(other.RepaintPixelsExt);
|
|
}
|
|
|
|
if (_doc is null) return;
|
|
if (_viewMode == ViewMode.Continuous)
|
|
{
|
|
// Null every slot's bitmap so the render pass (which skips filled slots)
|
|
// repaints the window around the viewport; far slots stay empty scaffolds
|
|
// until virtualization brings them back, exactly as after a long scroll.
|
|
// Slot sizes are untouched, so scroll geometry cannot move.
|
|
_continuousSharpenCts?.Cancel();
|
|
_continuousSharpPages.Clear();
|
|
foreach (var child in _continuousPanel.Children)
|
|
if (child is Border b && b.Child is Grid g
|
|
&& g.Children.Count > 0 && g.Children[0] is System.Windows.Controls.Image img)
|
|
img.Source = null;
|
|
_ = RenderContinuousPages(Math.Max(0, PageList.SelectedIndex));
|
|
StartRerenderTimer(); // then re-sharpen the visible pages at the current zoom
|
|
}
|
|
else
|
|
{
|
|
// Single/Two-Page/Grid: RenderPage repaints the primary and streams the
|
|
// secondary tiles back (grid anchors at page 0, like ApplyViewMode).
|
|
// keepTiles: the tile set is unchanged, so existing tiles stay put and get
|
|
// their bitmaps swapped in place - no clear-and-refill jitter in grid.
|
|
ActiveViewer.RenderPage(_viewMode == ViewMode.Grid ? 0 : Math.Max(0, PageList.SelectedIndex),
|
|
keepTiles: true);
|
|
}
|
|
}
|
|
|
|
private void DocInvertBtn_Click(object sender, RoutedEventArgs e)
|
|
=> ToggleDocInvert(!ActiveViewer.DocInvert);
|
|
|
|
private void OnThemeChanged()
|
|
{
|
|
_appliedSquared = null;
|
|
UpdateWindowChrome();
|
|
// The 98SE pane reaches the frame while rounded themes retain an 8px outer gutter.
|
|
// Reapply the saved sidebar side so switching away from 98SE restores that right edge.
|
|
ApplySidebarSide();
|
|
// Refresh snapshot FindResource calls that were set as local values.
|
|
// SetResourceReference bindings update automatically; sidebar tabs and
|
|
// active tool button background still need an explicit refresh.
|
|
SetTool(_currentTool);
|
|
if (_sidebarShowingOutlines)
|
|
SwitchSidebarToOutlinesTab();
|
|
else
|
|
SwitchSidebarToPagesTab();
|
|
// Re-evaluate the page-list edge overlays immediately. 98SE sets the theme
|
|
// multiplier to zero; modern themes leave it at the default full strength.
|
|
SyncPageListEdgeFades();
|
|
RefreshSelectionAccent();
|
|
// Both panes carry local border-thickness state. Rebuild both so an inactive pane cannot
|
|
// retain the previous theme's right-edge geometry until it happens to receive focus.
|
|
Viewer.RebuildTabStripExt();
|
|
ViewerB.RebuildTabStripExt();
|
|
// The signature popup is built from snapshot (FindResource) colors, so rebuild it in place
|
|
// if it's open so it picks up the new theme without the user having to close and reopen it.
|
|
if (_signaturePopup is not null) ShowSignaturePopup();
|
|
|
|
// The keyboard's layer selectors use the current theme's actual button template
|
|
// (98SE raised bevel versus the modern card button), so rebuild the lazy board when
|
|
// that template family changes. Key labels and mappings remain table-driven.
|
|
if (_kbBuilt)
|
|
{
|
|
var layer = _kbLayer;
|
|
_kbBuilt = false;
|
|
BuildKeyboardView();
|
|
SetKbLayer(layer);
|
|
}
|
|
|
|
// Code-built annotate bars can be open in either pane. Refresh both panes so a bar
|
|
// cannot retain the accent or control template from the theme that created it.
|
|
RefreshOpenAnnotationBars();
|
|
}
|
|
|
|
private void ThemeDarkRadio_Checked(object sender, RoutedEventArgs e) => SelectTheme(Theme.Dark);
|
|
private void ThemeLightRadio_Checked(object sender, RoutedEventArgs e) => SelectTheme(Theme.Light);
|
|
private void ThemeHCRadio_Checked(object sender, RoutedEventArgs e) => SelectTheme(Theme.Black);
|
|
private void Theme98SERadio_Checked(object sender, RoutedEventArgs e) => SelectTheme(Theme.SE98);
|
|
private void ThemeBloodRadio_Checked(object sender, RoutedEventArgs e) => SelectTheme(Theme.Blood);
|
|
private void ThemeGreedRadio_Checked(object sender, RoutedEventArgs e) => SelectTheme(Theme.Greed);
|
|
private void ThemeCyanoticRadio_Checked(object sender, RoutedEventArgs e) => SelectTheme(Theme.Cyanotic);
|
|
private void ThemeEctoplasmRadio_Checked(object sender, RoutedEventArgs e) => SelectTheme(Theme.Ectoplasm);
|
|
private void ThemeDecayRadio_Checked(object sender, RoutedEventArgs e) => SelectTheme(Theme.Decay);
|
|
private void ThemeMourningRadio_Checked(object sender, RoutedEventArgs e) => SelectTheme(Theme.Mourning);
|
|
private void ThemeSepulchreRadio_Checked(object sender, RoutedEventArgs e) => SelectTheme(Theme.Sepulchre);
|
|
private void ThemeDeliriumRadio_Checked(object sender, RoutedEventArgs e) => SelectTheme(Theme.Delirium);
|
|
private void ThemeMalaiseRadio_Checked(object sender, RoutedEventArgs e) => SelectTheme(Theme.Malaise);
|
|
|
|
private void SelectTheme(Theme theme)
|
|
{
|
|
bool wasOpen = ThemeFlyout is not null && ThemeFlyout.IsOpen;
|
|
ThemeManager.Apply(theme);
|
|
UpdateAccentStrip(animate: true);
|
|
// Intentionally leave the flyout open so the user can try another theme right away
|
|
// without reopening the submenu. The theme swap's side effects (tab strip rebuild,
|
|
// tool re-select) can still knock the popup closed behind our back, so check once
|
|
// layout has settled and quietly reopen it in place if that happened.
|
|
if (wasOpen)
|
|
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Background,
|
|
(Action)(() => { if (ThemeFlyout is not null && !ThemeFlyout.IsOpen) ThemeFlyout.IsOpen = true; }));
|
|
}
|
|
|
|
// #199 (Ryokoxx's design): ONE vertical strip of six swatches beside the theme list,
|
|
// repainted per family, instead of a row under each accented theme. The list height is
|
|
// identical for every theme, so the bottom-pinned card never moves; only the strip's
|
|
// width animates, growing rightward from the pinned left edge. Per-family accent memory
|
|
// in ThemeManager is untouched.
|
|
private void AccentStripDot_Click(object sender, MouseButtonEventArgs e) => HandleAccentDot(sender, _stripFamily);
|
|
|
|
private void HandleAccentDot(object sender, Theme family)
|
|
{
|
|
if (sender is not FrameworkElement fe || fe.Tag is not string tag) return;
|
|
if (!Enum.TryParse<DarkAccent>(tag, out var accent)) return;
|
|
ThemeManager.ApplyAccent(family, accent); // persists for that family; reapplies if active
|
|
RingAccentStrip();
|
|
}
|
|
|
|
// Per-family swatch colors, in each family's display order (moved here from the four
|
|
// retired XAML accent rows; the strip repaints these onto its six shared dots).
|
|
private static readonly (DarkAccent Accent, string Hex)[] DarkStripColors =
|
|
[(DarkAccent.Red, "#DD504B"), (DarkAccent.Orange, "#E8962C"), (DarkAccent.Green, "#1EA54C"),
|
|
(DarkAccent.Teal, "#1FB8A8"), (DarkAccent.Blue, "#4580D9"), (DarkAccent.Purple, "#B982E3")];
|
|
private static readonly (DarkAccent Accent, string Hex)[] LightStripColors =
|
|
[(DarkAccent.Red, "#931A1A"), (DarkAccent.Orange, "#C7710F"), (DarkAccent.Green, "#1B5E20"),
|
|
(DarkAccent.Teal, "#0D827E"), (DarkAccent.Blue, "#18608E"), (DarkAccent.Purple, "#5A1690")];
|
|
private static readonly (DarkAccent Accent, string Hex)[] BlackStripColors =
|
|
[(DarkAccent.Red, "#FF2929"), (DarkAccent.Orange, "#FF910A"), (DarkAccent.Green, "#00FF66"),
|
|
(DarkAccent.Teal, "#0AFFE7"), (DarkAccent.Blue, "#298DFF"), (DarkAccent.Purple, "#B829FF")];
|
|
private static readonly (DarkAccent Accent, string Hex)[] SE98StripColors =
|
|
[(DarkAccent.Red, "#800040"), (DarkAccent.Orange, "#A05000"), (DarkAccent.Green, "#006000"),
|
|
(DarkAccent.Teal, "#008080"), (DarkAccent.Blue, "#000080"), (DarkAccent.Purple, "#5A376E")];
|
|
|
|
private static (DarkAccent Accent, string Hex)[] StripColorsFor(Theme family) => family switch
|
|
{
|
|
Theme.Light => LightStripColors,
|
|
Theme.Black => BlackStripColors,
|
|
Theme.SE98 => SE98StripColors,
|
|
_ => DarkStripColors,
|
|
};
|
|
|
|
private Theme _stripFamily = Theme.Dark; // the family the strip's dots currently show
|
|
private bool _stripOpen;
|
|
private const double AccentStripWidth = 39; // 1px rule + 10 gap + 26 swatch + 2 air
|
|
private const double AccentStripSlideMs = 180;
|
|
|
|
private Border[] StripDots =>
|
|
[AccentStripDot0, AccentStripDot1, AccentStripDot2, AccentStripDot3, AccentStripDot4, AccentStripDot5];
|
|
|
|
private void PopulateAccentStrip(Theme family)
|
|
{
|
|
var colors = StripColorsFor(family);
|
|
var dots = StripDots;
|
|
for (int i = 0; i < dots.Length; i++)
|
|
{
|
|
var c = (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(colors[i].Hex);
|
|
dots[i].Background = new System.Windows.Media.SolidColorBrush(c);
|
|
dots[i].Tag = colors[i].Accent.ToString();
|
|
}
|
|
_stripFamily = family;
|
|
RingAccentStrip();
|
|
}
|
|
|
|
// Ring the strip's selected swatch for the family it is showing.
|
|
private void RingAccentStrip()
|
|
{
|
|
if (AccentStrip is null) return;
|
|
var ring = (System.Windows.Media.Brush)FindResource("TextBrush");
|
|
var chosen = _stripFamily switch
|
|
{
|
|
Theme.Light => ThemeManager.LightAccentChoice,
|
|
Theme.Black => ThemeManager.BlackAccentChoice,
|
|
Theme.SE98 => ThemeManager.SE98AccentChoice,
|
|
_ => ThemeManager.DarkAccentChoice,
|
|
};
|
|
foreach (var dot in StripDots)
|
|
{
|
|
bool sel = dot.Tag is string t && Enum.TryParse<DarkAccent>(t, out var a) && a == chosen;
|
|
dot.BorderBrush = sel ? ring : System.Windows.Media.Brushes.Transparent;
|
|
}
|
|
}
|
|
|
|
private void UpdateAccentStrip(bool animate)
|
|
{
|
|
var cur = ThemeManager.Current;
|
|
bool show = cur is Theme.Dark or Theme.Light or Theme.Black or Theme.SE98;
|
|
if (show)
|
|
{
|
|
if (animate && _stripOpen && _stripFamily != cur)
|
|
{
|
|
// Accented -> accented: the strip stays open and the swatches CROSSFADE.
|
|
// Re-running the open animation here snapped the strip shut and back.
|
|
var target = cur;
|
|
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(90));
|
|
fadeOut.Completed += (_, __) =>
|
|
{
|
|
PopulateAccentStrip(target);
|
|
AccentStrip.BeginAnimation(OpacityProperty,
|
|
new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(90)));
|
|
};
|
|
AccentStrip.BeginAnimation(OpacityProperty, fadeOut);
|
|
}
|
|
else PopulateAccentStrip(cur);
|
|
}
|
|
SlideAccentStrip(show, animate);
|
|
}
|
|
|
|
// Width slide, eased - one element animates now, so the old linear lockstep constraint
|
|
// that kept two rows summing to a constant height no longer applies.
|
|
private void SlideAccentStrip(bool show, bool animate)
|
|
{
|
|
if (show == _stripOpen && animate) return; // already there; crossfade handles content
|
|
_stripOpen = show;
|
|
AccentStripHost.BeginAnimation(WidthProperty, null); // drop any in-flight slide
|
|
if (!animate) { AccentStripHost.Width = show ? AccentStripWidth : 0; return; }
|
|
double from = double.IsNaN(AccentStripHost.Width) ? AccentStripHost.ActualWidth : AccentStripHost.Width;
|
|
var anim = new System.Windows.Media.Animation.DoubleAnimation(from, show ? AccentStripWidth : 0,
|
|
TimeSpan.FromMilliseconds(AccentStripSlideMs))
|
|
{
|
|
EasingFunction = new System.Windows.Media.Animation.QuadraticEase
|
|
{ EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut }
|
|
};
|
|
anim.Completed += (_, __) =>
|
|
{
|
|
AccentStripHost.BeginAnimation(WidthProperty, null);
|
|
AccentStripHost.Width = _stripOpen ? AccentStripWidth : 0;
|
|
};
|
|
AccentStripHost.BeginAnimation(WidthProperty, anim);
|
|
}
|
|
|
|
// Localized display name for each theme, shown on the picker row.
|
|
private string ThemeDisplayName(Theme t) => t switch
|
|
{
|
|
Theme.Light => Loc("Str_Theme_Light"),
|
|
Theme.Black => Loc("Str_Theme_Black"),
|
|
Theme.SE98 => Loc("Str_Theme_98SE"),
|
|
Theme.Blood => Loc("Str_Theme_Blood"),
|
|
Theme.Greed => Loc("Str_Theme_Greed"),
|
|
Theme.Cyanotic => Loc("Str_Theme_Cyanotic"),
|
|
Theme.Ectoplasm => Loc("Str_Theme_Ectoplasm"),
|
|
Theme.Decay => Loc("Str_Theme_Decay"),
|
|
Theme.Mourning => Loc("Str_Theme_Mourning"),
|
|
Theme.Sepulchre => Loc("Str_Theme_Sepulchre"),
|
|
Theme.Delirium => Loc("Str_Theme_Delirium"),
|
|
Theme.Malaise => Loc("Str_Theme_Malaise"),
|
|
_ => Loc("Str_Theme_Dark"),
|
|
};
|
|
|
|
// #211: translator test mode - when the external translation file live-reloads, re-run the
|
|
// same rebuilds a language switch does, so code-built captions update alongside the
|
|
// DynamicResource strings. Called once from the window ctor; the event only ever fires
|
|
// when the app was started with --lang-file.
|
|
internal void HookExternalLangReload()
|
|
=> KillerPDF.Services.LocaleManager.ExternalReloaded += () =>
|
|
{
|
|
ApplyToolNumberTooltips();
|
|
BuildToolbarMenu();
|
|
BuildContextMenu();
|
|
ApplyToolbarAppearance();
|
|
RefreshOpenAnnotationBars();
|
|
};
|
|
|
|
private void LangEnRadio_Checked(object sender, RoutedEventArgs e) => SelectLocale(KillerPDF.Services.Locale.EnUS);
|
|
private void LangCsRadio_Checked(object sender, RoutedEventArgs e) => SelectLocale(KillerPDF.Services.Locale.CsCZ);
|
|
private void LangEsRadio_Checked(object sender, RoutedEventArgs e) => SelectLocale(KillerPDF.Services.Locale.Es);
|
|
private void LangFrRadio_Checked(object sender, RoutedEventArgs e) => SelectLocale(KillerPDF.Services.Locale.Fr);
|
|
private void LangZhTWRadio_Checked(object sender, RoutedEventArgs e) => SelectLocale(KillerPDF.Services.Locale.ZhTW);
|
|
private void LangZhCNRadio_Checked(object sender, RoutedEventArgs e) => SelectLocale(KillerPDF.Services.Locale.ZhCN);
|
|
private void LangBnRadio_Checked(object sender, RoutedEventArgs e) => SelectLocale(KillerPDF.Services.Locale.Bn);
|
|
private void LangTrRadio_Checked(object sender, RoutedEventArgs e) => SelectLocale(KillerPDF.Services.Locale.TrTR);
|
|
private void LangDeRadio_Checked(object sender, RoutedEventArgs e) => SelectLocale(KillerPDF.Services.Locale.De);
|
|
private void LangJaRadio_Checked(object sender, RoutedEventArgs e) => SelectLocale(KillerPDF.Services.Locale.JaJP);
|
|
private void LangPlRadio_Checked(object sender, RoutedEventArgs e) => SelectLocale(KillerPDF.Services.Locale.PlPL);
|
|
private void LangHuRadio_Checked(object sender, RoutedEventArgs e) => SelectLocale(KillerPDF.Services.Locale.HuHU);
|
|
|
|
private void SelectLocale(KillerPDF.Services.Locale loc)
|
|
{
|
|
KillerPDF.Services.LocaleManager.Apply(loc);
|
|
ApplyToolNumberTooltips(); // re-append the numbers to the now-localized tool tooltips
|
|
BuildToolbarMenu(); // the toolbar right-click picker's items carry Loc() captions
|
|
LangFlyout.IsOpen = false; // a pick closes the rail flyout, like the accordion used to collapse
|
|
|
|
// The status bar text is a formatted string (not a DynamicResource), so it keeps the
|
|
// language it was last set in. Re-set it in the new locale instead of leaving it stale.
|
|
if (_doc is not null && PageList.SelectedIndex >= 0)
|
|
SetStatus(string.Format(Loc("Str_PageOf"), PageList.SelectedIndex + 1, _doc.PageCount));
|
|
else
|
|
SetStatus(Loc("Str_Ready"));
|
|
|
|
// The canvas right-click menu is built once with Loc() values captured at build time,
|
|
// so rebuild it in the new language. (The sidebar menu is rebuilt on each open.)
|
|
BuildContextMenu();
|
|
|
|
// Toolbar captions are built with Loc() at apply time (they don't auto-update like a
|
|
// DynamicResource), so rebuild the toolbar on every language change. Harmless for the
|
|
// icon-only modes; refreshes the captions for Text-beside / Text-under / Text-only.
|
|
ApplyToolbarAppearance();
|
|
|
|
// Refresh code-built annotate bars in both panes. A language change comes from a
|
|
// window flyout, so the pane containing the visible bar is not guaranteed to be the
|
|
// current ActiveViewer by the time these controls are rebuilt.
|
|
RefreshOpenAnnotationBars();
|
|
|
|
// Page thumbnails and outline tooltips snapshot Loc() strings when built; rebuild both
|
|
// lists so their "Page N" labels switch to the new language immediately.
|
|
RefreshPageList();
|
|
RefreshOutlines();
|
|
|
|
// A visible signature popup is built with Loc() too; rebuild it so its section headers and
|
|
// pen labels switch immediately.
|
|
RefreshSignaturePopupLanguage();
|
|
|
|
// The fit-mode terms differ in length per language; resize the zoom box so the longest never clips.
|
|
AdjustZoomBoxWidth();
|
|
}
|
|
|
|
private void RefreshOpenAnnotationBars()
|
|
{
|
|
var active = ActiveViewer;
|
|
var other = ReferenceEquals(active, Viewer) ? ViewerB : Viewer;
|
|
// Refresh the inactive pane first. BuildBarHost keeps a few drag/minimize references
|
|
// at window scope, so finishing on the active pane leaves those references pointed at
|
|
// the controls the shared toolbar is currently operating.
|
|
if (!ReferenceEquals(other, active)) RefreshOpenAnnotationBars(other);
|
|
RefreshOpenAnnotationBars(active);
|
|
}
|
|
|
|
private void RefreshOpenAnnotationBars(KillerPDF.Controls.PdfViewer viewer)
|
|
{
|
|
((KillerPDF.Features.IViewerHost)this).RunWithViewerContext(viewer, () =>
|
|
{
|
|
var tool = _annotBarTool;
|
|
if (tool == EditTool.Text)
|
|
ShowTextSettings();
|
|
else if (tool is EditTool bt && bt is EditTool.Draw or EditTool.Highlight
|
|
or EditTool.Line or EditTool.Strikethrough or EditTool.Underline or EditTool.Shape)
|
|
ShowDrawSettings(bt);
|
|
|
|
RebuildCropBarForLocale();
|
|
});
|
|
}
|
|
|
|
// Size the editable zoom ComboBox to its widest item in the CURRENT language, so localized fit-mode
|
|
// terms (e.g. French "Ajuster a la largeur") are never clipped. Re-run on locale change and at load.
|
|
private void AdjustZoomBoxWidth()
|
|
{
|
|
if (ZoomBox is null) return;
|
|
try
|
|
{
|
|
double pixelsPerDip = VisualTreeHelper.GetDpi(ZoomBox).PixelsPerDip;
|
|
var typeface = new System.Windows.Media.Typeface(
|
|
ZoomBox.FontFamily, ZoomBox.FontStyle, ZoomBox.FontWeight, ZoomBox.FontStretch);
|
|
double emSize = ZoomBox.FontSize > 0 ? ZoomBox.FontSize : 12;
|
|
double max = 0;
|
|
foreach (var item in ZoomBox.Items)
|
|
{
|
|
string text = item is System.Windows.Controls.ComboBoxItem ci
|
|
? ci.Content?.ToString() ?? ""
|
|
: item?.ToString() ?? "";
|
|
var ft = new System.Windows.Media.FormattedText(
|
|
text, System.Globalization.CultureInfo.CurrentCulture, System.Windows.FlowDirection.LeftToRight,
|
|
typeface, emSize, System.Windows.Media.Brushes.Black, pixelsPerDip);
|
|
if (ft.WidthIncludingTrailingWhitespace > max) max = ft.WidthIncludingTrailingWhitespace;
|
|
}
|
|
// Measured text + text insets (12) + chevron column (22) + borders (2), with
|
|
// breathing room between the value and arrow. Keep the compact toolbar control
|
|
// usable even when the current locale happens to have very short labels.
|
|
ZoomBox.Width = System.Math.Max(88, System.Math.Ceiling(max) + 40);
|
|
}
|
|
catch { /* best-effort; leave the XAML default width */ }
|
|
}
|
|
|
|
// Native name (autonym) for each language, shown in the picker regardless of UI locale.
|
|
private static string LangDisplayName(KillerPDF.Services.Locale loc) => loc switch
|
|
{
|
|
KillerPDF.Services.Locale.CsCZ => "Čeština",
|
|
KillerPDF.Services.Locale.Es => "Español",
|
|
KillerPDF.Services.Locale.Fr => "Français",
|
|
KillerPDF.Services.Locale.ZhTW => "中文 (繁體)",
|
|
KillerPDF.Services.Locale.ZhCN => "中文 (简体)",
|
|
KillerPDF.Services.Locale.Bn => "বাংলা",
|
|
KillerPDF.Services.Locale.TrTR => "Türkçe",
|
|
KillerPDF.Services.Locale.De => "Deutsch",
|
|
KillerPDF.Services.Locale.JaJP => "日本語",
|
|
_ => "English",
|
|
};
|
|
|
|
private void ViewContinuousRadio_Checked(object sender, RoutedEventArgs e) => SelectViewMode(ViewMode.Continuous);
|
|
private void ViewSingleRadio_Checked(object sender, RoutedEventArgs e) => SelectViewMode(ViewMode.Single);
|
|
private void ViewTwoPageRadio_Checked(object sender, RoutedEventArgs e) => SelectViewMode(ViewMode.TwoPage);
|
|
private void ViewGridRadio_Checked(object sender, RoutedEventArgs e) => SelectViewMode(ViewMode.Grid);
|
|
|
|
// #193: book layout toggle (cover page alone in Two-Page). Global, persisted; both panes
|
|
// re-run their current layout so an active Two-Page view re-pairs in place. Reached from
|
|
// the page context menu while in Two-Page, and the bare B key.
|
|
internal void ToggleBookMode()
|
|
{
|
|
App.SetSetting("TwoPageBook", Controls.PdfViewer.BookMode ? "0" : "1");
|
|
Viewer.ReapplyViewMode();
|
|
ViewerB.ReapplyViewMode();
|
|
}
|
|
|
|
// ── Toolbar appearance (right-click picker on the bar) ────────────
|
|
// Hover tooltips stay on in every mode, so the text modes are about preference, not
|
|
// discoverability.
|
|
// TWO AXES, NOT ONE (family standard, 2026-07-30; KillerUI/Shell/ToolbarStyle.cs is
|
|
// the reference). The old five-way ToolbarStyle could not express "large icons WITH text" -
|
|
// icon size and text placement were never one axis, they only looked like one. The old
|
|
// enum survives solely so an existing install's saved setting migrates (InitToolbarStyle).
|
|
private enum ToolbarStyle { SmallIcons, LargeIcons, TextBeside, TextUnder, TextOnly }
|
|
private enum ToolbarIconSize { Small, Large }
|
|
private enum ToolbarLabelMode { None, Beside, Under, Only }
|
|
// Large icons with the text underneath is the family default for new installs; migration
|
|
// keeps whatever an existing install was on.
|
|
private ToolbarIconSize _toolbarIconSize = ToolbarIconSize.Large;
|
|
private ToolbarLabelMode _toolbarLabelMode = ToolbarLabelMode.Under;
|
|
|
|
// Each toolbar icon button paired with its glyph and label-resource key, built once so the
|
|
// appearance can be rebuilt without re-walking the tree.
|
|
private readonly List<(Button btn, string glyph, string labelKey)> _toolbarButtons = [];
|
|
|
|
// Maps each toolbar glyph (Segoe MDL2 Assets code point) to its caption string key. Buttons
|
|
// whose glyph isn't listed keep their icon with no caption.
|
|
private static readonly Dictionary<string, string> _toolbarLabelKeys = new()
|
|
{
|
|
[""] = "Str_Lbl_New",
|
|
[""] = "Str_Lbl_Open",
|
|
[""] = "Str_Lbl_Close",
|
|
[""] = "Str_Lbl_Save",
|
|
[""] = "Str_Lbl_Flatten",
|
|
[""] = "Str_Lbl_Ocr",
|
|
[""] = "Str_Lbl_Print",
|
|
[""] = "Str_Lbl_Merge",
|
|
[""] = "Str_Lbl_Extract",
|
|
[""] = "Str_Lbl_Delete",
|
|
[""] = "Str_Lbl_MoveUp",
|
|
[""] = "Str_Lbl_MoveDown",
|
|
[""] = "Str_Lbl_Select",
|
|
[""] = "Str_Lbl_Text",
|
|
[""] = "Str_Lbl_Highlight",
|
|
[""] = "Str_Lbl_Strike",
|
|
[""] = "Str_Lbl_Underline",
|
|
[""] = "Str_Lbl_Draw",
|
|
[""] = "Str_Lbl_Crop",
|
|
[""] = "Str_Lbl_Rotate",
|
|
[""] = "Str_Lbl_Image",
|
|
[""] = "Str_Lbl_Signature",
|
|
[""] = "Str_Lbl_Undo",
|
|
[""] = "Str_Lbl_Clear",
|
|
[""] = "Str_Lbl_ZoomOut",
|
|
[""] = "Str_Lbl_ZoomIn",
|
|
[""] = "Str_Lbl_Highlight", // current highlighter glyph (see ToolHighlightBtn)
|
|
[""] = "Str_Lbl_Line", // repurposed ToolUnderlineBtn glyph = the Line tool
|
|
[""] = "Str_Lbl_ZoomOut", // boxed minus (RemoveFrom) - new zoom-out glyph
|
|
[""] = "Str_Lbl_ZoomIn", // boxed plus (AddTo) - new zoom-in glyph
|
|
[""] = "Str_Lbl_Search", // magnifier - toolbar search button
|
|
[""] = "Str_Lbl_Stamp", // page-number / watermark stamp tool
|
|
[""] = "Str_Lbl_Shape", // Shapes tool (rect / ellipse / polygon)
|
|
};
|
|
|
|
// Walks LeftBar + RightBar once and records each icon button with its glyph + label key.
|
|
private void IndexToolbarButtons()
|
|
{
|
|
_toolbarButtons.Clear();
|
|
foreach (Panel? bar in new Panel?[] { LeftBar, RightBar })
|
|
{
|
|
if (bar is null) continue;
|
|
foreach (var btn in DescendantButtons(bar))
|
|
if (btn.Content is string g && g.Length > 0 && _toolbarLabelKeys.TryGetValue(g, out var key))
|
|
_toolbarButtons.Add((btn, g, key));
|
|
}
|
|
}
|
|
|
|
private static IEnumerable<Button> DescendantButtons(DependencyObject root)
|
|
{
|
|
foreach (var obj in LogicalTreeHelper.GetChildren(root))
|
|
{
|
|
if (obj is Button b) yield return b;
|
|
if (obj is DependencyObject d)
|
|
foreach (var nested in DescendantButtons(d)) yield return nested;
|
|
}
|
|
}
|
|
|
|
// Rebuilds one toolbar button's content and size for the current mode. withLabel=false forces
|
|
// icon-only (used when Text-beside has to shed captions to fit a narrow window). Deliberately
|
|
// never touches Foreground/Background, so theme accents, the dirty-save tint, and the
|
|
// active-tool highlight survive (the caption TextBlocks inherit the button's foreground and
|
|
// the template's drop shadow).
|
|
private void SetToolbarButton(Button btn, string glyph, string key, bool withLabel)
|
|
{
|
|
bool large = _toolbarIconSize == ToolbarIconSize.Large;
|
|
bool beside = _toolbarLabelMode == ToolbarLabelMode.Beside;
|
|
bool under = _toolbarLabelMode == ToolbarLabelMode.Under;
|
|
bool textOnly = _toolbarLabelMode == ToolbarLabelMode.Only;
|
|
// The size axis ALONE decides the glyph - the old engine let the text mode force
|
|
// 16/20, which is exactly the coupling the two-axis split removes.
|
|
double glyphSize = large ? 20 : 14;
|
|
btn.FontSize = glyphSize;
|
|
|
|
// Text only: caption, no icon (nothing to shed - there'd be nothing left).
|
|
if (textOnly)
|
|
{
|
|
btn.Width = double.NaN; btn.MinWidth = 0; btn.Height = 34; btn.Padding = new Thickness(8, 5, 8, 5);
|
|
btn.Content = new TextBlock
|
|
{
|
|
Text = Loc(key),
|
|
FontFamily = UiKit.UiFont,
|
|
FontSize = 12,
|
|
VerticalAlignment = VerticalAlignment.Center
|
|
};
|
|
return;
|
|
}
|
|
|
|
// Text beside the icon, while it still fits.
|
|
if (beside && withLabel)
|
|
{
|
|
btn.Width = double.NaN; btn.MinWidth = 0; btn.Height = 34; btn.Padding = new Thickness(8, 5, 8, 5);
|
|
var row = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
|
|
row.Children.Add(new TextBlock
|
|
{
|
|
Text = glyph,
|
|
FontFamily = UiKit.IconFont,
|
|
FontSize = glyphSize,
|
|
VerticalAlignment = VerticalAlignment.Center
|
|
});
|
|
row.Children.Add(new TextBlock
|
|
{
|
|
Text = Loc(key),
|
|
FontFamily = UiKit.UiFont,
|
|
FontSize = 12,
|
|
Margin = new Thickness(7, 0, 0, 0),
|
|
VerticalAlignment = VerticalAlignment.Center
|
|
});
|
|
btn.Content = row;
|
|
return;
|
|
}
|
|
|
|
// Text under the icon: the icon stacked over a small caption, while it still fits.
|
|
if (under && withLabel)
|
|
{
|
|
btn.Width = double.NaN; btn.MinWidth = 0; btn.Height = large ? 56 : 52; btn.Padding = new Thickness(6, 4, 6, 4);
|
|
var col = new StackPanel { Orientation = Orientation.Vertical, HorizontalAlignment = HorizontalAlignment.Center };
|
|
col.Children.Add(new TextBlock
|
|
{
|
|
Text = glyph,
|
|
FontFamily = UiKit.IconFont,
|
|
FontSize = glyphSize,
|
|
HorizontalAlignment = HorizontalAlignment.Center
|
|
});
|
|
col.Children.Add(new TextBlock
|
|
{
|
|
Text = Loc(key),
|
|
FontFamily = UiKit.UiFont,
|
|
FontSize = 10,
|
|
Margin = new Thickness(0, 2, 0, 0),
|
|
HorizontalAlignment = HorizontalAlignment.Center,
|
|
TextAlignment = TextAlignment.Center
|
|
});
|
|
btn.Content = col;
|
|
return;
|
|
}
|
|
|
|
// Icon only: the None mode, or Beside/Under after a caption was shed. The box is the
|
|
// size axis's alone (family numbers).
|
|
btn.Width = large ? 46 : 36;
|
|
btn.MinWidth = 0;
|
|
btn.Height = large ? 42 : 32;
|
|
btn.Padding = new Thickness(10, 6, 10, 6);
|
|
btn.Content = glyph;
|
|
}
|
|
|
|
// Order in which Text-beside buttons shed their captions when the bar runs short of room:
|
|
// lowest rank sheds first. Zoom and Select go first (their icons are obvious); the annotation
|
|
// tools keep their captions longest because that is where the labels earn their space.
|
|
private static int LabelStripRank(string key) => key switch
|
|
{
|
|
"Str_Lbl_ZoomOut" or "Str_Lbl_ZoomIn" => 0,
|
|
"Str_Lbl_Select" => 1,
|
|
"Str_Lbl_Undo" or "Str_Lbl_Clear" => 2,
|
|
"Str_Lbl_New" or "Str_Lbl_Open" or "Str_Lbl_Close"
|
|
or "Str_Lbl_Save" or "Str_Lbl_Flatten" or "Str_Lbl_Ocr" or "Str_Lbl_Print" => 3,
|
|
"Str_Lbl_MoveUp" or "Str_Lbl_MoveDown" or "Str_Lbl_Delete"
|
|
or "Str_Lbl_Merge" or "Str_Lbl_Extract" => 4,
|
|
_ => 5, // annotation tools keep their labels longest
|
|
};
|
|
|
|
// Rebuilds every toolbar button for the current mode (captions on where applicable), then
|
|
// lets ReflowToolbar shed captions and/or collapse groups to fit the current width.
|
|
private void ApplyToolbarAppearance()
|
|
{
|
|
if (_toolbarButtons.Count == 0) return;
|
|
foreach (var (btn, glyph, key) in _toolbarButtons)
|
|
SetToolbarButton(btn, glyph, key, withLabel: true);
|
|
// Open / Save / OCR are split buttons (main half + overlapping dropdown chevron). Their chrome
|
|
// is applied from one place (ApplySplitButtonChrome) so the three never drift apart again.
|
|
bool textMode = _toolbarLabelMode != ToolbarLabelMode.None;
|
|
ApplySplitButtonChrome(textMode);
|
|
SyncToolbarMenuChecks();
|
|
InvalidateToolbarReflow(); // the buttons themselves changed, so the last decision is void
|
|
ReflowToolbar();
|
|
}
|
|
|
|
// Single source of truth for the split-button chrome shared by Open, Save, and OCR. Each entry is
|
|
// (main half, dropdown chevron, split style, plain style). In icon modes the chevron overlaps the
|
|
// main half (-6) for the connected split look; with a caption the button widens, so the chevron sits
|
|
// clear of the text (margin 1) and the main half drops its split (hover-inset) style.
|
|
private void ApplySplitButtonChrome(bool textMode)
|
|
{
|
|
var chevMargin = textMode ? new Thickness(1, 0, 0, 0) : new Thickness(-6, 0, 0, 0);
|
|
var splits = new (Button? Main, Button? Chevron, string Split, string Plain)[]
|
|
{
|
|
(OpenFileBtn, OpenRecentBtn, "ToolbarSplitMain", "ToolbarButton"),
|
|
(SaveAsBtn, SaveMenuBtn, "ToolbarSplitMainAccent", "ToolbarButtonAccent"),
|
|
(OcrBtn, OcrMenuBtn, "ToolbarSplitMain", "ToolbarButton"),
|
|
};
|
|
foreach (var s in splits)
|
|
{
|
|
if (s.Chevron is not null) s.Chevron.Margin = chevMargin;
|
|
if (s.Main is not null) s.Main.Style = (Style)FindResource(textMode ? s.Plain : s.Split);
|
|
}
|
|
}
|
|
|
|
/// <summary>Restores the saved axes, migrating the retired five-way "ToolbarStyle" key so
|
|
/// an existing install keeps the bar it was left on instead of silently resetting. Only
|
|
/// falls back to migration when the new keys are absent. Called once from the ctor.</summary>
|
|
private void InitToolbarStyle()
|
|
{
|
|
bool haveNew = false;
|
|
if (Enum.TryParse<ToolbarIconSize>(App.GetSetting("ToolbarIconSize"), out var s)) { _toolbarIconSize = s; haveNew = true; }
|
|
if (Enum.TryParse<ToolbarLabelMode>(App.GetSetting("ToolbarLabels"), out var l)) { _toolbarLabelMode = l; haveNew = true; }
|
|
// Restore directly - SetToolbarHidden would overwrite the "Ready" status line at startup.
|
|
if (App.GetSetting("ToolbarHidden") == "1")
|
|
{ _toolbarHidden = true; ToolbarRowBorder.Visibility = Visibility.Collapsed; }
|
|
if (!haveNew && Enum.TryParse<ToolbarStyle>(App.GetSetting("ToolbarStyle"), out var old))
|
|
{
|
|
switch (old)
|
|
{
|
|
case ToolbarStyle.SmallIcons: _toolbarIconSize = ToolbarIconSize.Small; _toolbarLabelMode = ToolbarLabelMode.None; break;
|
|
case ToolbarStyle.LargeIcons: _toolbarIconSize = ToolbarIconSize.Large; _toolbarLabelMode = ToolbarLabelMode.None; break;
|
|
// The old text modes never said what size the icon was, so they keep the default size.
|
|
case ToolbarStyle.TextBeside: _toolbarLabelMode = ToolbarLabelMode.Beside; break;
|
|
case ToolbarStyle.TextUnder: _toolbarLabelMode = ToolbarLabelMode.Under; break;
|
|
case ToolbarStyle.TextOnly: _toolbarLabelMode = ToolbarLabelMode.Only; break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void SetToolbarIconSize(ToolbarIconSize size)
|
|
{
|
|
_toolbarIconSize = size;
|
|
App.SetSetting("ToolbarIconSize", size.ToString());
|
|
ApplyToolbarAppearance();
|
|
}
|
|
|
|
private void SetToolbarLabelMode(ToolbarLabelMode mode)
|
|
{
|
|
_toolbarLabelMode = mode;
|
|
App.SetSetting("ToolbarLabels", mode.ToString());
|
|
ApplyToolbarAppearance();
|
|
}
|
|
|
|
// #215: a reading view without the toolbar, while the window stays a normal window the
|
|
// user can still switch away from (full screen was the wrong tool for that). On the
|
|
// toolbar's right-click menu and Ctrl+F11; the tools all remain reachable by shortcut.
|
|
private bool _toolbarHidden;
|
|
|
|
internal void ToggleToolbarHidden() => SetToolbarHidden(!_toolbarHidden);
|
|
|
|
private void SetToolbarHidden(bool hidden)
|
|
{
|
|
_toolbarHidden = hidden;
|
|
ToolbarRowBorder.Visibility = hidden ? Visibility.Collapsed : Visibility.Visible;
|
|
App.SetSetting("ToolbarHidden", hidden ? "1" : "0");
|
|
SyncToolbarMenuChecks();
|
|
SetStatus(Loc(hidden ? "Str_St_ToolbarHidden" : "Str_St_ToolbarShown"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The right-click picker on the toolbar: TWO radio groups, separated - icon size, then
|
|
/// where the text goes. One flat list of five could not express "large icons WITH text",
|
|
/// which is the whole reason for the split. Items stay open on click so modes can be
|
|
/// compared without reopening (the behavior the old Settings flyout had).
|
|
/// </summary>
|
|
private void BuildToolbarMenu()
|
|
{
|
|
ToolbarMenu.Items.Clear();
|
|
ToolbarMenu.Items.Add(new MenuItem { Header = Loc("Str_Toolbar_Header"), IsEnabled = false });
|
|
ToolbarMenu.Items.Add(new Separator());
|
|
|
|
foreach (var (size, key, gesture) in new[]
|
|
{ (ToolbarIconSize.Small, "Str_Toolbar_SmallIcons", "Ctrl+Shift+1"),
|
|
(ToolbarIconSize.Large, "Str_Toolbar_LargeIcons", "Ctrl+Shift+2") })
|
|
{
|
|
var mi = new MenuItem { Header = Loc(key), Tag = size, IsCheckable = true,
|
|
IsChecked = size == _toolbarIconSize, StaysOpenOnClick = true,
|
|
InputGestureText = gesture };
|
|
var v = size;
|
|
// Checking one unchecks the rest through SyncToolbarMenuChecks, so this behaves as
|
|
// a radio group without needing RadioButton plumbing inside a menu.
|
|
mi.Click += (_, _2) => SetToolbarIconSize(v);
|
|
ToolbarMenu.Items.Add(mi);
|
|
}
|
|
|
|
ToolbarMenu.Items.Add(new Separator());
|
|
|
|
foreach (var (mode, key, gesture) in new[]
|
|
{ (ToolbarLabelMode.None, "Str_Toolbar_TextNone", "Ctrl+Shift+3"),
|
|
(ToolbarLabelMode.Beside, "Str_Toolbar_TextBeside", "Ctrl+Shift+4"),
|
|
(ToolbarLabelMode.Under, "Str_Toolbar_TextUnder", "Ctrl+Shift+5"),
|
|
(ToolbarLabelMode.Only, "Str_Toolbar_TextOnly", "Ctrl+Shift+6") })
|
|
{
|
|
var mi = new MenuItem { Header = Loc(key), Tag = mode, IsCheckable = true,
|
|
IsChecked = mode == _toolbarLabelMode, StaysOpenOnClick = true,
|
|
InputGestureText = gesture };
|
|
var v = mode;
|
|
mi.Click += (_, _2) => SetToolbarLabelMode(v);
|
|
ToolbarMenu.Items.Add(mi);
|
|
}
|
|
|
|
ToolbarMenu.Items.Add(new Separator());
|
|
var hide = new MenuItem { Header = Loc("Str_Toolbar_Hide"), Tag = "hide", IsCheckable = true,
|
|
IsChecked = _toolbarHidden, InputGestureText = "Alt+M" };
|
|
hide.Click += (_, _2) => ToggleToolbarHidden();
|
|
ToolbarMenu.Items.Add(hide);
|
|
}
|
|
|
|
private void SyncToolbarMenuChecks()
|
|
{
|
|
if (ToolbarMenu is null) return;
|
|
foreach (var item in ToolbarMenu.Items)
|
|
{
|
|
if (item is not MenuItem mi) continue;
|
|
if (mi.Tag is ToolbarIconSize sz)
|
|
{
|
|
mi.IsChecked = sz == _toolbarIconSize;
|
|
// Text-only has no icon to size. Gray the choice rather than hiding it, so the
|
|
// setting stays visible and comes back when text moves off Only.
|
|
mi.IsEnabled = _toolbarLabelMode != ToolbarLabelMode.Only;
|
|
}
|
|
else if (mi.Tag is ToolbarLabelMode lb) mi.IsChecked = lb == _toolbarLabelMode;
|
|
else if (mi.Tag is "hide") mi.IsChecked = _toolbarHidden;
|
|
}
|
|
}
|
|
|
|
// ── Responsive toolbar overflow ───────────────────────────────────
|
|
private bool _reflowingToolbar;
|
|
private bool _reflowQueued;
|
|
|
|
// ReflowToolbar decides which toolbar groups collapse into the overflow chevron. It must run
|
|
// live during a window resize (deferring it leaves buttons overlapping mid-drag), so the cost is
|
|
// kept low two ways: SizeChanged is coalesced to at most one reflow per render tick, and the
|
|
// reflow re-measures only the two bars instead of forcing repeated whole-tree UpdateLayout passes.
|
|
private void ToolbarGrid_SizeChanged(object sender, SizeChangedEventArgs e) => QueueReflowToolbar();
|
|
|
|
/// <summary>Toolbar width the last completed reflow decided against. Reset to -1 by anything
|
|
/// that changes what the toolbar CONTAINS (appearance mode, language), which is the only
|
|
/// other thing that can change the answer.</summary>
|
|
private double _lastReflowWidth = -1;
|
|
internal void InvalidateToolbarReflow() => _lastReflowWidth = -1;
|
|
|
|
private void QueueReflowToolbar()
|
|
{
|
|
if (_reflowQueued) return;
|
|
_reflowQueued = true;
|
|
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Render, (Action)(() =>
|
|
{
|
|
_reflowQueued = false;
|
|
// Skip when the width has not actually moved since the last decision. ReflowToolbar
|
|
// re-expands every group and caption at the start of each pass and then sheds to
|
|
// fit; that expand-then-shed is itself a layout change, which raises SizeChanged,
|
|
// which runs it again - at a width sitting on the shed boundary it oscillates
|
|
// forever. Measured at 57 reflows a second with the window pinned and nothing
|
|
// moving, which is what stopped the app ever painting.
|
|
if (ToolbarGrid != null
|
|
&& Math.Abs(ToolbarGrid.ActualWidth - _lastReflowWidth) < 0.5) return;
|
|
ReflowToolbar();
|
|
}));
|
|
}
|
|
|
|
// The tab-strip and footer grain fades each allocate a gradient brush, run a TransformToVisual
|
|
// query, and reset an OpacityMask. Several SizeChanged handlers (window, sidebar, doc pane, tab
|
|
// strip) drive them, so during a live resize they fired multiple times per frame - synchronous
|
|
// UI-thread work that widened the WPF frame/content desync and made the whole window thrash.
|
|
// Coalesce every resize-driven fade refresh into a single pass per render tick.
|
|
private bool _fadeRefreshQueued;
|
|
private void ScheduleFadeRefresh()
|
|
{
|
|
if (_fadeRefreshQueued) return;
|
|
_fadeRefreshQueued = true;
|
|
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Render, (Action)(() =>
|
|
{
|
|
_fadeRefreshQueued = false;
|
|
UpdateTabStripFade(); // also refreshes the footer fade
|
|
}));
|
|
}
|
|
|
|
// Collapses lower-priority button groups into the overflow popup when the toolbar runs
|
|
// out of room, and restores them when there is space again. Keeps the left/right layout.
|
|
private void ReflowToolbar()
|
|
{
|
|
if (_reflowingToolbar || ToolbarGrid is null || LeftBar is null || RightContainer is null) return;
|
|
_reflowingToolbar = true;
|
|
try
|
|
{
|
|
// Order in which buttons move to the overflow menu as the bar narrows: FIRST entry
|
|
// goes first. Lowest-value / most-redundant first - page move/delete and merge/extract
|
|
// (all reachable from the sidebar right-click), then signature/image/crop, then
|
|
// undo-clear, with the text-markup tools (draw, strike, underline, highlight, text)
|
|
// kept on the bar the longest. Zoom, Select, and the file basics never collapse here;
|
|
// they only shed their captions later (see LabelStripRank). Edit this list to retune.
|
|
var order = new (UIElement bar, UIElement[] items)[]
|
|
{
|
|
(GrpPageEdit, new UIElement[] { MiDelete, MiMoveUp, MiMoveDown }),
|
|
(GrpPageOps, new UIElement[] { MiMerge, MiExtract }),
|
|
// Stamp goes before signature, image and the markup tools: it is the most
|
|
// occasional of them, and inserting an image or typing text is reached for far
|
|
// more often (2026-08-01).
|
|
(ToolStampBtn, new UIElement[] { MiStamp }),
|
|
(GrpSignature, new UIElement[] { MiSignature }),
|
|
(ToolImageBtn, new UIElement[] { MiImage }),
|
|
(ToolCropBtn, new UIElement[] { MiCrop }),
|
|
(GrpUndo, new UIElement[] { MiUndo, MiClear }),
|
|
(ToolShapeBtn, new UIElement[] { MiShape }),
|
|
(ToolDrawBtn, new UIElement[] { MiDraw }),
|
|
(ToolUnderlineBtn, new UIElement[] { MiUnderline }), // now the Line tool
|
|
(ToolHighlightBtn, new UIElement[] { MiHighlight }),
|
|
(ToolTextBtn, new UIElement[] { MiText }),
|
|
};
|
|
|
|
// Start fully expanded (everything in the bar, nothing in the popup).
|
|
foreach (var (grp, items) in order)
|
|
{
|
|
grp.Visibility = Visibility.Visible;
|
|
foreach (var it in items) it.Visibility = Visibility.Collapsed;
|
|
}
|
|
MeasureToolbarBars();
|
|
|
|
double avail = ToolbarGrid.ActualWidth;
|
|
|
|
// Text-beside / Text-under: each pass starts with ALL captions on, so widening the
|
|
// window always restores them. Captions are only shed much later, as a last resort.
|
|
bool textCaptions = _toolbarLabelMode is ToolbarLabelMode.Beside or ToolbarLabelMode.Under;
|
|
if (textCaptions && _toolbarButtons.Count > 0)
|
|
{
|
|
foreach (var (btn, glyph, key) in _toolbarButtons)
|
|
SetToolbarButton(btn, glyph, key, withLabel: true);
|
|
ToolbarGrid.UpdateLayout();
|
|
}
|
|
|
|
// Keep the ACTIVE tool on the bar no matter how narrow, so its selected state stays visible -
|
|
// otherwise it vanishes into the overflow chevron and there's no way to tell what's active.
|
|
UIElement? activeToolBar = _currentTool switch
|
|
{
|
|
EditTool.Text => ToolTextBtn,
|
|
EditTool.Line => ToolUnderlineBtn, // repurposed to the Line tool
|
|
EditTool.Highlight => ToolHighlightBtn,
|
|
EditTool.Draw => ToolDrawBtn,
|
|
EditTool.Shape => ToolShapeBtn,
|
|
EditTool.Image => ToolImageBtn,
|
|
EditTool.Crop => ToolCropBtn,
|
|
EditTool.Signature => GrpSignature,
|
|
_ => null
|
|
};
|
|
|
|
// First defense against a narrow bar (and the long-standing behavior): collapse whole
|
|
// low-priority groups into the overflow menu, KEEPING captions on whatever stays. This
|
|
// is what runs at normal widths - captions stay, extras move to the chevron.
|
|
if (LeftBar.DesiredSize.Width + RightContainer.DesiredSize.Width > avail)
|
|
{
|
|
foreach (var (grp, items) in order)
|
|
{
|
|
if (ReferenceEquals(grp, activeToolBar)) continue; // never collapse the active tool
|
|
grp.Visibility = Visibility.Collapsed; // pull this group out of the bar
|
|
foreach (var it in items) it.Visibility = Visibility.Visible; // ...into the popup
|
|
MeasureToolbarBars();
|
|
if (LeftBar.DesiredSize.Width + RightContainer.DesiredSize.Width + 30 <= avail) break;
|
|
}
|
|
}
|
|
|
|
// Last resort, ONLY at the ultra-narrow width where everything collapsible is already
|
|
// in the overflow menu and the remaining captioned buttons still overlap: shed captions
|
|
// to icon-only in priority order (zoom and Select first, annotation tools last). Until
|
|
// this point the toolbar keeps its full captions, exactly as it looked before.
|
|
if (textCaptions && LeftBar.DesiredSize.Width + RightContainer.DesiredSize.Width > avail)
|
|
{
|
|
foreach (var (btn, glyph, key) in _toolbarButtons.OrderBy(x => LabelStripRank(x.labelKey)))
|
|
{
|
|
if (LeftBar.DesiredSize.Width + RightContainer.DesiredSize.Width <= avail) break;
|
|
if (!btn.IsVisible) continue; // already collapsed into the overflow menu
|
|
SetToolbarButton(btn, glyph, key, withLabel: false);
|
|
ToolbarGrid.UpdateLayout();
|
|
}
|
|
}
|
|
|
|
bool anyCollapsed = order.Any(o => o.bar.Visibility != Visibility.Visible);
|
|
OverflowChevron.Visibility = anyCollapsed ? Visibility.Visible : Visibility.Collapsed;
|
|
if (!anyCollapsed) OverflowChevron.IsChecked = false;
|
|
|
|
_lastReflowWidth = avail; // this width is now decided - see QueueReflowToolbar
|
|
|
|
// The toolbar does NOT get a say in how narrow the window may be. Deriving a floor
|
|
// from it produced a minimum unrelated to anything the user can see, and a
|
|
// different one in each mode. The only floor is the pane minimum (SyncSplitMinWidth).
|
|
}
|
|
finally { _reflowingToolbar = false; }
|
|
}
|
|
|
|
private static readonly Size ToolbarMeasureBudget = new(double.PositiveInfinity, double.PositiveInfinity);
|
|
|
|
// Re-measures ONLY the two toolbar bars to refresh their DesiredSize, instead of calling
|
|
// ToolbarGrid.UpdateLayout() - which forces a synchronous Measure+Arrange of the ENTIRE visual
|
|
// tree. The reflow only needs each bar's natural width to decide what fits, so a measure-only
|
|
// pass on the bars is enough and far cheaper. This is what lets the reflow run live on every
|
|
// resize frame (no deferral, so no mid-drag button overlap) without thrashing the window.
|
|
// WPF re-arranges the bars with their real constraint on the next normal layout pass.
|
|
private void MeasureToolbarBars()
|
|
{
|
|
LeftBar.InvalidateMeasure();
|
|
RightContainer.InvalidateMeasure();
|
|
LeftBar.Measure(ToolbarMeasureBudget);
|
|
RightContainer.Measure(ToolbarMeasureBudget);
|
|
}
|
|
|
|
private void OverflowItem_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
OverflowChevron.IsChecked = false; // close the flyout after a choice is made
|
|
}
|
|
}
|
|
}
|