sources/Google.Solutions.IapDesktop.Application/Theme/VSThemeRuleSet.cs (411 lines of code) (raw):
//
// Copyright 2023 Google LLC
//
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//
using Google.Solutions.Common.Util;
using Google.Solutions.IapDesktop.Application.Windows;
using Google.Solutions.Mvvm.Controls;
using Google.Solutions.Mvvm.Drawing;
using Google.Solutions.Mvvm.Interop;
using Google.Solutions.Mvvm.Theme;
using System;
using System.Diagnostics;
using System.Drawing;
using System.Reflection;
using System.Windows.Forms;
using WeifenLuo.WinFormsUI.Docking;
namespace Google.Solutions.IapDesktop.Application.Theme
{
/// <summary>
/// Theming rules for applying a VS Theme (i.e., Visual Studio theme file).
/// </summary>
internal abstract class VSThemeRuleSetBase
{
private static Bitmap? listBackgroundImage;
private readonly IconInverter darkModeIconInverter;
protected readonly VSTheme theme;
protected VSThemeRuleSetBase(VSTheme theme)
{
this.theme = theme.ExpectNotNull(nameof(theme));
this.darkModeIconInverter = new IconInverter()
{
//
// NB. These factors are chosen based on what looked good, there's
// no science behind them.
//
InvertGray = true,
GrayFactor = .9f,
ColorFactor = 1.0f,
#if DEBUG
MarkerPixel = true
#endif
};
}
private void SetControlBorder(
Control control,
Color color,
Color hoverColor,
Color focusColor)
{
if (control is TextBoxBase textBox)
{
//
// TextBoxes don't fire Paint events, so we have to use
// subclassing to synthesize an event.
//
var subclass = new SubclassCallback(textBox, (ref Message m) =>
{
SubclassCallback.DefaultWndProc(ref m);
if ((WindowMessage)m.Msg == WindowMessage.WM_PAINT)
{
using (var g = Graphics.FromHwnd(textBox.Handle))
{
OnPaint(textBox, new PaintEventArgs(g, textBox.ClientRectangle));
}
}
});
subclass.UnhandledException += (_, args) => Debug.Fail(args.FullMessage());
}
else
{
control.Paint += OnPaint;
control.Disposed += (_, __) =>
{
control.Paint -= OnPaint;
};
}
void OnPaint(object sender, PaintEventArgs args)
{
var senderControl = (Control)sender;
Color borderColor;
if (senderControl.Focused)
{
borderColor = focusColor;
}
else if (args.ClipRectangle.Contains(senderControl.PointToClient(Cursor.Position)))
{
borderColor = hoverColor;
}
else
{
borderColor = color;
}
using (var pen = new Pen(borderColor, 1))
{
args.Graphics.DrawRectangle(
pen,
new Rectangle(
0,
0,
senderControl.Size.Width - 1,
senderControl.Size.Height - 1));
}
}
}
//---------------------------------------------------------------------
// Theming rules.
//---------------------------------------------------------------------
private void StyleHeaderLabel(HeaderLabel headerLabel)
{
headerLabel.ForeColor = this.theme.Palette.HeaderLabel.Text;
}
private void StyleTreeView(TreeView treeView)
{
treeView.BackColor = this.theme.Palette.ToolWindowInnerTabInactive.Background;
treeView.ForeColor = this.theme.Palette.ToolWindowInnerTabInactive.Text;
if (this.theme.IsDark)
{
this.darkModeIconInverter.Invert(treeView.ImageList);
this.darkModeIconInverter.Invert(treeView.StateImageList);
}
}
private void StyleListView(ListView listView)
{
listView.BackColor = this.theme.Palette.ToolWindowInnerTabInactive.Background;
listView.ForeColor = this.theme.Palette.ToolWindowInnerTabInactive.Text;
if (this.theme.IsDark)
{
listView.GridLines = false;
listView.HotTracking = false;
this.darkModeIconInverter.Invert(listView.SmallImageList);
this.darkModeIconInverter.Invert(listView.LargeImageList);
//
// When disabled, the list view's background turns gray by default.
// That's fine in light mode, but looks bad in dark mode. It doesn't
// seem to be possible to override this behavior by using subclassing,
// but we can disable it by using a background image.
//
// Create a 1x1 image with the intended background color, and
// set that as a tiled background image.
//
if (listBackgroundImage == null)
{
listBackgroundImage = new Bitmap(1, 1);
using (var g = Graphics.FromImage(listBackgroundImage))
using (var brush = new SolidBrush(listView.BackColor))
{
g.FillRectangle(brush, new Rectangle(Point.Empty, listBackgroundImage.Size));
}
}
listView.BackgroundImageTiled = true;
listView.BackgroundImage = listBackgroundImage;
}
}
private void StyleSplitContainer(SplitContainer container)
{
if (!container.IsSplitterFixed)
{
//
// Make sure the splitter is visible and doesn't use
// the same back color as list views and other container
// controls.
//
container.BackColor = this.theme.Palette.Button.Background;
}
}
private void StylePropertyGrid(PropertyGrid grid)
{
grid.CategorySplitterColor = this.theme.Palette.GridHeading.Background;
grid.LineColor = this.theme.Palette.GridHeading.Background;
grid.CategoryForeColor = this.theme.Palette.GridHeading.Text;
grid.ViewBackColor = this.theme.Palette.ToolWindowInnerTabInactive.Background;
grid.ViewForeColor = this.theme.Palette.GridHeading.Text;
grid.ViewBorderColor = this.theme.Palette.GridHeading.Background;
grid.HelpBackColor = this.theme.Palette.ToolWindowInnerTabInactive.Background;
grid.HelpForeColor = this.theme.Palette.GridHeading.Text;
grid.HelpBorderColor = this.theme.Palette.GridHeading.Background;
//
// The control uses a hard-coded 1px margin around labels,
// which causes the control to look very dense at high-DPI
// levels.
//
// By adjusting the private cachedRowHeight field, we can
// increase the top-margin, but not the bottom-margin. That's
// not ideal, but still slightly improves the look.
//
var deviceCaps = DeviceCapabilities.Current;
if (deviceCaps.IsHighDpi)
{
var gridView = typeof(PropertyGrid)
.GetField("gridView", BindingFlags.Instance | BindingFlags.NonPublic)?
.GetValue(grid);
if (gridView != null)
{
var cachedRowHeight = gridView.GetType()
.GetField("cachedRowHeight", BindingFlags.Instance | BindingFlags.NonPublic);
if (cachedRowHeight != null)
{
var value = (int)cachedRowHeight.GetValue(gridView);
if (value > 0)
{
//
// Add 1px for each 25% of scaling.
//
var extra = (deviceCaps.ScaleToDpi(100) - 100) / 25;
cachedRowHeight.SetValue(gridView, value + extra);
}
}
}
}
}
private void StyleToolStrip(ToolStrip strip)
{
this.theme.ApplyTo(strip);
}
private void StyleToolStripItem(ToolStripItem item)
{
if (this.theme.IsDark && !(item.Owner is StatusStrip))
{
if (item.Image is Bitmap bitmap)
{
this.darkModeIconInverter.Invert(bitmap);
}
}
}
private void StyleActiveStatusStrip(ActiveStatusStrip strip)
{
strip.ActiveForeColor = this.theme.Palette.StatusBar.ActiveText;
strip.ActiveBackColor = this.theme.Palette.StatusBar.ActiveBackground;
strip.InactiveForeColor = this.theme.Palette.StatusBar.InactiveText;
strip.InactiveBackColor = this.theme.Palette.StatusBar.InactiveBackground;
}
private void StyleButton(Button button)
{
button.FlatStyle = FlatStyle.Flat;
button.FlatAppearance.MouseDownBackColor = this.theme.Palette.Button.BackgroundPressed;
button.FlatAppearance.MouseOverBackColor = this.theme.Palette.Button.BackgroundHover;
button.BackColor = this.theme.Palette.Button.Background;
button.ForeColor = this.theme.Palette.Button.Text;
button.UseVisualStyleBackColor = false;
if (button is DropDownButton dropDownButton)
{
dropDownButton.GlyphColor = this.theme.Palette.Button.DropDownGlyphColor;
dropDownButton.GlyphDisabledColor = this.theme.Palette.Button.DropDownGlyphDisabledColor;
}
//
// Draw a custom border so that (1) we prevent the extra-thick
// border that Windows draws by default for buttons that have focus
// and (2) use a different border color for buttons that have focus.
//
button.FlatAppearance.BorderSize = 0;
SetControlBorder(
button,
this.theme.Palette.Button.Border,
this.theme.Palette.Button.BorderHover,
this.theme.Palette.Button.BorderFocused);
}
private void StyleDropDownButton(DropDownButton button)
{
if (button.Menu != null)
{
StyleToolStrip(button.Menu);
}
}
private void StyleLabel(Label label)
{
//
// Don't change the color if it was set to something
// custom (for example, as done in info bars).
//
if (label.ForeColor == Control.DefaultForeColor)
{
label.ForeColor = this.theme.Palette.Label.Text;
}
}
private void StyleLinkLabel(LinkLabel link)
{
link.LinkColor = this.theme.Palette.LinkLabel.Text;
link.ActiveLinkColor = this.theme.Palette.LinkLabel.Text;
}
private void StyleCheckBox(CheckBox checkbox)
{
checkbox.ForeColor = this.theme.Palette.Label.Text;
}
private void StyleRadioButton(RadioButton radio)
{
radio.ForeColor = this.theme.Palette.Label.Text;
}
private void StyleTextBox(TextBoxBase text)
{
if (text is RichTextBox rtfBox)
{
//
// RichTextBoxes don't support FixedSingle. Fixed3D looks
// okay in light mode, but awful in Dark mode.
//
rtfBox.BorderStyle = BorderStyle.None;
}
else
{
text.BorderStyle = BorderStyle.FixedSingle;
}
text.ForeColor = this.theme.Palette.TextBox.Text;
SetBackColor();
//
// NB. If the textbox has a scrollbar, it'll remain light gray.
// There's no good way to apply a dark theme (or any custom colors)
// to child scroll bar controls as they don't generate a
// WM_CTLCOLORSCROLLBAR message.
//
//
// Update colors when enabled/readonly status changes.
//
text.ReadOnlyChanged += OnEnabledOrReadonlyChanged;
text.EnabledChanged += OnEnabledOrReadonlyChanged;
text.Disposed += (_, __) =>
{
text.ReadOnlyChanged -= OnEnabledOrReadonlyChanged;
text.EnabledChanged -= OnEnabledOrReadonlyChanged;
};
void OnEnabledOrReadonlyChanged(object _, EventArgs __)
{
SetBackColor();
}
void SetBackColor()
{
text.BackColor = text.ReadOnly
? this.theme.Palette.TextBox.BackgroundDisabled
: this.theme.Palette.TextBox.Background;
}
SetControlBorder(
text,
this.theme.Palette.TextBox.Border,
this.theme.Palette.TextBox.BorderHover,
this.theme.Palette.TextBox.BorderFocused);
}
private void StyleMarkdownViewer(MarkdownViewer md)
{
md.Colors.BackColor = this.theme.Palette.TextBox.BackgroundDisabled;
md.Colors.CodeBackColor = this.theme.Palette.TextBox.BackgroundDisabled;
md.Colors.TextForeColor = this.theme.Palette.TextBox.Text;
md.Colors.LinkForeColor = this.theme.Palette.LinkLabel.Text;
}
private void StyleComboBox(ComboBox combo)
{
//
// NB. Use FlatStyle.System to prevent a white border
// around the control when used as a ToolStripDropDown.
//
combo.FlatStyle = FlatStyle.System;
combo.ForeColor = this.theme.Palette.ComboBox.Text;
combo.BackColor = this.theme.Palette.ComboBox.Background;
}
private void StyleGroupBox(GroupBox groupBox)
{
groupBox.ForeColor = this.theme.Palette.Label.Text;
if (this.theme.IsDark)
{
//
// In dark mode, the border is drawn in black by default,
// which doesn't look good. Draw a custom border instead
// that uses the button border color.
//
groupBox.Paint += (sender, e) =>
{
var box = (GroupBox)sender;
using (var textBrush = new SolidBrush(box.ForeColor))
using (var borderBrush = new SolidBrush(this.theme.Palette.Button.Border))
using (var borderPen = new Pen(borderBrush))
{
var headerTextSize = e.Graphics.MeasureString(box.Text, box.Font);
var boxRect = new Rectangle(
box.ClientRectangle.X,
box.ClientRectangle.Y + (int)(headerTextSize.Height / 2),
box.ClientRectangle.Width - 1,
box.ClientRectangle.Height - (int)(headerTextSize.Height / 2) - 1);
//
// Clear text and border.
//
e.Graphics.Clear(box.BackColor);
//
// Draw header.
//
e.Graphics.DrawString(box.Text, box.Font, textBrush, box.Padding.Left, 0);
//
// Draw Border, starting from the header in clockwise direction.
//
e.Graphics.DrawLines(
borderPen,
new Point[] {
new Point(boxRect.X + box.Padding.Left + (int)(headerTextSize.Width), boxRect.Y),
new Point(boxRect.X + boxRect.Width, boxRect.Y),
new Point(boxRect.X + boxRect.Width, boxRect.Y + boxRect.Height),
new Point(boxRect.X, boxRect.Y + boxRect.Height),
boxRect.Location,
new Point(boxRect.X + box.Padding.Left, boxRect.Y)
});
}
};
}
}
private void StyleTabControl(VerticalTabControl tab)
{
tab.SheetBackColor = this.theme.Palette.ToolWindowInnerTabInactive.Background;
tab.InactiveTabBackColor = this.theme.Palette.TabControl.TabBackground;
tab.InactiveTabForeColor = this.theme.Palette.TabControl.TabText;
tab.ActiveTabBackColor = this.theme.Palette.TabControl.SelectedTabBackground;
tab.ActiveTabForeColor = this.theme.Palette.TabControl.SelectedTabText;
tab.HoverTabBackColor = this.theme.Palette.TabControl.MouseOverTabBackground;
tab.HoverTabForeColor = this.theme.Palette.TabControl.MouseOverTabText;
}
private void StyleProgressBar(ProgressBarBase bar)
{
bar.BackColor = this.theme.Palette.ProgressBar.Background;
bar.ForeColor = this.theme.Palette.ProgressBar.Indicator;
}
public virtual void AddRules(ControlTheme controlTheme)
{
controlTheme.AddRule<HeaderLabel>(StyleHeaderLabel);
controlTheme.AddRule<PropertyGrid>(c => StylePropertyGrid(c));
controlTheme.AddRule<TreeView>(c => StyleTreeView(c));
controlTheme.AddRule<ListView>(c => StyleListView(c));
controlTheme.AddRule<SplitContainer>(c => StyleSplitContainer(c));
controlTheme.AddRule<PropertyGrid>(c => StylePropertyGrid(c));
controlTheme.AddRule<ToolStrip>(c => StyleToolStrip(c));
controlTheme.AddRule<Button>(c => StyleButton(c));
controlTheme.AddRule<DropDownButton>(c => StyleDropDownButton(c));
controlTheme.AddRule<Label>(c => StyleLabel(c), ControlTheme.Options.IgnoreDerivedTypes);
controlTheme.AddRule<LinkLabel>(c => StyleLinkLabel(c));
controlTheme.AddRule<CheckBox>(c => StyleCheckBox(c));
controlTheme.AddRule<RadioButton>(c => StyleRadioButton(c));
controlTheme.AddRule<TextBoxBase>(c => StyleTextBox(c));
controlTheme.AddRule<ComboBox>(c => StyleComboBox(c));
controlTheme.AddRule<GroupBox>(c => StyleGroupBox(c));
controlTheme.AddRule<VerticalTabControl>(c => StyleTabControl(c));
controlTheme.AddRule<ProgressBarBase>(c => StyleProgressBar(c));
controlTheme.AddRule<MarkdownViewer>(c => StyleMarkdownViewer(c));
controlTheme.AddRule<ActiveStatusStrip>(c => StyleActiveStatusStrip(c));
var menuTheme = new ToolStripItemTheme(true);
menuTheme.AddRule(i => StyleToolStripItem(i));
controlTheme.AddRules(menuTheme);
}
}
/// <summary>
/// Rule set for main window and dialogs.
/// </summary>
internal class VSThemeDialogRuleSet : VSThemeRuleSetBase, ControlTheme.IRuleSet
{
public VSThemeDialogRuleSet(VSTheme theme) : base(theme)
{
}
private void StyleDialog(Form form)
{
form.BackColor = this.theme.Palette.Window.Background;
}
/// <summary>
/// Register rules.
/// </summary>
public override void AddRules(ControlTheme controlTheme)
{
controlTheme.ExpectNotNull(nameof(controlTheme));
controlTheme.AddRule<Form>(c => StyleDialog(c));
base.AddRules(controlTheme);
}
}
/// <summary>
/// Rule set for dock windows (main window and tool windows).
/// </summary>
internal class VSThemeDockWindowRuleSet : VSThemeRuleSetBase, ControlTheme.IRuleSet
{
private void StyleDockWindow(Form form)
{
form.BackColor = this.theme.Palette.ToolWindowInnerTabInactive.Background;
}
private void StyleFlyoutWindow(FlyoutWindow flyout)
{
flyout.BackColor = this.theme.Palette.ToolWindowInnerTabInactive.Background;
flyout.BorderColor = SystemTheme.AccentColor;
}
private void StyleDockPanel(DockPanel dockPanel)
{
dockPanel.Theme = this.theme;
}
private void StyleToolWindow(ToolWindowViewBase window, ControlTheme controlTheme)
{
if (window.TabPageContextMenuStrip != null)
{
//
// Apply the entire control theme.
//
controlTheme.ApplyTo(window.TabPageContextMenuStrip);
}
}
public VSThemeDockWindowRuleSet(VSTheme theme) : base(theme)
{
}
/// <summary>
/// Register rules.
/// </summary>
public override void AddRules(ControlTheme controlTheme)
{
controlTheme.ExpectNotNull(nameof(controlTheme));
controlTheme.AddRule<Form>(c => StyleDockWindow(c));
controlTheme.AddRule<DockPanel>(c => StyleDockPanel(c));
controlTheme.AddRule<ToolWindowViewBase>(c => StyleToolWindow(c, controlTheme));
controlTheme.AddRule<FlyoutWindow>(c => StyleFlyoutWindow(c));
base.AddRules(controlTheme);
}
}
}