Files
mmd-pdf/Shell/KeyboardMapOverlay.cs
T

362 lines
21 KiB
C#

using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Shapes;
namespace KillerPDF
{
// ============================================================
// Visual keyboard for the shortcuts overlay.
//
// Same philosophy as ShortcutsOverlay.cs: the board is generated from the tables below (one
// source of truth), and every brush and label is wired with SetResourceReference so theme and
// language switches repaint live. Category colors are theme brushes (KsCat* in Themes/*.xaml),
// keycap faces ride BgPanel / BorderDim / TextPrimary - the board always looks native to the
// active theme. Holding a real Ctrl / Shift / Alt previews that layer, like the key will work.
// ============================================================
public partial class MainWindow
{
// KbLayer, the binding table and its types live in Services/ShortcutTable.cs, which is free
// of WPF so KillerPDF.Tests can link it.
private KbLayer _kbLayer = KbLayer.Base;
private bool _kbBuilt;
private TextBlock? _kbDetail;
private TextBlock? _kbHoverAct; // caption of the key under the mouse (marquee restart on layer switch)
private string? _kbHoverId;
private readonly Dictionary<string, (Border Cap, TextBlock Act, Rectangle Bar)> _kbKeys = new();
private readonly Dictionary<KbLayer, Button> _kbLayerBtns = new();
private const string KsViewSetting = "ShortcutView"; // "list" (default) | "keyboard"
// ── Binding table ──────────────────────────────────────────────────────────────────────
// DERIVED from ShortcutTable.KsAll, the same array the list is built from, so the two views
// cannot describe a binding differently. Adding a shortcut is a one-line edit there.
// A double claim on one cap would silently let the last writer win, which is how Ctrl+B
// meant two things for so long; ShortcutTableTests asserts that never happens.
private static readonly Dictionary<KbLayer, Dictionary<string, (string Cat, string Label)>> KbMap =
ShortcutTable.BuildMap();
// ── Physical layout ────────────────────────────────────────────────────────────────────
// (id, cap text, width units). id "" = spacer. Numpad omitted (digits mirror the number row).
private static readonly (string Id, string Cap, double W)[][] KbRows =
[
[("Esc","Esc",1), ("","",0.8), ("F1","F1",1),("F2","F2",1),("F3","F3",1),("F4","F4",1), ("","",0.6),
("F5","F5",1),("F6","F6",1),("F7","F7",1),("F8","F8",1), ("","",0.6),
("F9","F9",1),("F10","F10",1),("F11","F11",1),("F12","F12",1)],
[("Grave","`",1),("D1","1",1),("D2","2",1),("D3","3",1),("D4","4",1),("D5","5",1),("D6","6",1),
("D7","7",1),("D8","8",1),("D9","9",1),("D0","0",1),("Minus","-",1),("Equals","=",1),("Back","\u232B",2),
("","",0.6), ("Ins","Ins",1),("Home","Home",1),("PgUp","PgUp",1)],
[("Tab","Tab",1.5),("Q","Q",1),("W","W",1),("E","E",1),("R","R",1),("T","T",1),("Y","Y",1),("U","U",1),
("I","I",1),("O","O",1),("P","P",1),("LBr","[",1),("RBr","]",1),("Bslash","\\",1.5),
("","",0.6), ("Del","Del",1),("End","End",1),("PgDn","PgDn",1)],
[("Caps","Caps",1.8),("A","A",1),("S","S",1),("D","D",1),("F","F",1),("G","G",1),("H","H",1),("J","J",1),
("K","K",1),("L","L",1),("Semi",";",1),("Quote","'",1),("Enter","Enter",2.2)],
[("Shift","Shift",2.3),("Z","Z",1),("X","X",1),("C","C",1),("V","V",1),("B","B",1),("N","N",1),("M","M",1),
("Comma",",",1),("Period",".",1),("Slash","/",1),("RShift","Shift",2.7),
("","",1.6), ("Up","\u2191",1)],
[("Ctrl","Ctrl",1.5),("Win","Win",1.2),("Alt","Alt",1.5),("Space","",6.8),("RAlt","Alt",1.5),("Menu","\u2630",1),("RCtrl","Ctrl",1.5),
("","",0.6), ("Left","\u2190",1),("Down","\u2193",1),("Right","\u2192",1)],
];
private static readonly (KbLayer Layer, string Caption)[] KbLayerButtons =
[
(KbLayer.Base, "BASE"), (KbLayer.Ctrl, "CTRL"), (KbLayer.CtrlShift, "CTRL+SHIFT"),
(KbLayer.Shift, "SHIFT"), (KbLayer.Alt, "ALT"),
];
// Modifier keycaps that light up per layer (they define it rather than carry a binding).
private static readonly Dictionary<KbLayer, string[]> KbLayerMods = new()
{
[KbLayer.Base] = [], [KbLayer.Ctrl] = ["Ctrl", "RCtrl"],
[KbLayer.CtrlShift] = ["Ctrl", "RCtrl", "Shift", "RShift"],
[KbLayer.Shift] = ["Shift", "RShift"], [KbLayer.Alt] = ["Alt", "RAlt"],
};
private static string KbSectionKeyFor(string cat) => cat switch
{
"File" => "Str_KS_File", "Tools" => "Str_KS_Tools", "Edit" => "Str_KS_Editing",
"Nav" => "Str_KS_Navigation", "View" => "Str_KS_View", "Search" => "Str_KS_SearchSelect",
"Help" => "Str_KS_Help", _ => "Str_KS_Ocr",
};
// ── View toggle (LIST / KEYBOARD) ──────────────────────────────────────────────────────
private void KsViewList_Click(object sender, RoutedEventArgs e) => ApplyShortcutView(keyboard: false, persist: true);
private void KsViewKeyboard_Click(object sender, RoutedEventArgs e) => ApplyShortcutView(keyboard: true, persist: true);
/// <summary>Shows the list or the keyboard inside the shortcuts overlay card. Called on
/// every overlay open with the persisted choice, and by the two toggle captions.</summary>
private void ApplyShortcutView(bool keyboard, bool persist = false)
{
if (keyboard && !_kbBuilt) BuildKeyboardView();
ShortcutListHost.Visibility = keyboard ? Visibility.Collapsed : Visibility.Visible;
ShortcutKeyboardHost.Visibility = keyboard ? Visibility.Visible : Visibility.Collapsed;
ShortcutCardGrid.MaxWidth = keyboard ? 1080 : 640;
KsViewListBtn.SetResourceReference(ForegroundProperty, keyboard ? "MutedTextBrush" : "PrimaryBrush");
KsViewKeyboardBtn.SetResourceReference(ForegroundProperty, keyboard ? "PrimaryBrush" : "MutedTextBrush");
if (keyboard) SetKbLayer(KbLayer.Base);
if (persist) App.SetSetting(KsViewSetting, keyboard ? "keyboard" : "list");
}
private void ApplyPersistedShortcutView() =>
ApplyShortcutView(App.GetSetting(KsViewSetting) == "keyboard");
// ── Board construction (once, lazily) ──────────────────────────────────────────────────
private void BuildKeyboardView()
{
_kbBuilt = true;
var host = ShortcutKeyboardHost;
host.Children.Clear();
_kbKeys.Clear();
_kbLayerBtns.Clear();
// Layer captions row.
var layerRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 10) };
foreach (var (layer, caption) in KbLayerButtons)
{
// Use the shared command-button factory. In 98SE this supplies the raised two-tone
// face and inverted pressed bevel; the old hand-built flat template bypassed it.
var b = UiKit.Make(caption, accent: false);
b.FontFamily = UiKit.MonoFont;
b.FontSize = 11;
b.Padding = new Thickness(10, 4, 10, 4);
b.Margin = new Thickness(0, 0, 8, 0);
b.BorderThickness = new Thickness(1);
b.FocusVisualStyle = null;
b.SetResourceReference(ForegroundProperty, "MutedTextBrush");
b.SetResourceReference(BorderBrushProperty, "CardBorderBrush");
var l = layer;
b.Click += (_, _2) => SetKbLayer(l);
_kbLayerBtns[layer] = b;
layerRow.Children.Add(b);
}
var hint = new TextBlock
{
FontSize = 11, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(6, 0, 0, 0),
};
hint.SetResourceReference(TextBlock.TextProperty, "Str_KS_HoldHint");
hint.SetResourceReference(TextBlock.ForegroundProperty, "DimTextBrush");
layerRow.Children.Add(hint);
host.Children.Add(layerRow);
// The board. A DownOnly Viewbox keeps it fitting smaller windows without scrollbars.
const double U = 46; // one key unit incl. its 4px gap
var board = new StackPanel();
foreach (var row in KbRows)
{
var r = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 4) };
foreach (var (id, cap, w) in row)
{
if (id.Length == 0) { r.Children.Add(new Border { Width = U * w }); continue; }
var capText = new TextBlock
{
Text = cap, FontFamily = UiKit.MonoFont, // symbols render via font fallback
FontSize = 11, HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Top, Margin = new Thickness(0, 5, 0, 0),
};
capText.SetResourceReference(TextBlock.ForegroundProperty, "TextBrush");
var act = new TextBlock
{
FontSize = 8.5, HorizontalAlignment = HorizontalAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis, Visibility = Visibility.Collapsed,
RenderTransform = new TranslateTransform(),
};
var actHost = new Border // clips the caption so it can marquee on hover
{
ClipToBounds = true, VerticalAlignment = VerticalAlignment.Bottom,
Margin = new Thickness(2, 0, 2, 5), Child = act,
};
var bar = new Rectangle
{
Height = 3, VerticalAlignment = VerticalAlignment.Bottom, RadiusX = 1.5, RadiusY = 1.5,
Margin = new Thickness(3, 0, 3, 0), Visibility = Visibility.Collapsed,
};
var inner = new Grid();
inner.Children.Add(capText);
inner.Children.Add(actHost);
inner.Children.Add(bar);
var key = new Border
{
Width = U * w - 4, Height = 44, CornerRadius = new CornerRadius(0),
BorderThickness = new Thickness(1), Margin = new Thickness(0, 0, 4, 0),
Child = inner,
};
key.SetResourceReference(Border.CornerRadiusProperty, "ControlCornerRadius");
key.SetResourceReference(Border.BackgroundProperty, "KeyboardKeyBrush");
key.SetResourceReference(Border.BorderBrushProperty, "CardBorderBrush");
// Hover: the keycap lifts a few pixels, like the cards on the killertools.net front page.
var lift = new TranslateTransform();
key.RenderTransform = lift;
string keyId = id;
key.MouseEnter += (_, _2) =>
{
_kbHoverAct = act; _kbHoverId = keyId;
KbShowDetail(keyId);
if (KbMap[_kbLayer].ContainsKey(keyId)) // only keys with a binding lift; dummies stay put
{
lift.BeginAnimation(TranslateTransform.YProperty, new DoubleAnimation(-3, TimeSpan.FromMilliseconds(90)));
KbMarqueeStart(act); // a cut-off caption scrolls, marquee-style
}
};
key.MouseLeave += (_, _2) =>
{
_kbHoverAct = null; _kbHoverId = null;
if (_kbDetail is not null) _kbDetail.Text = " ";
lift.BeginAnimation(TranslateTransform.YProperty, new DoubleAnimation(0, TimeSpan.FromMilliseconds(130)));
KbMarqueeStop(act);
};
_kbKeys[id] = (key, act, bar);
r.Children.Add(key);
}
board.Children.Add(r);
}
host.Children.Add(new Viewbox
{
Child = board, Stretch = Stretch.Uniform, StretchDirection = StretchDirection.DownOnly,
HorizontalAlignment = HorizontalAlignment.Center,
});
_kbDetail = new TextBlock
{
Text = " ", FontFamily = UiKit.MonoFont, FontSize = 12.5,
Margin = new Thickness(2, 10, 0, 0), Height = 18,
};
_kbDetail.SetResourceReference(TextBlock.ForegroundProperty, "PrimaryBrush");
host.Children.Add(_kbDetail);
}
private void KbShowDetail(string id)
{
if (_kbDetail is null) return;
if (KbMap[_kbLayer].TryGetValue(id, out var b))
{
string section = TryFindResource(KbSectionKeyFor(b.Cat)) as string ?? b.Cat;
string label = TryFindResource(b.Label) as string ?? b.Label;
_kbDetail.Text = $"{section} :: {label}";
}
else _kbDetail.Text = " ";
}
// ── Caption marquee (hover a lit key whose caption is cut off) ─────────────────────────
/// <summary>Scrolls a truncated caption back and forth inside its clipped host while the
/// key is hovered. No-op when the full text already fits.</summary>
private void KbMarqueeStart(TextBlock act)
{
if (act.Visibility != Visibility.Visible || act.Parent is not Border host) return;
// Measure with a probe TextBlock, NOT FormattedText: the probe inherits the same
// text formatting mode as the live control, so its width matches what actually
// renders. FormattedText measures Ideal-mode metrics and under-reports by a couple
// of pixels, which made barely-trimmed captions ("Signature") never scroll.
var probe = new TextBlock
{
Text = act.Text, FontFamily = act.FontFamily, FontSize = act.FontSize,
FontStyle = act.FontStyle, FontWeight = act.FontWeight, FontStretch = act.FontStretch,
};
TextOptions.SetTextFormattingMode(probe, TextOptions.GetTextFormattingMode(act));
probe.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
double over = probe.DesiredSize.Width - host.ActualWidth;
if (over <= 0.5) return;
// Reparent the caption into a Canvas for the ride. A Canvas measures children with
// INFINITE space, so the TextBlock escapes WPF's layout clip and renders the whole
// caption; the host border clips the viewport. (Arranged directly in the too-small
// host, the TextBlock is clipped to its slot BEFORE the transform runs, so the
// animation just slides a pre-cut snapshot - the "tamp Pa" bug.)
double h = act.ActualHeight;
act.TextTrimming = TextTrimming.None;
host.Child = null;
var cv = new Canvas { Height = h };
cv.Children.Add(act);
Canvas.SetLeft(act, 0);
Canvas.SetTop(act, 0);
host.Child = cv;
var tt = (TranslateTransform)act.RenderTransform;
tt.BeginAnimation(TranslateTransform.XProperty,
new DoubleAnimation(0, -over, TimeSpan.FromMilliseconds(System.Math.Max(600, over * 40)))
{ AutoReverse = true, RepeatBehavior = RepeatBehavior.Forever, BeginTime = TimeSpan.FromMilliseconds(350) });
}
private void KbMarqueeStop(TextBlock act)
{
var tt = (TranslateTransform)act.RenderTransform;
tt.BeginAnimation(TranslateTransform.XProperty, null);
tt.X = 0;
act.TextTrimming = TextTrimming.CharacterEllipsis;
if (act.Parent is Canvas cv && cv.Parent is Border host)
{
cv.Children.Clear();
host.Child = act; // back to the plain centered, ellipsized layout
}
}
// ── Layer painting ─────────────────────────────────────────────────────────────────────
private void SetKbLayer(KbLayer layer)
{
_kbLayer = layer;
if (!_kbBuilt) return;
var map = KbMap[layer];
foreach (var kv in _kbKeys) // no KeyValuePair deconstruction on net48
{
var vis = kv.Value;
if (map.TryGetValue(kv.Key, out var b))
{
vis.Cap.SetResourceReference(Border.BorderBrushProperty, "KsCat" + b.Cat);
vis.Bar.SetResourceReference(Shape.FillProperty, "KsCat" + b.Cat);
vis.Bar.Visibility = Visibility.Visible;
vis.Act.SetResourceReference(TextBlock.TextProperty, b.Label);
vis.Act.SetResourceReference(TextBlock.ForegroundProperty, "KsCat" + b.Cat);
vis.Act.Visibility = Visibility.Visible;
}
else
{
vis.Cap.SetResourceReference(Border.BorderBrushProperty, "CardBorderBrush");
vis.Bar.Visibility = Visibility.Collapsed;
vis.Act.Visibility = Visibility.Collapsed;
}
}
// Modifier caps that define the layer glow accent; the layer captions follow suit.
string[] allMods = ["Ctrl", "RCtrl", "Shift", "RShift", "Alt", "RAlt"];
foreach (var m in allMods)
if (_kbKeys.TryGetValue(m, out var vis))
vis.Cap.SetResourceReference(Border.BorderBrushProperty,
System.Array.IndexOf(KbLayerMods[layer], m) >= 0 ? "PrimaryBrush" : "CardBorderBrush");
foreach (var kv in _kbLayerBtns) // no KeyValuePair deconstruction on net48
{
kv.Value.SetResourceReference(ForegroundProperty, kv.Key == layer ? "PrimaryBrush" : "MutedTextBrush");
kv.Value.SetResourceReference(BorderBrushProperty, kv.Key == layer ? "PrimaryBrush" : "CardBorderBrush");
}
// Layer changed while a key is hovered (holding Ctrl / Shift / Alt): restart that key's
// marquee for its NEW caption - MouseEnter alone never re-fires. Deferred one layout
// pass so the caption text and size reflect the new layer before measuring.
if (_kbHoverAct is not null && _kbHoverId is not null)
{
KbMarqueeStop(_kbHoverAct);
KbShowDetail(_kbHoverId);
var act = _kbHoverAct;
Dispatcher.BeginInvoke(new Action(() =>
{
if (ReferenceEquals(act, _kbHoverAct)) KbMarqueeStart(act);
}), System.Windows.Threading.DispatcherPriority.Loaded);
}
}
/// <summary>Maps the live modifier state to a layer while the keyboard view is showing -
/// called from OnPreviewKeyDown/Up so holding Ctrl / Shift / Alt previews that layer.</summary>
private void KbSyncLayerFromModifiers()
{
if (!_kbBuilt || ShortcutKeyboardHost.Visibility != Visibility.Visible) return;
var m = Keyboard.Modifiers;
var layer = m.HasFlag(ModifierKeys.Control) && m.HasFlag(ModifierKeys.Shift) ? KbLayer.CtrlShift
: m.HasFlag(ModifierKeys.Control) ? KbLayer.Ctrl
: m.HasFlag(ModifierKeys.Alt) ? KbLayer.Alt
: m.HasFlag(ModifierKeys.Shift) ? KbLayer.Shift
: KbLayer.Base;
if (layer != _kbLayer) SetKbLayer(layer);
}
}
}