diff --git a/CHANGELOG.md b/CHANGELOG.md index e175559..ee45422 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,56 @@ All notable changes to ConfigurO are recorded here. The updater reads the heading for the running version to decide what to show, so keep the `## [x.y]` format. +## [3.2] + +### Fixes +- **Every outlined surface in the app was drawn blurred.** Cards, buttons, + inputs, tags, toggles, checkboxes, table rows, the sidebar rule and the + window's own edge all reach the screen through one hairline helper, and that + helper inset its path by a whole pixel before stroking it. The app paints + under `PixelOffsetMode.HighQuality`, which puts sample points on pixel + *corners*, so a 1px pen running along an integer coordinate straddles two + rows and GDI+ resolves it by painting both at roughly two-thirds strength. + Measured on Windows: a `#3F424D` edge on `#161826` came back as two rows of + `#2B2D3A` instead of one row of `#3F424D`. The stroke now sits on a + half-pixel centreline, with the corner radius pulled in to match so its outer + edge still follows the fill beneath it. Verified in the shipping binary at + 150% scale — the edge is one column, at the exact token colour. + This could not be seen where the interface was being reviewed: libgdiplus + does not reproduce the split, so on Linux the borders had always looked + correct. +- **Aero Snap, Snap Layouts, the Alt+Space window menu and the minimise + animation all did nothing.** The shell is a borderless window, and + `FormBorderStyle.None` strips `WS_THICKFRAME`, `WS_MINIMIZEBOX`, + `WS_MAXIMIZEBOX` and `WS_SYSMENU` — the style bits Windows reads to decide + what kind of window it is looking at. Measured, the window was reporting + `0x16010000`: no thick frame, no system menu. They are restored in + `CreateParams`; the window still draws all of its own chrome, and maximise + stays inside the work area because `WM_GETMINMAXINFO` was already answered + correctly. The rounded corners, shadow and DWM border were never affected — + Windows 11 gives those to any top-level window. + +### Interface +- **The mouse wheel moved content in 54px jumps.** Scrolling now eases toward a + target at roughly 66fps instead of teleporting once per notch, and a fast + flick compounds into one long glide rather than each notch restarting from + wherever the last one reached. The panel composites its children so moving + them does not tear. Dragging the scrollbar thumb stays 1:1 — that is direct + manipulation and should not lag behind the pointer. +- **Hover and press now arrive and leave instead of switching on.** Every + interactive surface in the app repainted its hover state instantly; only the + toggle knob had ever animated. That curve — 150ms, ease-out — is now a + reusable value in `NAnim`, and `NControl` and `NPanel` drive one for hover + and one for press, so painters read a float rather than a bool. Buttons, + selectable cards and text inputs use it: a button fades its fill up from + nothing and deepens rather than jumping when pressed, and a card edge mixes + toward the accent rather than flicking between two colours as the pointer + crosses a grid. Anyone who has turned animation off in Windows gets none of + it — `SPI_GETCLIENTAREAANIMATION` is honoured. +- The wheel router no longer keeps its own copy of the scroll distance, so a + routed wheel and a focused one can no longer disagree about how far a notch + goes. + ## [3.1] ### Fixes diff --git a/src/ConfigurO/ConfigurO.csproj b/src/ConfigurO/ConfigurO.csproj index 3ec57b0..ef29d73 100644 --- a/src/ConfigurO/ConfigurO.csproj +++ b/src/ConfigurO/ConfigurO.csproj @@ -1,4 +1,4 @@ - + @@ -108,6 +108,7 @@ + diff --git a/src/ConfigurO/Controls/MoonCheck.cs b/src/ConfigurO/Controls/MoonCheck.cs index e5483ac..e1a2fe3 100644 --- a/src/ConfigurO/Controls/MoonCheck.cs +++ b/src/ConfigurO/Controls/MoonCheck.cs @@ -80,7 +80,7 @@ protected override void OnPaint(PaintEventArgs e) else { Color edge = _hover ? NocturneTheme.Accent : NocturneTheme.Neutral600; - using (Pen pen = new Pen(edge)) g.DrawPath(pen, p); + NocturneTheme.DrawRounded(g, box, radius, edge); } } @@ -101,10 +101,7 @@ protected override void OnPaint(PaintEventArgs e) if (Focused) { - Rectangle ring = Rectangle.Inflate(box, NocturneScale.S(2), NocturneScale.S(2)); - using (GraphicsPath p = NocturneTheme.RoundedRect(ring, radius + NocturneScale.S(2))) - using (Pen pen = new Pen(NocturneTheme.Accent, NocturneScale.S(2))) - g.DrawPath(pen, p); + NocturneDraw.FocusRing(g, box, radius); } if (!string.IsNullOrEmpty(Text)) diff --git a/src/ConfigurO/Controls/MoonCheckList.cs b/src/ConfigurO/Controls/MoonCheckList.cs index 2e0a5e5..f146a17 100644 --- a/src/ConfigurO/Controls/MoonCheckList.cs +++ b/src/ConfigurO/Controls/MoonCheckList.cs @@ -45,7 +45,7 @@ protected override void OnDrawItem(DrawItemEventArgs e) } else { - using (Pen pen = new Pen(NocturneTheme.Neutral600)) g.DrawPath(pen, p); + NocturneTheme.DrawRounded(g, box, NocturneScale.S(4), NocturneTheme.Neutral600); } } diff --git a/src/ConfigurO/Controls/MoonSelect.cs b/src/ConfigurO/Controls/MoonSelect.cs index 66a5b55..ca79dba 100644 --- a/src/ConfigurO/Controls/MoonSelect.cs +++ b/src/ConfigurO/Controls/MoonSelect.cs @@ -63,8 +63,7 @@ protected override void WndProc(ref Message m) Color edge = Focused ? NocturneTheme.Accent : _hover ? NocturneTheme.BorderStrong : NocturneTheme.Divider; - using (GraphicsPath p = NocturneTheme.RoundedRect(r, NocturneTheme.RadiusMd)) - using (Pen pen = new Pen(edge)) g.DrawPath(pen, p); + NocturneTheme.DrawRounded(g, r, NocturneTheme.RadiusMd, edge); NocturneIcons.Draw(g, NocturneIcons.Caret, Width - NocturneScale.S(24), (Height - NocturneScale.S(16)) / 2, diff --git a/src/ConfigurO/Controls/MoonToggle.cs b/src/ConfigurO/Controls/MoonToggle.cs index 731d048..ec3c886 100644 --- a/src/ConfigurO/Controls/MoonToggle.cs +++ b/src/ConfigurO/Controls/MoonToggle.cs @@ -104,15 +104,12 @@ protected override void OnPaint(PaintEventArgs e) using (GraphicsPath p = NocturneTheme.RoundedRect(pill, h / 2)) { using (SolidBrush b = new SolidBrush(fill)) g.FillPath(b, p); - using (Pen pen = new Pen(edge)) g.DrawPath(pen, p); + NocturneTheme.DrawRounded(g, pill, h / 2, edge); } if (Focused) { - Rectangle ring = Rectangle.Inflate(pill, NocturneScale.S(2), NocturneScale.S(2)); - using (GraphicsPath p = NocturneTheme.RoundedRect(ring, ring.Height / 2)) - using (Pen pen = new Pen(NocturneTheme.Accent, NocturneScale.S(2))) - g.DrawPath(pen, p); + NocturneDraw.FocusRing(g, pill, pill.Height / 2); } float knobX = left + pad + _position * (w - knob - pad * 2); diff --git a/src/ConfigurO/Nocturne/Controls/NButton.cs b/src/ConfigurO/Nocturne/Controls/NButton.cs index 9f24895..714954d 100644 --- a/src/ConfigurO/Nocturne/Controls/NButton.cs +++ b/src/ConfigurO/Nocturne/Controls/NButton.cs @@ -28,7 +28,7 @@ internal enum NButtonStyle /// internal sealed class NButton : NControl, IButtonControl { - bool _hover, _pressed; + bool _pressed; string _icon; NButtonStyle _style = NButtonStyle.Secondary; bool _active; @@ -113,8 +113,15 @@ int EffectiveIconSize get { return IconSize > 0 ? NocturneScale.S(IconSize) : NocturneScale.S(15); } } - protected override void OnMouseEnter(EventArgs e) { _hover = true; Invalidate(); base.OnMouseEnter(e); } - protected override void OnMouseLeave(EventArgs e) { _hover = _pressed = false; Invalidate(); base.OnMouseLeave(e); } + // Enter is handled entirely by NControl, which eases HoverAmount. + protected override void OnMouseLeave(EventArgs e) + { + // NControl unwinds the easing; this clears the logical flag the + // keyboard path guards on. + _pressed = false; + base.OnMouseLeave(e); + } + protected override void OnMouseDown(MouseEventArgs e) { // Clicking is not navigating: a ring here answers a question nobody @@ -126,7 +133,7 @@ protected override void OnMouseDown(MouseEventArgs e) } _pressed = true; Focus(); Invalidate(); base.OnMouseDown(e); } - protected override void OnMouseUp(MouseEventArgs e) { _pressed = false; Invalidate(); base.OnMouseUp(e); } + protected override void OnMouseUp(MouseEventArgs e) { _pressed = false; base.OnMouseUp(e); } protected override void OnGotFocus(EventArgs e) { Invalidate(); base.OnGotFocus(e); } protected override void OnLostFocus(EventArgs e) { Invalidate(); base.OnLostFocus(e); } @@ -140,7 +147,7 @@ protected override void OnKeyDown(KeyEventArgs e) if (e.KeyCode == Keys.Space || e.KeyCode == Keys.Enter) { _pressed = true; - Invalidate(); + SetPressed(true); e.Handled = true; } base.OnKeyDown(e); @@ -151,7 +158,7 @@ protected override void OnKeyUp(KeyEventArgs e) if (_pressed && (e.KeyCode == Keys.Space || e.KeyCode == Keys.Enter)) { _pressed = false; - Invalidate(); + SetPressed(false); PerformClick(); e.Handled = true; } @@ -191,9 +198,31 @@ void Render(PaintEventArgs e) else { border = NocturneTheme.Divider; text = NocturneTheme.Text; } if (!Enabled) { border = NocturneTheme.Border; text = NocturneTheme.TextDim; } - else if (_pressed) fill = _style == NButtonStyle.Secondary ? NocturneTheme.Border : NocturneTheme.PressedAccent; - else if (_hover) fill = _style == NButtonStyle.Secondary || _style == NButtonStyle.Icon - ? NocturneTheme.HoverFill : NocturneTheme.HoverAccent; + else + { + // Hover and press used to be two discrete fills swapped in by + // an if/else. They are now one colour faded up from nothing, + // so the button arrives and leaves instead of blinking. + // + // Alpha-blended rather than mixed against a ground colour + // because these controls are transparent-backed: the parent + // has already painted whatever sits behind, and at amount 0 + // there must be nothing here at all. + Color hoverFill = _style == NButtonStyle.Secondary || _style == NButtonStyle.Icon + ? NocturneTheme.HoverFill : NocturneTheme.HoverAccent; + Color pressFill = _style == NButtonStyle.Secondary + ? NocturneTheme.Border : NocturneTheme.PressedAccent; + + float h = HoverAmount, p = PressAmount; + float amount = Math.Max(h, p); + if (amount > 0.004f) + { + // Press wins as it comes in, so a click deepens the hover + // tint rather than replacing it and jumping. + Color blended = p > 0f ? NocturneTheme.Mix(pressFill, hoverFill, p) : hoverFill; + fill = NocturneTheme.Alpha(blended, amount); + } + } NocturneDraw.Card(g, r, fill, border, radius); if (Focused && Enabled && NocturneDraw.ShowFocusRings) diff --git a/src/ConfigurO/Nocturne/Controls/NControl.cs b/src/ConfigurO/Nocturne/Controls/NControl.cs index 68d62b3..dfd4754 100644 --- a/src/ConfigurO/Nocturne/Controls/NControl.cs +++ b/src/ConfigurO/Nocturne/Controls/NControl.cs @@ -17,6 +17,9 @@ internal class NControl : Control { bool _subscribed; + readonly NAnim _hoverAnim; + readonly NAnim _pressAnim; + internal NControl() { SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | @@ -24,11 +27,61 @@ internal NControl() ControlStyles.SupportsTransparentBackColor, true); BackColor = Color.Transparent; ForeColor = NocturneTheme.Text; + + // Press settles faster than hover: a click wants to feel immediate, + // where a pointer crossing a surface wants to feel unhurried. + _hoverAnim = new NAnim(Invalidate, 130); + _pressAnim = new NAnim(Invalidate, 90); + NocturneTheme.Changed += OnThemeChangedInternal; NocturneScale.Changed += OnScaleChangedInternal; _subscribed = true; } + // ── Eased interaction state ───────────────────────────────────── + // Painters read these instead of a bool. At 0 the control is at rest, + // at 1 it is fully hovered or fully pressed, and in between it is on + // its way -- which is the whole difference between a surface that + // responds and one that switches. + + /// 0 at rest, 1 hovered, eased in between. + protected float HoverAmount { get { return _hoverAnim.Value; } } + + /// 0 at rest, 1 pressed, eased in between. + protected float PressAmount { get { return _pressAnim.Value; } } + + /// Drives the hover easing. Called for you on enter and leave. + protected void SetHover(bool on) { _hoverAnim.To(on ? 1f : 0f); } + + /// + /// Drives the press easing. The mouse path calls this for you; keyboard + /// activation has to say so itself, since Space and Enter produce no + /// mouse events. + /// + protected void SetPressed(bool on) { _pressAnim.To(on ? 1f : 0f); } + + protected override void OnMouseEnter(EventArgs e) { SetHover(true); base.OnMouseEnter(e); } + + protected override void OnMouseLeave(EventArgs e) + { + // A pointer that leaves mid-press must not leave the press behind: + // the mouse-up will land somewhere else and never come back here. + SetHover(false); + SetPressed(false); + base.OnMouseLeave(e); + } + + protected override void OnMouseDown(MouseEventArgs e) { SetPressed(true); base.OnMouseDown(e); } + protected override void OnMouseUp(MouseEventArgs e) { SetPressed(false); base.OnMouseUp(e); } + + protected override void OnEnabledChanged(EventArgs e) + { + // A control disabled under the pointer keeps its hover for as long + // as the pointer stays, and comes back wearing it. + if (!Enabled) { _hoverAnim.Set(0f); _pressAnim.Set(0f); } + base.OnEnabledChanged(e); + } + void OnThemeChangedInternal(object sender, EventArgs e) { if (IsDisposed) return; @@ -55,6 +108,8 @@ protected override void Dispose(bool disposing) { NocturneTheme.Changed -= OnThemeChangedInternal; NocturneScale.Changed -= OnScaleChangedInternal; + _hoverAnim.Dispose(); + _pressAnim.Dispose(); _subscribed = false; } base.Dispose(disposing); @@ -66,15 +121,47 @@ internal class NPanel : Panel { bool _subscribed; + readonly NAnim _hoverAnim; + readonly NAnim _pressAnim; + internal NPanel() { SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.ResizeRedraw, true); + + // Mirrors NControl. Most panels are containers that never drive + // these, and an NAnim that is never told to move never starts its + // timer, so the pair costs a couple of managed objects and nothing + // else -- a WinForms Timer does not touch the OS until Start. + _hoverAnim = new NAnim(Invalidate, 130); + _pressAnim = new NAnim(Invalidate, 90); + NocturneTheme.Changed += OnThemeChangedInternal; NocturneScale.Changed += OnScaleChangedInternal; _subscribed = true; } + /// 0 at rest, 1 hovered, eased in between. + protected float HoverAmount { get { return _hoverAnim.Value; } } + + /// 0 at rest, 1 pressed, eased in between. + protected float PressAmount { get { return _pressAnim.Value; } } + + protected void SetHover(bool on) { _hoverAnim.To(on ? 1f : 0f); } + protected void SetPressed(bool on) { _pressAnim.To(on ? 1f : 0f); } + + protected override void OnMouseEnter(EventArgs e) { SetHover(true); base.OnMouseEnter(e); } + + protected override void OnMouseLeave(EventArgs e) + { + SetHover(false); + SetPressed(false); + base.OnMouseLeave(e); + } + + protected override void OnMouseDown(MouseEventArgs e) { SetPressed(true); base.OnMouseDown(e); } + protected override void OnMouseUp(MouseEventArgs e) { SetPressed(false); base.OnMouseUp(e); } + void OnThemeChangedInternal(object sender, EventArgs e) { if (IsDisposed) return; @@ -98,6 +185,8 @@ protected override void Dispose(bool disposing) { NocturneTheme.Changed -= OnThemeChangedInternal; NocturneScale.Changed -= OnScaleChangedInternal; + _hoverAnim.Dispose(); + _pressAnim.Dispose(); _subscribed = false; } base.Dispose(disposing); diff --git a/src/ConfigurO/Nocturne/Controls/NLanguagePicker.cs b/src/ConfigurO/Nocturne/Controls/NLanguagePicker.cs index b7d9cd9..7265779 100644 --- a/src/ConfigurO/Nocturne/Controls/NLanguagePicker.cs +++ b/src/ConfigurO/Nocturne/Controls/NLanguagePicker.cs @@ -201,8 +201,7 @@ protected override void OnPaint(PaintEventArgs e) g.DrawImage(entry.Flag, flag); // Japan, and any other flag with a white field, has no edge of // its own against a light surface. - using (Pen edge = new Pen(NocturneTheme.Border)) - g.DrawRectangle(edge, flag.X, flag.Y, flag.Width - 1, flag.Height - 1); + NocturneTheme.DrawRounded(g, flag, 0, NocturneTheme.Border); int tx = fx + flagW + pad; // Each name is written in its own script, so the face has to be diff --git a/src/ConfigurO/Nocturne/Controls/NScrollPanel.cs b/src/ConfigurO/Nocturne/Controls/NScrollPanel.cs index 4787f3f..7a5a84b 100644 --- a/src/ConfigurO/Nocturne/Controls/NScrollPanel.cs +++ b/src/ConfigurO/Nocturne/Controls/NScrollPanel.cs @@ -7,15 +7,33 @@ namespace ConfigurO /// /// A vertically scrolling content host with a Nocturne scrollbar. /// - /// WinForms' own scrollbars are system-drawn and cannot be themed, so the + /// The WinForms scrollbars are system-drawn and cannot be themed, so the /// native ones are suppressed and a 6px accent-neutral thumb is painted on /// top of the content instead. + /// + /// The wheel does not move the content directly. It moves a target, and a + /// timer eases the content towards it -- see . /// internal sealed class NScrollPanel : NPanel { const int ThumbWidth = 6; + /// Design pixels travelled per wheel notch. + const int WheelStep = 54; + + /// Glide frame interval, ~66fps. + const int FrameMs = 15; + + /// + /// Fraction of the remaining distance covered each frame. Exponential + /// rather than linear: it leaves quickly and arrives softly, which is + /// what every other scrolling surface on the platform does. + /// + const float Ease = 0.28f; + readonly NPanel _content = new NPanel(); + readonly Timer _glide = new Timer(); + int _target; bool _dragging; int _dragOffset; bool _thumbHover; @@ -26,6 +44,27 @@ internal NScrollPanel() _content.BackColor = Color.Transparent; _content.Location = Point.Empty; Controls.Add(_content); + + _glide.Interval = FrameMs; + _glide.Tick += OnGlide; + } + + /// + /// Composites children into one buffer before the panel is painted. + /// + /// Moving the content panel is what scrolling is, and moving a control + /// with children repaints each of them separately. At one step per + /// notch that reads as a jump; at 66 frames a second it reads as + /// tearing. This is the flag that makes the difference. + /// + protected override CreateParams CreateParams + { + get + { + CreateParams cp = base.CreateParams; + cp.ExStyle |= 0x02000000; // WS_EX_COMPOSITED + return cp; + } } /// Put children in here; set its Height to the full content height. @@ -33,36 +72,85 @@ internal NScrollPanel() int Overflow { get { return Math.Max(0, _content.Height - Height); } } + int Clamp(int v) { return Math.Max(0, Math.Min(Overflow, v)); } + int ScrollY { get { return -_content.Top; } set { - int v = Math.Max(0, Math.Min(Overflow, value)); + int v = Clamp(value); if (-_content.Top == v) return; _content.Top = -v; Invalidate(); } } - internal void ScrollToTop() { ScrollY = 0; } - /// True when the content is taller than the viewport. internal bool CanScroll { get { return Overflow > 0; } } - /// Scrolls by a pixel delta. Used by the wheel router. - internal void ScrollBy(int delta) { ScrollY += delta; } + /// + /// Moves the scroll target by a number of wheel notches and lets the + /// glide catch up. Fractional notches are honoured, so a precision + /// touchpad reports the small deltas it means rather than having them + /// rounded up to a full step. + /// + internal void ScrollByNotches(float notches) + { + if (!CanScroll) return; + + // Accumulated against the target, not the current position, so a + // fast flick compounds into one long glide instead of each notch + // restarting from wherever the last one had reached. + _target = Clamp(_target + (int)Math.Round(notches * NocturneScale.S(WheelStep))); + if (_target == ScrollY) { _glide.Stop(); return; } + _glide.Start(); + } + + /// Jumps without animating. For direct manipulation and screen changes. + void ScrollTo(int v) + { + _glide.Stop(); + ScrollY = v; + _target = ScrollY; + } + + internal void ScrollToTop() { ScrollTo(0); } + + void OnGlide(object sender, EventArgs e) + { + int from = ScrollY; + int gap = _target - from; + + // Below a pixel a proportional step rounds to zero and the glide + // would never finish, so the last stretch is walked one pixel at a + // time. + int step = (int)Math.Round(gap * Ease); + if (step == 0) step = Math.Sign(gap); + + ScrollY = from + step; + + // Stopping on "did not move" as well as on "reached the target" + // catches the case where the content shrank under us and the + // target is no longer reachable. + if (ScrollY == _target || ScrollY == from) + { + _target = ScrollY; + _glide.Stop(); + } + } protected override void OnLayout(LayoutEventArgs e) { base.OnLayout(e); _content.Width = Width; - if (Overflow == 0 && _content.Top != 0) _content.Top = 0; + if (Overflow == 0 && _content.Top != 0) ScrollTo(0); + else _target = Clamp(_target); } protected override void OnMouseWheel(MouseEventArgs e) { - ScrollY -= (int)(e.Delta / 120f * NocturneScale.S(54)); + ScrollByNotches(-e.Delta / 120f); base.OnMouseWheel(e); } @@ -84,6 +172,7 @@ protected override void OnMouseDown(MouseEventArgs e) _dragging = true; _dragOffset = e.Y - t.Y; Capture = true; + _glide.Stop(); } base.OnMouseDown(e); } @@ -97,8 +186,10 @@ protected override void OnMouseMove(MouseEventArgs e) if (_dragging && Overflow > 0) { int trackFree = Height - t.Height; + // Dragging is direct manipulation: the thumb belongs under the + // pointer, not easing towards it. if (trackFree > 0) - ScrollY = (int)((float)(e.Y - _dragOffset) / trackFree * Overflow); + ScrollTo((int)((float)(e.Y - _dragOffset) / trackFree * Overflow)); } base.OnMouseMove(e); } @@ -127,5 +218,15 @@ protected override void OnPaint(PaintEventArgs e) NocturneTheme.FillRounded(e.Graphics, t, t.Width / 2, _dragging || _thumbHover ? NocturneTheme.Accent700 : NocturneTheme.ScrollThumb); } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _glide.Tick -= OnGlide; + _glide.Dispose(); + } + base.Dispose(disposing); + } } } diff --git a/src/ConfigurO/Nocturne/Controls/NSelectCard.cs b/src/ConfigurO/Nocturne/Controls/NSelectCard.cs index f93af22..2472667 100644 --- a/src/ConfigurO/Nocturne/Controls/NSelectCard.cs +++ b/src/ConfigurO/Nocturne/Controls/NSelectCard.cs @@ -21,7 +21,7 @@ internal enum CardLayout Tile } - bool _hover, _selected; + bool _selected; internal NSelectCard() { @@ -61,8 +61,7 @@ void OnSelectedChanged() if (h != null) h(this, EventArgs.Empty); } - protected override void OnMouseEnter(EventArgs e) { _hover = true; Invalidate(); base.OnMouseEnter(e); } - protected override void OnMouseLeave(EventArgs e) { _hover = false; Invalidate(); base.OnMouseLeave(e); } + // Hover easing is NControl's; nothing to override. protected override void OnClick(EventArgs e) { Focus(); Selected = !Selected; base.OnClick(e); } protected override bool IsInputKey(Keys keyData) @@ -90,7 +89,12 @@ void Render(PaintEventArgs e) NocturneDraw.Prepare(g); Rectangle r = new Rectangle(0, 0, Width, Height); - Color border = _selected || _hover ? NocturneTheme.Accent700 : NocturneTheme.Border; + // Selection is a state and lands at once; hover is a response and + // fades. Mixing rather than swapping is what stops the card edge + // flicking between two colours as the pointer crosses a grid. + Color border = _selected + ? NocturneTheme.Accent700 + : NocturneTheme.Mix(NocturneTheme.Accent700, NocturneTheme.Border, HoverAmount); Color fill = _selected ? NocturneTheme.SelectedFill : Color.Empty; NocturneDraw.Card(g, r, fill, border, NocturneTheme.RadiusMd); @@ -202,7 +206,7 @@ internal static void Draw(Graphics g, Rectangle box, bool on) } else { - using (Pen pen = new Pen(NocturneTheme.Neutral600)) g.DrawPath(pen, p); + NocturneTheme.DrawRounded(g, r, radius, NocturneTheme.Neutral600); } } diff --git a/src/ConfigurO/Nocturne/Controls/NSidebar.cs b/src/ConfigurO/Nocturne/Controls/NSidebar.cs index 46e8d16..74669ed 100644 --- a/src/ConfigurO/Nocturne/Controls/NSidebar.cs +++ b/src/ConfigurO/Nocturne/Controls/NSidebar.cs @@ -143,8 +143,11 @@ void Render(PaintEventArgs e) using (SolidBrush b = new SolidBrush(NocturneTheme.Bg)) g.FillRectangle(b, ClientRectangle); - using (Pen p = new Pen(NocturneTheme.Border)) - g.DrawLine(p, Width - 1, 0, Width - 1, Height); + // Filled, not stroked: under PixelOffsetMode.HighQuality a 1px pen on + // an integer coordinate straddles two columns at partial strength, + // while a 1px fill lands on exactly one. See NocturneTheme.DrawRounded. + using (SolidBrush b = new SolidBrush(NocturneTheme.Border)) + g.FillRectangle(b, Width - 1, 0, 1, Height); List v = VisibleItems(); int y = TopPad; diff --git a/src/ConfigurO/Nocturne/Controls/NTable.cs b/src/ConfigurO/Nocturne/Controls/NTable.cs index e4315cd..8099699 100644 --- a/src/ConfigurO/Nocturne/Controls/NTable.cs +++ b/src/ConfigurO/Nocturne/Controls/NTable.cs @@ -373,9 +373,8 @@ internal static void DrawAnimated(Graphics g, Rectangle pill, float t) using (SolidBrush b = new SolidBrush( NocturneTheme.Mix(NocturneTheme.ToggleOn, NocturneTheme.ToggleOff, k))) g.FillPath(b, p); - using (Pen pen = new Pen( - NocturneTheme.Mix(NocturneTheme.Accent, NocturneTheme.ToggleOffEdge, k))) - g.DrawPath(pen, p); + NocturneTheme.DrawRounded(g, pill, (pill.Height + 1) / 2, + NocturneTheme.Mix(NocturneTheme.Accent, NocturneTheme.ToggleOffEdge, k)); } float travel = pill.Width + 1 - pad * 2 - knob; diff --git a/src/ConfigurO/Nocturne/Controls/NTextBox.cs b/src/ConfigurO/Nocturne/Controls/NTextBox.cs index cf17ecf..65fe31b 100644 --- a/src/ConfigurO/Nocturne/Controls/NTextBox.cs +++ b/src/ConfigurO/Nocturne/Controls/NTextBox.cs @@ -19,7 +19,6 @@ internal sealed class NTextBox : NPanel readonly TextBox _inner = new TextBox(); string _placeholder = string.Empty; string _icon; - bool _hover; internal NTextBox() { @@ -130,8 +129,7 @@ protected override void OnLayout(LayoutEventArgs e) Math.Max(0, Width - pad * 2 - iconSpace), h); } - protected override void OnMouseEnter(EventArgs e) { _hover = true; Invalidate(); base.OnMouseEnter(e); } - protected override void OnMouseLeave(EventArgs e) { _hover = false; Invalidate(); base.OnMouseLeave(e); } + // Hover easing is NPanel's; nothing to override. protected override void OnMouseDown(MouseEventArgs e) { _inner.Focus(); base.OnMouseDown(e); } protected override void OnPaint(PaintEventArgs e) @@ -149,9 +147,11 @@ void Render(PaintEventArgs e) bool focused = _inner.Focused; Rectangle r = new Rectangle(0, 0, Width, Height); - Color border = focused ? NocturneTheme.Accent - : _hover ? NocturneTheme.BorderStrong - : NocturneTheme.Divider; + // Focus is binary and immediate. Hover walks the edge from the + // resting divider up to BorderStrong and back. + Color border = focused + ? NocturneTheme.Accent + : NocturneTheme.Mix(NocturneTheme.BorderStrong, NocturneTheme.Divider, HoverAmount); NocturneDraw.Card(g, r, NocturneTheme.Surface, border, NocturneTheme.RadiusMd); if (focused) NocturneDraw.FocusRing(g, new Rectangle(0, 0, Width - 1, Height - 1), NocturneTheme.RadiusMd); diff --git a/src/ConfigurO/Nocturne/NAnim.cs b/src/ConfigurO/Nocturne/NAnim.cs new file mode 100644 index 0000000..94c15b0 --- /dev/null +++ b/src/ConfigurO/Nocturne/NAnim.cs @@ -0,0 +1,117 @@ +using System; +using System.Runtime.InteropServices; +using System.Windows.Forms; + +namespace ConfigurO +{ + /// + /// A single eased 0..1 value, driven by a timer. + /// + /// Generalised from the one place that already did this properly -- + /// MoonToggle, whose knob has always slid over 150ms on an ease-out curve + /// while every other interactive surface in the app snapped. Hover, press, + /// selection and fade all want the same three things: a value, a target, + /// and a repaint per frame. + /// + /// The timer only runs while the value is actually moving, so an idle + /// control costs nothing. + /// + internal sealed class NAnim : IDisposable + { + /// ~66fps. Matches NScrollPanel so the two never beat against each other. + const int FrameMs = 15; + + readonly Timer _timer = new Timer { Interval = FrameMs }; + readonly Action _onFrame; + readonly int _duration; + + float _value, _target, _from; + DateTime _start; + + internal NAnim(Action onFrame, int durationMs = 130) + { + _onFrame = onFrame; + _duration = Math.Max(1, durationMs); + _timer.Tick += Tick; + } + + /// Current eased value, 0..1. + internal float Value { get { return _value; } } + + /// True while the value is still travelling. + internal bool Running { get { return _timer.Enabled; } } + + /// Eases towards . Cheap to call repeatedly. + internal void To(float target) + { + if (target < 0f) target = 0f; + if (target > 1f) target = 1f; + if (Math.Abs(_target - target) < 0.0001f && _timer.Enabled) return; + if (Math.Abs(_value - target) < 0.0001f) { Set(target); return; } + + // Someone who has turned animation off in Windows should not be + // made to watch it here. SPI_GETCLIENTAREAANIMATION is the signal + // the shell itself uses for exactly this. + if (!AnimationsEnabled) { Set(target); return; } + + _from = _value; + _target = target; + _start = DateTime.UtcNow; + _timer.Start(); + } + + /// Jumps straight to a value and repaints. No easing. + internal void Set(float value) + { + _timer.Stop(); + _value = _target = value; + if (_onFrame != null) _onFrame(); + } + + void Tick(object sender, EventArgs e) + { + float t = (float)(DateTime.UtcNow - _start).TotalMilliseconds / _duration; + if (t >= 1f) { t = 1f; _timer.Stop(); } + + // Ease-out quadratic: leaves fast, settles rather than stopping + // dead. The same curve MoonToggle uses, so the toggle and the row + // it sits in move as one thing. + float eased = 1f - (1f - t) * (1f - t); + _value = _from + (_target - _from) * eased; + if (_onFrame != null) _onFrame(); + } + + // ── Windows animation preference ──────────────────────────────── + const int SPI_GETCLIENTAREAANIMATION = 0x1042; + + [DllImport("user32.dll", SetLastError = true)] + static extern bool SystemParametersInfo(int action, int param, ref bool value, int winIni); + + static bool? _animations; + + /// + /// Whether Windows wants client-area animation at all. Read once -- + /// this is checked on every hover, and the setting does not move + /// without a restart of the shell anyway. + /// + internal static bool AnimationsEnabled + { + get + { + if (_animations.HasValue) return _animations.Value; + bool on = true; + try { SystemParametersInfo(SPI_GETCLIENTAREAANIMATION, 0, ref on, 0); } + catch (DllNotFoundException) { on = true; } + catch (EntryPointNotFoundException) { on = true; } + _animations = on; + return on; + } + } + + public void Dispose() + { + _timer.Tick -= Tick; + _timer.Dispose(); + } + } +} diff --git a/src/ConfigurO/Nocturne/NocturneDraw.cs b/src/ConfigurO/Nocturne/NocturneDraw.cs index d5b683b..ae891f0 100644 --- a/src/ConfigurO/Nocturne/NocturneDraw.cs +++ b/src/ConfigurO/Nocturne/NocturneDraw.cs @@ -291,12 +291,15 @@ internal static void Card(Graphics g, Rectangle r, Color fill, Color border, int /// A 2px accent focus ring, offset 2px outside the control. internal static void FocusRing(Graphics g, Rectangle r, int radius) { - Rectangle ring = Rectangle.Inflate(r, NocturneScale.S(2), NocturneScale.S(2)); - using (GraphicsPath p = NocturneTheme.RoundedRect( - new Rectangle(ring.X, ring.Y, ring.Width - 1, ring.Height - 1), - radius + NocturneScale.S(2))) - using (Pen pen = new Pen(NocturneTheme.Accent, NocturneScale.S(2))) - g.DrawPath(pen, p); + // Through DrawRounded so the ring gets the same half-pen inset as + // every other outline. It matters here only at scales where S(2) + // comes out odd -- an even pen width centred on an integer + // coordinate already lands on whole pixels -- but a focus ring + // that sharpens and softens as the display scale changes is worse + // than one that is simply always right. + int w = NocturneScale.S(2); + NocturneTheme.DrawRounded(g, Rectangle.Inflate(r, w, w), + radius + w, NocturneTheme.Accent, w); } } } diff --git a/src/ConfigurO/Nocturne/NocturneShell.cs b/src/ConfigurO/Nocturne/NocturneShell.cs index df9af2a..7a49dc1 100644 --- a/src/ConfigurO/Nocturne/NocturneShell.cs +++ b/src/ConfigurO/Nocturne/NocturneShell.cs @@ -29,6 +29,11 @@ internal class NocturneShell : Form { // ── Win32 ─────────────────────────────────────────────────────── const int WM_NCCALCSIZE = 0x0083; + + // Window styles, re-applied in CreateParams. + const int WS_CAPTION = 0x00C00000, WS_SYSMENU = 0x00080000; + const int WS_THICKFRAME = 0x00040000; + const int WS_MINIMIZEBOX = 0x00020000, WS_MAXIMIZEBOX = 0x00010000; const int WM_NCHITTEST = 0x0084; const int WM_GETMINMAXINFO = 0x0024; const int WM_DPICHANGED = 0x02E0; @@ -97,6 +102,41 @@ internal NocturneShell() NocturneWheelRouter.Install(); } + /// + /// Puts back the frame style bits FormBorderStyle.None strips. + /// + /// The window still draws its own chrome -- WM_NCCALCSIZE below keeps + /// the client area covering the whole window, so none of these are + /// ever painted by Windows. What they do is tell the shell what kind + /// of window this is, and without them it is treated as a fixed panel: + /// + /// WS_THICKFRAME Aero Snap and the Windows 11 Snap Layouts flyout, + /// both of which ignore a window that is not sizable. + /// WS_MINIMIZEBOX the minimise and restore animations, and the + /// taskbar click-to-minimise behaviour. + /// WS_MAXIMIZEBOX Win+Up, and double-click-to-maximise. + /// WS_SYSMENU the Alt+Space window menu and the taskbar + /// right-click menu. + /// + /// The rounded corners, drop shadow and DWM border are NOT among them: + /// Windows 11 gives those to any top-level window, with or without a + /// frame. Those come from DwmChrome. + /// + /// Maximising stays correct because WM_GETMINMAXINFO is answered with + /// the monitor work area -- see ClampToWorkArea. Without that, a window + /// with a thick frame maximises to the monitor plus the frame width and + /// hides the taskbar. + /// + protected override CreateParams CreateParams + { + get + { + CreateParams cp = base.CreateParams; + cp.Style |= WS_CAPTION | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_SYSMENU; + return cp; + } + } + /// /// The application icon, for the taskbar button, Alt-Tab and the window /// menu. @@ -400,8 +440,8 @@ void Render(PaintEventArgs e) // Windows 11 rounds and outlines the window for us; older versions // get a hairline so the app still reads as a distinct surface. if (WindowsRelease.SupportsRoundedCorners || WindowState == FormWindowState.Maximized) return; - using (Pen p = new Pen(NocturneTheme.Neutral500)) - e.Graphics.DrawRectangle(p, 0, 0, Width - 1, Height - 1); + NocturneTheme.DrawRounded(e.Graphics, new Rectangle(0, 0, Width, Height), + 0, NocturneTheme.Neutral500); } /// Shows a transient confirmation. Overridden by the main shell. diff --git a/src/ConfigurO/Nocturne/NocturneTheme.cs b/src/ConfigurO/Nocturne/NocturneTheme.cs index aa301cd..90896d8 100644 --- a/src/ConfigurO/Nocturne/NocturneTheme.cs +++ b/src/ConfigurO/Nocturne/NocturneTheme.cs @@ -165,6 +165,27 @@ internal static GraphicsPath RoundedRect(Rectangle r, int radius) return p; } + /// + /// Sub-pixel flavour of , for + /// stroked paths that have to sit on a half-pixel centreline. + /// + internal static GraphicsPath RoundedRect(RectangleF r, float radius) + { + GraphicsPath p = new GraphicsPath(); + if (radius <= 0f || r.Width <= 0f || r.Height <= 0f) + { + p.AddRectangle(r); + return p; + } + float d = Math.Min(radius * 2f, Math.Min(r.Width, r.Height)); + p.AddArc(r.X, r.Y, d, d, 180, 90); + p.AddArc(r.Right - d, r.Y, d, d, 270, 90); + p.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90); + p.AddArc(r.X, r.Bottom - d, d, d, 90, 90); + p.CloseFigure(); + return p; + } + internal static void FillRounded(Graphics g, Rectangle r, int radius, Color fill) { using (GraphicsPath p = RoundedRect(r, radius)) @@ -172,11 +193,37 @@ internal static void FillRounded(Graphics g, Rectangle r, int radius, Color fill g.FillPath(b, p); } - /// Draws a 1px hairline inset by half a pixel so it stays crisp. + /// + /// A hairline whose outer edge lands on . + /// + /// The centreline is inset by half the pen width, in floating point, + /// and that half pixel is the whole point. NocturneDraw.Prepare sets + /// PixelOffsetMode.HighQuality, which puts sample points on pixel + /// *corners* -- so a 1px pen run along an integer coordinate straddles + /// two pixel rows and GDI+ resolves it by painting both at roughly + /// two-thirds strength. Measured on Windows: a #3F424D line on #161826 + /// came back as two rows of #2B2D3A instead of one row of #3F424D. + /// Every card, input, button, tag and table outline in the app is + /// drawn through here, so the whole interface read as soft. + /// + /// The integer -1 inset this replaces is the same idea done in whole + /// pixels, which lands the centreline on the boundary rather than in + /// the middle of a pixel and so does not avoid the split. It is + /// invisible under libgdiplus, where the UI was reviewed. + /// + /// The corner radius is pulled in by the same half pixel so the + /// stroke's outer edge follows the fill beneath it. + /// internal static void DrawRounded(Graphics g, Rectangle r, int radius, Color stroke, float width = 1f) { - Rectangle rr = new Rectangle(r.X, r.Y, Math.Max(0, r.Width - 1), Math.Max(0, r.Height - 1)); - using (GraphicsPath p = RoundedRect(rr, radius)) + if (r.Width <= 0 || r.Height <= 0) return; + + float half = width / 2f; + RectangleF rr = new RectangleF( + r.X + half, r.Y + half, + Math.Max(0f, r.Width - width), Math.Max(0f, r.Height - width)); + + using (GraphicsPath p = RoundedRect(rr, Math.Max(0f, radius - half))) using (Pen pen = new Pen(stroke, width)) g.DrawPath(pen, p); } diff --git a/src/ConfigurO/Nocturne/NocturneWheelRouter.cs b/src/ConfigurO/Nocturne/NocturneWheelRouter.cs index 0685eab..9d48658 100644 --- a/src/ConfigurO/Nocturne/NocturneWheelRouter.cs +++ b/src/ConfigurO/Nocturne/NocturneWheelRouter.cs @@ -60,7 +60,11 @@ public bool PreFilterMessage(ref Message m) if (panel == null || !panel.CanScroll) return false; int delta = unchecked((short)((long)m.WParam >> 16)); - panel.ScrollBy(-(int)(delta / 120f * NocturneScale.S(54))); + // The step size lives in NScrollPanel; this only reports how + // far the wheel turned. It used to compute the pixel distance + // too, which meant two copies of the same constant and a + // routed wheel that could scroll differently from a focused one. + panel.ScrollByNotches(-delta / 120f); return true; // handled; do not let it reach the focused control } catch (Exception ex)