diff --git a/DockerPanel.cs b/DockerPanel.cs index e5e3832..3854769 100644 --- a/DockerPanel.cs +++ b/DockerPanel.cs @@ -9,18 +9,23 @@ public sealed class DockerPanel : UserControl private readonly ListView _images; private readonly Label _status; private readonly Action _openTab; // (title, content, fakeFileNameForHighlighting) + private readonly FlowLayoutPanel _toolbar; + private readonly Button _refreshBtn; + private readonly Label _headerContainers; + private readonly Label _headerImages; + private readonly SplitContainer _split; public DockerPanel(Action openTab) { _openTab = openTab; Dock = DockStyle.Fill; - var toolbar = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 34, Padding = new Padding(4, 4, 0, 0) }; - var refreshBtn = new Button { Text = "Refresh", AutoSize = true }; - refreshBtn.Click += async (_, _) => await RefreshAsync(); + _toolbar = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 34, Padding = new Padding(4, 4, 0, 0) }; + _refreshBtn = new Button { Text = "Refresh", AutoSize = true }; + _refreshBtn.Click += async (_, _) => await RefreshAsync(); _status = new Label { AutoSize = true, Padding = new Padding(8, 6, 0, 0), ForeColor = Color.DimGray }; - toolbar.Controls.Add(refreshBtn); - toolbar.Controls.Add(_status); + _toolbar.Controls.Add(_refreshBtn); + _toolbar.Controls.Add(_status); _containers = MakeList(new[] { "ID", "Image", "Name", "Status", "Ports" }, new[] { 110, 200, 160, 180, 180 }); _images = MakeList(new[] { "Repository", "Tag", "ID", "Size", "Created" }, new[] { 260, 120, 110, 100, 160 }); @@ -39,15 +44,62 @@ public DockerPanel(Action openTab) _images.ContextMenuStrip = imagesMenu; _images.DoubleClick += async (_, _) => await ImageInspect(); - var split = new SplitContainer { Dock = DockStyle.Fill, Orientation = Orientation.Horizontal, SplitterWidth = 4 }; - split.Panel1.Controls.Add(_containers); - split.Panel1.Controls.Add(Header("Containers")); - split.Panel2.Controls.Add(_images); - split.Panel2.Controls.Add(Header("Images")); + _headerContainers = Header("Containers"); + _headerImages = Header("Images"); - Controls.Add(split); - Controls.Add(toolbar); - split.BringToFront(); + _split = new SplitContainer { Dock = DockStyle.Fill, Orientation = Orientation.Horizontal, SplitterWidth = 4 }; + _split.Panel1.Controls.Add(_containers); + _split.Panel1.Controls.Add(_headerContainers); + _split.Panel2.Controls.Add(_images); + _split.Panel2.Controls.Add(_headerImages); + + Controls.Add(_split); + Controls.Add(_toolbar); + _split.BringToFront(); + } + + internal void ApplyTheme(Theme t) + { + BackColor = t.PanelBack; + _toolbar.BackColor = t.PanelBack; + _status.ForeColor = t.IsDark ? Color.FromArgb(0x9D, 0x9D, 0x9D) : Color.DimGray; + _split.BackColor = t.PanelBack; + _split.Panel1.BackColor = t.PanelBack; + _split.Panel2.BackColor = t.PanelBack; + + foreach (var list in new[] { _containers, _images }) + { + list.BackColor = t.ListBack; + list.ForeColor = t.ListFore; + } + ApplyNativeTheme(t.IsDark); + + foreach (var header in new[] { _headerContainers, _headerImages }) + { + header.BackColor = t.HeaderBack; + header.ForeColor = t.HeaderFore; + } + + if (t.IsDark) + { + _refreshBtn.FlatStyle = FlatStyle.Flat; + _refreshBtn.BackColor = t.MenuBack; + _refreshBtn.ForeColor = t.MenuFore; + _refreshBtn.FlatAppearance.BorderColor = t.MenuBorder; + } + else + { + _refreshBtn.FlatStyle = FlatStyle.Standard; + _refreshBtn.UseVisualStyleBackColor = true; + _refreshBtn.ForeColor = t.PanelFore; + } + } + + /// Applies dark-mode scrollbars to the ListViews; safe to call before/after their handles exist. + public void ApplyNativeTheme(bool dark) + { + if (_containers.IsHandleCreated) MainForm.ApplyDarkScrollbars(_containers.Handle, dark); + if (_images.IsHandleCreated) MainForm.ApplyDarkScrollbars(_images.Handle, dark); } public async Task RefreshAsync() diff --git a/Languages.cs b/Languages.cs index c58fdf6..173ea49 100644 --- a/Languages.cs +++ b/Languages.cs @@ -2,163 +2,192 @@ namespace CodeViewer; -/// Maps file extensions to Scintilla lexers, keywords, and a VS-light color scheme. +/// Semantic syntax-highlighting tokens; each maps to a color in the active theme's palette. +enum Tok { Default, Comment, Keyword, Type, Str, Num, Preproc, Err, Property, Tag, Attr, Regex, Muted } + +/// Maps file extensions to Scintilla lexers, keywords, and dark/light color palettes. static class Languages { - private sealed record StyleRule(int[] Ids, int Rgb, bool Bold = false, bool Italic = false); + private sealed record StyleRule(int[] Ids, Tok Token, bool Bold = false, bool Italic = false); private sealed record Lang(string Lexer, string Display, string? Kw0 = null, string? Kw1 = null); - // palette - private const int Comment = 0x008000; - private const int Keyword = 0x0000FF; - private const int TypeCol = 0x2B91AF; - private const int Str = 0xA31515; - private const int Num = 0x098658; - private const int Preproc = 0xAF00DB; - private const int ErrCol = 0xCD0000; + // palettes: raw RGB per semantic token, light (VS-light) and dark (VS Code Dark+) + private static readonly Dictionary LightPalette = new() + { + [Tok.Default] = 0x000000, + [Tok.Comment] = 0x008000, + [Tok.Keyword] = 0x0000FF, + [Tok.Type] = 0x2B91AF, + [Tok.Str] = 0xA31515, + [Tok.Num] = 0x098658, + [Tok.Preproc] = 0xAF00DB, + [Tok.Err] = 0xCD0000, + [Tok.Property] = 0x0451A5, + [Tok.Tag] = 0x800000, + [Tok.Attr] = 0xE50000, + [Tok.Regex] = 0x811F3F, + [Tok.Muted] = 0x808080, + }; + + private static readonly Dictionary DarkPalette = new() + { + [Tok.Default] = 0xD4D4D4, + [Tok.Comment] = 0x6A9955, + [Tok.Keyword] = 0x569CD6, + [Tok.Type] = 0x4EC9B0, + [Tok.Str] = 0xCE9178, + [Tok.Num] = 0xB5CEA8, + [Tok.Preproc] = 0xC586C0, + [Tok.Err] = 0xF44747, + [Tok.Property] = 0x9CDCFE, + [Tok.Tag] = 0x569CD6, + [Tok.Attr] = 0x9CDCFE, + [Tok.Regex] = 0xD16969, + [Tok.Muted] = 0x6E7681, + }; // per-lexer style tables (raw SCE_* style numbers, uniform across ScintillaNET versions) private static readonly Dictionary LexerStyles = new() { ["cpp"] = new[] { - new StyleRule(new[] { 1, 2, 3, 15, 17, 18 }, Comment), - new StyleRule(new[] { 4 }, Num), - new StyleRule(new[] { 5 }, Keyword, Bold: true), - new StyleRule(new[] { 6, 7, 12, 13, 20 }, Str), - new StyleRule(new[] { 14 }, 0x811F3F), // regex - new StyleRule(new[] { 9 }, Preproc), - new StyleRule(new[] { 16, 19 }, TypeCol), // word2, globalclass + new StyleRule(new[] { 1, 2, 3, 15, 17, 18 }, Tok.Comment), + new StyleRule(new[] { 4 }, Tok.Num), + new StyleRule(new[] { 5 }, Tok.Keyword, Bold: true), + new StyleRule(new[] { 6, 7, 12, 13, 20 }, Tok.Str), + new StyleRule(new[] { 14 }, Tok.Regex), + new StyleRule(new[] { 9 }, Tok.Preproc), + new StyleRule(new[] { 16, 19 }, Tok.Type), // word2, globalclass }, ["python"] = new[] { - new StyleRule(new[] { 1, 12 }, Comment), - new StyleRule(new[] { 2 }, Num), - new StyleRule(new[] { 3, 4, 6, 7, 13, 16, 17, 18, 19 }, Str), - new StyleRule(new[] { 5 }, Keyword, Bold: true), - new StyleRule(new[] { 8 }, TypeCol, Bold: true), // class name - new StyleRule(new[] { 9 }, TypeCol), // def name - new StyleRule(new[] { 14 }, Keyword), // word2 - new StyleRule(new[] { 15 }, Preproc), // decorator + new StyleRule(new[] { 1, 12 }, Tok.Comment), + new StyleRule(new[] { 2 }, Tok.Num), + new StyleRule(new[] { 3, 4, 6, 7, 13, 16, 17, 18, 19 }, Tok.Str), + new StyleRule(new[] { 5 }, Tok.Keyword, Bold: true), + new StyleRule(new[] { 8 }, Tok.Type, Bold: true), // class name + new StyleRule(new[] { 9 }, Tok.Type), // def name + new StyleRule(new[] { 14 }, Tok.Keyword), // word2 + new StyleRule(new[] { 15 }, Tok.Preproc), // decorator }, ["json"] = new[] { - new StyleRule(new[] { 1 }, Num), - new StyleRule(new[] { 2 }, Str), - new StyleRule(new[] { 4 }, 0x0451A5), // property name - new StyleRule(new[] { 5 }, Preproc), // escape sequence - new StyleRule(new[] { 6, 7 }, Comment), - new StyleRule(new[] { 11, 12 }, Keyword, Bold: true), - new StyleRule(new[] { 13 }, ErrCol), + new StyleRule(new[] { 1 }, Tok.Num), + new StyleRule(new[] { 2 }, Tok.Str), + new StyleRule(new[] { 4 }, Tok.Property), // property name + new StyleRule(new[] { 5 }, Tok.Preproc), // escape sequence + new StyleRule(new[] { 6, 7 }, Tok.Comment), + new StyleRule(new[] { 11, 12 }, Tok.Keyword, Bold: true), + new StyleRule(new[] { 13 }, Tok.Err), }, ["yaml"] = new[] { - new StyleRule(new[] { 1 }, Comment), - new StyleRule(new[] { 2 }, 0x0451A5), // key / identifier - new StyleRule(new[] { 3 }, Keyword, Bold: true), // true/false/null - new StyleRule(new[] { 4 }, Num), - new StyleRule(new[] { 5, 6 }, Preproc), // &anchor, --- document - new StyleRule(new[] { 8 }, ErrCol), + new StyleRule(new[] { 1 }, Tok.Comment), + new StyleRule(new[] { 2 }, Tok.Property), // key / identifier + new StyleRule(new[] { 3 }, Tok.Keyword, Bold: true), // true/false/null + new StyleRule(new[] { 4 }, Tok.Num), + new StyleRule(new[] { 5, 6 }, Tok.Preproc), // &anchor, --- document + new StyleRule(new[] { 8 }, Tok.Err), }, ["xml"] = new[] { - new StyleRule(new[] { 1, 2 }, 0x800000), // tags - new StyleRule(new[] { 3, 4 }, 0xE50000), // attributes - new StyleRule(new[] { 5 }, Num), - new StyleRule(new[] { 6, 7 }, Keyword), // attribute values - new StyleRule(new[] { 9 }, Comment), - new StyleRule(new[] { 10 }, Preproc), // entity - new StyleRule(new[] { 17 }, 0x808080), // cdata + new StyleRule(new[] { 1, 2 }, Tok.Tag), + new StyleRule(new[] { 3, 4 }, Tok.Attr), + new StyleRule(new[] { 5 }, Tok.Num), + new StyleRule(new[] { 6, 7 }, Tok.Keyword), // attribute values + new StyleRule(new[] { 9 }, Tok.Comment), + new StyleRule(new[] { 10 }, Tok.Preproc), // entity + new StyleRule(new[] { 17 }, Tok.Muted), // cdata }, ["hypertext"] = new[] { - new StyleRule(new[] { 1, 2 }, 0x800000), - new StyleRule(new[] { 3, 4 }, 0xE50000), - new StyleRule(new[] { 5 }, Num), - new StyleRule(new[] { 6, 7 }, Keyword), - new StyleRule(new[] { 9 }, Comment), - new StyleRule(new[] { 10 }, Preproc), - new StyleRule(new[] { 17 }, 0x808080), + new StyleRule(new[] { 1, 2 }, Tok.Tag), + new StyleRule(new[] { 3, 4 }, Tok.Attr), + new StyleRule(new[] { 5 }, Tok.Num), + new StyleRule(new[] { 6, 7 }, Tok.Keyword), + new StyleRule(new[] { 9 }, Tok.Comment), + new StyleRule(new[] { 10 }, Tok.Preproc), + new StyleRule(new[] { 17 }, Tok.Muted), }, ["css"] = new[] { - new StyleRule(new[] { 9 }, Comment), - new StyleRule(new[] { 1 }, 0x800000), // tag selector - new StyleRule(new[] { 2, 10 }, TypeCol), // .class, #id - new StyleRule(new[] { 3, 4, 12 }, Preproc), // :pseudo, @directive - new StyleRule(new[] { 6, 7, 15 }, 0x0451A5), // property names - new StyleRule(new[] { 8 }, Str), // values - new StyleRule(new[] { 13, 14 }, Str), - new StyleRule(new[] { 11 }, ErrCol), // !important + new StyleRule(new[] { 9 }, Tok.Comment), + new StyleRule(new[] { 1 }, Tok.Tag), // tag selector + new StyleRule(new[] { 2, 10 }, Tok.Type), // .class, #id + new StyleRule(new[] { 3, 4, 12 }, Tok.Preproc), // :pseudo, @directive + new StyleRule(new[] { 6, 7, 15 }, Tok.Property), // property names + new StyleRule(new[] { 8 }, Tok.Str), // values + new StyleRule(new[] { 13, 14 }, Tok.Str), + new StyleRule(new[] { 11 }, Tok.Err), // !important }, ["sql"] = new[] { - new StyleRule(new[] { 1, 2, 3 }, Comment), - new StyleRule(new[] { 4 }, Num), - new StyleRule(new[] { 5 }, Keyword, Bold: true), - new StyleRule(new[] { 6, 7 }, Str), + new StyleRule(new[] { 1, 2, 3 }, Tok.Comment), + new StyleRule(new[] { 4 }, Tok.Num), + new StyleRule(new[] { 5 }, Tok.Keyword, Bold: true), + new StyleRule(new[] { 6, 7 }, Tok.Str), }, ["bash"] = new[] { - new StyleRule(new[] { 2 }, Comment), - new StyleRule(new[] { 3 }, Num), - new StyleRule(new[] { 4 }, Keyword, Bold: true), - new StyleRule(new[] { 5, 6, 12, 13 }, Str), - new StyleRule(new[] { 9, 10 }, Preproc), // $var, ${param} - new StyleRule(new[] { 11 }, 0x811F3F), // backticks - new StyleRule(new[] { 1 }, ErrCol), + new StyleRule(new[] { 2 }, Tok.Comment), + new StyleRule(new[] { 3 }, Tok.Num), + new StyleRule(new[] { 4 }, Tok.Keyword, Bold: true), + new StyleRule(new[] { 5, 6, 12, 13 }, Tok.Str), + new StyleRule(new[] { 9, 10 }, Tok.Preproc), // $var, ${param} + new StyleRule(new[] { 11 }, Tok.Regex), // backticks + new StyleRule(new[] { 1 }, Tok.Err), }, ["powershell"] = new[] { - new StyleRule(new[] { 1, 13, 16 }, Comment), - new StyleRule(new[] { 2, 3, 14, 15 }, Str), - new StyleRule(new[] { 4 }, Num), - new StyleRule(new[] { 5 }, Preproc), // $variable - new StyleRule(new[] { 8 }, Keyword, Bold: true), - new StyleRule(new[] { 9, 10, 11 }, TypeCol), // cmdlet, alias, function + new StyleRule(new[] { 1, 13, 16 }, Tok.Comment), + new StyleRule(new[] { 2, 3, 14, 15 }, Tok.Str), + new StyleRule(new[] { 4 }, Tok.Num), + new StyleRule(new[] { 5 }, Tok.Preproc), // $variable + new StyleRule(new[] { 8 }, Tok.Keyword, Bold: true), + new StyleRule(new[] { 9, 10, 11 }, Tok.Type), // cmdlet, alias, function }, ["markdown"] = new[] { - new StyleRule(new[] { 6, 7, 8, 9, 10, 11 }, Keyword, Bold: true), // headers - new StyleRule(new[] { 2, 3 }, 0x000000, Bold: true), - new StyleRule(new[] { 4, 5 }, 0x000000, Italic: true), - new StyleRule(new[] { 13, 14 }, Preproc), // list markers - new StyleRule(new[] { 15 }, Comment), // blockquote - new StyleRule(new[] { 18 }, 0x0451A5), // link - new StyleRule(new[] { 19, 20, 21 }, Str), // code + new StyleRule(new[] { 6, 7, 8, 9, 10, 11 }, Tok.Keyword, Bold: true), // headers + new StyleRule(new[] { 2, 3 }, Tok.Default, Bold: true), + new StyleRule(new[] { 4, 5 }, Tok.Default, Italic: true), + new StyleRule(new[] { 13, 14 }, Tok.Preproc), // list markers + new StyleRule(new[] { 15 }, Tok.Comment), // blockquote + new StyleRule(new[] { 18 }, Tok.Property), // link + new StyleRule(new[] { 19, 20, 21 }, Tok.Str), // code }, ["batch"] = new[] { - new StyleRule(new[] { 1 }, Comment), - new StyleRule(new[] { 2 }, Keyword, Bold: true), - new StyleRule(new[] { 3 }, Preproc), // :label - new StyleRule(new[] { 5 }, 0x0451A5), // command + new StyleRule(new[] { 1 }, Tok.Comment), + new StyleRule(new[] { 2 }, Tok.Keyword, Bold: true), + new StyleRule(new[] { 3 }, Tok.Preproc), // :label + new StyleRule(new[] { 5 }, Tok.Property), // command }, ["props"] = new[] { - new StyleRule(new[] { 1 }, Comment), - new StyleRule(new[] { 2 }, 0x800000, Bold: true), // [section] - new StyleRule(new[] { 5 }, 0x0451A5), // key - new StyleRule(new[] { 4 }, Str), // value + new StyleRule(new[] { 1 }, Tok.Comment), + new StyleRule(new[] { 2 }, Tok.Tag, Bold: true), // [section] + new StyleRule(new[] { 5 }, Tok.Property), // key + new StyleRule(new[] { 4 }, Tok.Str), // value }, ["latex"] = new[] { - new StyleRule(new[] { 1, 9 }, Keyword), // \commands - new StyleRule(new[] { 2, 5 }, TypeCol), // {tags} - new StyleRule(new[] { 3, 6 }, 0x811F3F), // math - new StyleRule(new[] { 4, 7 }, Comment), - new StyleRule(new[] { 8 }, Str), // verbatim - new StyleRule(new[] { 10 }, Preproc), - new StyleRule(new[] { 11 }, 0x808080), // [options] - new StyleRule(new[] { 12 }, ErrCol), + new StyleRule(new[] { 1, 9 }, Tok.Keyword), // \commands + new StyleRule(new[] { 2, 5 }, Tok.Type), // {tags} + new StyleRule(new[] { 3, 6 }, Tok.Regex), // math + new StyleRule(new[] { 4, 7 }, Tok.Comment), + new StyleRule(new[] { 8 }, Tok.Str), // verbatim + new StyleRule(new[] { 10 }, Tok.Preproc), + new StyleRule(new[] { 11 }, Tok.Muted), // [options] + new StyleRule(new[] { 12 }, Tok.Err), }, ["makefile"] = new[] { - new StyleRule(new[] { 1 }, Comment), - new StyleRule(new[] { 2 }, Preproc), - new StyleRule(new[] { 3 }, 0x0451A5), - new StyleRule(new[] { 5 }, 0x800000, Bold: true), // target - new StyleRule(new[] { 9 }, ErrCol), + new StyleRule(new[] { 1 }, Tok.Comment), + new StyleRule(new[] { 2 }, Tok.Preproc), + new StyleRule(new[] { 3 }, Tok.Property), + new StyleRule(new[] { 5 }, Tok.Tag, Bold: true), // target + new StyleRule(new[] { 9 }, Tok.Err), }, }; @@ -256,8 +285,8 @@ private sealed record Lang(string Lexer, string Display, string? Kw0 = null, str [".env"] = new("props", "Env"), }; - /// Applies lexer, keywords, and colors for the given file. Returns a display name for the status bar. - public static string Apply(Scintilla editor, string? filePath) + /// Applies lexer, keywords, and theme colors for the given file. Returns a display name for the status bar. + public static string Apply(Scintilla editor, string? filePath, Theme theme) { Lang? lang = null; if (filePath != null) @@ -279,9 +308,11 @@ public static string Apply(Scintilla editor, string? filePath) if (LexerStyles.TryGetValue(lang.Lexer, out var rules)) { + var palette = theme.IsDark ? DarkPalette : LightPalette; foreach (var rule in rules) { - var color = Color.FromArgb((rule.Rgb >> 16) & 0xFF, (rule.Rgb >> 8) & 0xFF, rule.Rgb & 0xFF); + var rgb = palette[rule.Token]; + var color = Color.FromArgb((rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF); foreach (var id in rule.Ids) { editor.Styles[id].ForeColor = color; diff --git a/MainForm.cs b/MainForm.cs index 6e1d5b8..0d0c7c9 100644 --- a/MainForm.cs +++ b/MainForm.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Net; +using System.Runtime.InteropServices; using System.Text; using Markdig; using ScintillaNET; @@ -15,18 +16,22 @@ public class MainForm : Form private readonly SplitContainer _split; private readonly TreeView _tree; - private readonly TabControl _tabs; + private readonly ThemedTabControl _tabs; + private readonly StatusStrip _status; private readonly ToolStripStatusLabel _statusPath; private readonly ToolStripStatusLabel _statusLang; private readonly ToolStripStatusLabel _statusPos; private readonly ToolStripMenuItem _wordWrapMenu; private readonly ToolStripMenuItem _sidebarMenu; + private readonly ToolStripMenuItem _lightModeMenu; private TabPage? _dockerPage; + private Theme _theme = Theme.Load(); private sealed class TabState { public Scintilla Editor = null!; public string? FilePath; + public string? HighlightPath; public bool IsDirty; public Encoding Encoding = new UTF8Encoding(false); public string Language = "Plain text"; @@ -64,8 +69,16 @@ public MainForm(string[] args) }; _sidebarMenu = new ToolStripMenuItem("Folder &Sidebar") { CheckOnClick = true, Checked = true }; _sidebarMenu.Click += (_, _) => _split!.Panel1Collapsed = !_sidebarMenu.Checked; + _lightModeMenu = new ToolStripMenuItem("&Light Mode") { CheckOnClick = true, Checked = !_theme.IsDark }; + _lightModeMenu.Click += (_, _) => + { + _theme = _lightModeMenu.Checked ? Theme.Light : Theme.Dark; + Theme.Save(_theme); + ApplyTheme(); + }; viewMenu.DropDownItems.Add(_wordWrapMenu); viewMenu.DropDownItems.Add(_sidebarMenu); + viewMenu.DropDownItems.Add(_lightModeMenu); viewMenu.DropDownItems.Add(new ToolStripSeparator()); viewMenu.DropDownItems.Add(MenuItem("&Markdown Preview", Keys.Control | Keys.Shift | Keys.V, (_, _) => ToggleMarkdownPreview())); @@ -80,11 +93,11 @@ public MainForm(string[] args) MainMenuStrip = menu; // status bar - var status = new StatusStrip(); + _status = new StatusStrip { SizingGrip = false }; _statusPath = new ToolStripStatusLabel("Ready") { Spring = true, TextAlign = ContentAlignment.MiddleLeft }; _statusLang = new ToolStripStatusLabel(""); _statusPos = new ToolStripStatusLabel(""); - status.Items.AddRange(new ToolStripItem[] { _statusPath, _statusLang, _statusPos }); + _status.Items.AddRange(new ToolStripItem[] { _statusPath, _statusLang, _statusPos }); // sidebar tree + editor tabs _tree = new TreeView @@ -93,14 +106,20 @@ public MainForm(string[] args) BorderStyle = System.Windows.Forms.BorderStyle.None, ShowLines = false, Font = new Font("Segoe UI", 9f), - BackColor = Color.FromArgb(250, 250, 250), }; _tree.BeforeExpand += Tree_BeforeExpand; - _tree.NodeMouseDoubleClick += (_, e) => { if (e.Node.Tag is string path && File.Exists(path)) OpenFile(path); }; + _tree.NodeMouseDoubleClick += (_, e) => { if (e.Node?.Tag is string path && File.Exists(path)) OpenFile(path); }; - _tabs = new TabControl { Dock = DockStyle.Fill }; + _tabs = new ThemedTabControl + { + Dock = DockStyle.Fill, + DrawMode = System.Windows.Forms.TabDrawMode.OwnerDrawFixed, + Padding = new Point(12, 4), + StripBack = _theme.TabStripBack, + }; _tabs.SelectedIndexChanged += (_, _) => UpdateStatus(); _tabs.MouseDown += Tabs_MouseDown; + _tabs.DrawItem += Tabs_DrawItem; var tabContext = new ContextMenuStrip(); tabContext.Items.Add("Close", null, (_, _) => { if (_tabs.SelectedTab != null) CloseTab(_tabs.SelectedTab); }); @@ -119,7 +138,7 @@ public MainForm(string[] args) _split.Panel2.Controls.Add(_tabs); Controls.Add(_split); - Controls.Add(status); + Controls.Add(_status); Controls.Add(menu); _split.BringToFront(); @@ -138,6 +157,9 @@ public MainForm(string[] args) Shown += (_, _) => ToggleMarkdownPreview(); if (args.Contains("--docker")) Shown += async (_, _) => await ShowDocker(); + + Shown += (_, _) => ApplyNativeDarkMode(); // child handles exist by now + ApplyTheme(); } private static ToolStripMenuItem MenuItem(string text, Keys keys, EventHandler onClick) @@ -202,14 +224,14 @@ private void AddEditorTab(TabState state, string title, string text, string? hig { var editor = CreateEditor(); state.Editor = editor; + state.HighlightPath = highlightPath; editor.Text = text; editor.EmptyUndoBuffer(); editor.SetSavePoint(); - state.Language = Languages.Apply(editor, highlightPath); - SetLineNumberWidth(editor); + state.Language = ApplyEditorTheme(editor, highlightPath); var page = new TabPage(title) { Tag = state, ToolTipText = state.FilePath ?? title }; - var split = new SplitContainer { Dock = DockStyle.Fill, SplitterWidth = 4, Panel2Collapsed = true }; + var split = new SplitContainer { Dock = DockStyle.Fill, SplitterWidth = 4, Panel2Collapsed = true, BackColor = _theme.EditorBack }; split.Panel1.Controls.Add(editor); page.Controls.Add(split); WireEditor(editor, page); @@ -240,25 +262,42 @@ private Scintilla CreateEditor() TabWidth = 4, UseTabs = false, CaretLineVisible = true, - CaretLineBackColor = Color.FromArgb(245, 247, 250), WrapMode = _wordWrapMenu.Checked ? WrapMode.Word : WrapMode.None, AllowDrop = true, }; - editor.Styles[ScintillaNET.Style.Default].Font = "Cascadia Mono"; - editor.Styles[ScintillaNET.Style.Default].Size = 10; - editor.StyleClearAll(); - editor.Styles[ScintillaNET.Style.LineNumber].ForeColor = Color.FromArgb(140, 140, 140); - editor.Styles[ScintillaNET.Style.LineNumber].BackColor = Color.FromArgb(248, 248, 248); editor.Margins[0].Type = MarginType.Number; editor.Margins[1].Width = 4; - editor.SetSelectionBackColor(true, Color.FromArgb(173, 214, 255)); editor.DragEnter += OnDragEnter; editor.DragDrop += OnDragDrop; return editor; } + /// Applies the active theme's editor colors and syntax palette; re-runnable on theme toggle. + private string ApplyEditorTheme(Scintilla editor, string? highlightPath) + { + editor.Styles[ScintillaNET.Style.Default].Font = "Cascadia Mono"; + editor.Styles[ScintillaNET.Style.Default].Size = 10; + editor.Styles[ScintillaNET.Style.Default].ForeColor = _theme.EditorFore; + editor.Styles[ScintillaNET.Style.Default].BackColor = _theme.EditorBack; + editor.StyleClearAll(); + + editor.Styles[ScintillaNET.Style.LineNumber].ForeColor = _theme.LineNumberFore; + editor.Styles[ScintillaNET.Style.LineNumber].BackColor = _theme.LineNumberBack; + editor.CaretLineBackColor = _theme.CaretLineBack; + editor.CaretForeColor = _theme.CaretFore; + editor.SetSelectionBackColor(true, _theme.SelectionBack); + editor.Styles[ScintillaNET.Style.IndentGuide].ForeColor = _theme.IndentGuideFore; + editor.Styles[ScintillaNET.Style.IndentGuide].BackColor = _theme.EditorBack; + editor.SetFoldMarginColor(true, _theme.EditorBack); + editor.SetFoldMarginHighlightColor(true, _theme.EditorBack); + + var language = Languages.Apply(editor, highlightPath, _theme); + SetLineNumberWidth(editor); + return language; + } + private void WireEditor(Scintilla editor, TabPage page) { editor.SavePointLeft += (_, _) => @@ -364,30 +403,39 @@ private void ToggleMarkdownPreview() RenderMarkdown(state); } - private static void RenderMarkdown(TabState state) + private void RenderMarkdown(TabState state) { if (state.Preview == null) return; - state.Preview.DocumentText = BuildMarkdownHtmlDocument(state.Editor.Text, state.FilePath); + state.Preview.DocumentText = BuildMarkdownHtmlDocument(state.Editor.Text, state.FilePath, _theme.IsDark); } - private static string BuildMarkdownHtmlDocument(string markdown, string? title) + private static string BuildMarkdownHtmlDocument(string markdown, string? title, bool dark) { string body; try { body = Markdown.ToHtml(markdown, MdPipeline); } catch (Exception ex) { body = "
render error: " + WebUtility.HtmlEncode(ex.Message) + "
"; } var safeTitle = WebUtility.HtmlEncode(title != null ? Path.GetFileName(title) : "Markdown output"); + var style = dark + ? "body{font-family:'Segoe UI',system-ui,-apple-system,sans-serif;font-size:16px;line-height:1.65;color:#d4d4d4;max-width:900px;margin:0 auto;padding:32px 40px;background:#1e1e1e;}" + + "h1,h2{border-bottom:1px solid #3c3c3c;padding-bottom:.3em;}h1{font-size:2em;}h2{font-size:1.45em;margin-top:1.8em;}h3{font-size:1.2em;margin-top:1.5em;}" + + "p,ul,ol,blockquote,pre,table{margin:0 0 1em;}ul,ol{padding-left:1.6em;}li+li{margin-top:.25em;}" + + "code{background:#2d2d2d;padding:.15em .35em;border-radius:4px;font-family:'Cascadia Mono',Consolas,monospace;font-size:.9em;}" + + "pre{background:#252526;padding:16px;border-radius:6px;overflow-x:auto;}pre code{background:none;padding:0;font-size:.9em;}" + + "blockquote{border-left:4px solid #3c3c3c;padding-left:16px;color:#9d9d9d;}" + + "table{border-collapse:collapse;display:block;overflow-x:auto;}th,td{border:1px solid #3c3c3c;padding:6px 12px;}th{background:#252526;}" + + "img{max-width:100%;height:auto;}a{color:#4fc1ff;}hr{border:0;border-top:1px solid #3c3c3c;margin:24px 0;}" + : "body{font-family:'Segoe UI',system-ui,-apple-system,sans-serif;font-size:16px;line-height:1.65;color:#1f2328;max-width:900px;margin:0 auto;padding:32px 40px;background:#fff;}" + + "h1,h2{border-bottom:1px solid #d8dee4;padding-bottom:.3em;}h1{font-size:2em;}h2{font-size:1.45em;margin-top:1.8em;}h3{font-size:1.2em;margin-top:1.5em;}" + + "p,ul,ol,blockquote,pre,table{margin:0 0 1em;}ul,ol{padding-left:1.6em;}li+li{margin-top:.25em;}" + + "code{background:#f0f1f2;padding:.15em .35em;border-radius:4px;font-family:'Cascadia Mono',Consolas,monospace;font-size:.9em;}" + + "pre{background:#f6f8fa;padding:16px;border-radius:6px;overflow-x:auto;}pre code{background:none;padding:0;font-size:.9em;}" + + "blockquote{border-left:4px solid #d8dee4;padding-left:16px;color:#59636e;}" + + "table{border-collapse:collapse;display:block;overflow-x:auto;}th,td{border:1px solid #d8dee4;padding:6px 12px;}th{background:#f6f8fa;}" + + "img{max-width:100%;height:auto;}a{color:#0969da;}hr{border:0;border-top:1px solid #d8dee4;margin:24px 0;}"; return - "" + safeTitle + "" + body + ""; } @@ -405,7 +453,8 @@ private void CompileMarkdown() var outputPath = Path.ChangeExtension(state.FilePath, ".html"); try { - var html = BuildMarkdownHtmlDocument(state.Editor.Text, state.FilePath); + // standalone output always uses the light stylesheet, regardless of app theme + var html = BuildMarkdownHtmlDocument(state.Editor.Text, state.FilePath, dark: false); File.WriteAllText(outputPath, html, new UTF8Encoding(false)); _statusPath.Text = $"Compiled Markdown -> {outputPath}"; Process.Start(new ProcessStartInfo(outputPath) { UseShellExecute = true }); @@ -485,11 +534,13 @@ private async Task ShowDocker() if (_dockerPage == null || !_tabs.TabPages.Contains(_dockerPage)) { var panel = new DockerPanel(OpenTextTab); + panel.ApplyTheme(_theme); _dockerPage = new TabPage("Docker"); _dockerPage.Controls.Add(panel); _tabs.TabPages.Add(_dockerPage); _tabs.SelectedTab = _dockerPage; await panel.RefreshAsync(); + panel.ApplyNativeTheme(_theme.IsDark); } else { @@ -534,8 +585,9 @@ private bool SaveTabAs(TabPage page) if (dlg.ShowDialog(this) != DialogResult.OK) return false; state.FilePath = dlg.FileName; + state.HighlightPath = dlg.FileName; page.ToolTipText = dlg.FileName; - state.Language = Languages.Apply(state.Editor, dlg.FileName); + state.Language = Languages.Apply(state.Editor, dlg.FileName, _theme); if (!SaveTab(page)) return false; page.Text = Path.GetFileName(dlg.FileName); UpdateStatus(); @@ -666,4 +718,123 @@ private void OpenFolderDialogAction() if (dlg.ShowDialog(this) == DialogResult.OK) OpenFolder(dlg.SelectedPath); } + + // ---------- theming ---------- + + private void ApplyTheme() + { + BackColor = _theme.PanelBack; + ToolStripManager.Renderer = new ThemeRenderer(new ThemeColorTable(_theme), _theme); + if (MainMenuStrip != null) MainMenuStrip.BackColor = _theme.MenuBack; + // explicit colors: the professional renderer skips its gradient once BackColor + // is non-default, and the ambient form BackColor already makes it non-default + _status.BackColor = _theme.StatusBack; + _status.ForeColor = _theme.StatusFore; + + _tree.BackColor = _theme.SidebarBack; + _tree.ForeColor = _theme.SidebarFore; + _split.BackColor = _theme.TabStripBack; + _split.Panel1.BackColor = _theme.SidebarBack; + _split.Panel2.BackColor = _theme.TabStripBack; + _tabs.BackColor = _theme.TabStripBack; + _tabs.StripBack = _theme.TabStripBack; + + foreach (TabPage page in _tabs.TabPages) + { + if (State(page) is { } state) + { + if (page.Controls.Count > 0 && page.Controls[0] is SplitContainer tabSplit) + tabSplit.BackColor = _theme.EditorBack; + state.Language = ApplyEditorTheme(state.Editor, state.HighlightPath); + RenderMarkdown(state); + } + else if (page == _dockerPage && page.Controls.Count > 0 && page.Controls[0] is DockerPanel dockerPanel) + { + dockerPanel.ApplyTheme(_theme); + } + } + _tabs.Invalidate(); + UpdateStatus(); + + ApplyNativeDarkMode(); + } + + private void Tabs_DrawItem(object? sender, DrawItemEventArgs e) + { + var page = _tabs.TabPages[e.Index]; + bool selected = e.Index == _tabs.SelectedIndex; + var back = selected ? _theme.TabActiveBack : _theme.TabInactiveBack; + var fore = selected ? _theme.TabActiveFore : _theme.TabInactiveFore; + + using var backBrush = new SolidBrush(back); + e.Graphics.FillRectangle(backBrush, e.Bounds); + TextRenderer.DrawText(e.Graphics, page.Text, _tabs.Font, e.Bounds, fore, + TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); + } + + /// TabControl that repaints the native header strip and pane border, which ignore BackColor. + private sealed class ThemedTabControl : TabControl + { + public Color StripBack = SystemColors.Control; + + protected override void WndProc(ref Message m) + { + base.WndProc(ref m); + if (m.Msg == 0x000F) PaintChrome(); // WM_PAINT + } + + private void PaintChrome() + { + if (!IsHandleCreated || IsDisposed) return; + using var g = Graphics.FromHwnd(Handle); + using var region = new Region(ClientRectangle); + region.Exclude(DisplayRectangle); + for (int i = 0; i < TabCount; i++) region.Exclude(GetTabRect(i)); + using var brush = new SolidBrush(StripBack); + g.FillRegion(brush, region); + } + } + + // ---------- native dark chrome (best effort) ---------- + + [DllImport("dwmapi.dll")] + private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int attrValue, int attrSize); + + [DllImport("uxtheme.dll", CharSet = CharSet.Unicode)] + private static extern int SetWindowTheme(IntPtr hWnd, string? pszSubAppName, string? pszSubIdList); + + private const int DwmwaUseImmersiveDarkMode = 20; + + /// Applies dark scrollbars/list chrome to a control's handle; safe to call anywhere, never throws. + internal static void ApplyDarkScrollbars(IntPtr handle, bool dark) + { + try { SetWindowTheme(handle, dark ? "DarkMode_Explorer" : "Explorer", null); } catch { } + } + + protected override void OnHandleCreated(EventArgs e) + { + base.OnHandleCreated(e); + ApplyNativeDarkMode(); + } + + private void ApplyNativeDarkMode() + { + if (!IsHandleCreated) return; + + try + { + int useDark = _theme.IsDark ? 1 : 0; + DwmSetWindowAttribute(Handle, DwmwaUseImmersiveDarkMode, ref useDark, sizeof(int)); + } + catch { } + + if (_tree.IsHandleCreated) ApplyDarkScrollbars(_tree.Handle, _theme.IsDark); + foreach (TabPage page in _tabs.TabPages) + { + if (State(page) is { } s && s.Editor.IsHandleCreated) + ApplyDarkScrollbars(s.Editor.Handle, _theme.IsDark); + else if (page == _dockerPage && page.Controls.Count > 0 && page.Controls[0] is DockerPanel dockerPanel) + dockerPanel.ApplyNativeTheme(_theme.IsDark); + } + } } diff --git a/README.md b/README.md index a3721fd..e3fd3ef 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ bin\Release\net10.0-windows\codeviewer.exe [files or folders...] ## Features - Syntax coloring: TS/JS/JSX, Python, Java, C#, C/C++, Go, Rust, PHP, Kotlin, SQL, HTML/CSS, JSON, YAML, XML, Markdown, LaTeX/BibTeX, shell/bash, PowerShell, Dockerfile, Terraform, batch, ini/env/toml, Makefile, proto +- **Dark mode by default** with VS Code Dark+-style syntax colors; View > Light Mode toggles the light theme. Choice persists across runs (`%APPDATA%\codeviewer\settings.txt`). - **Markdown preview**: View > Markdown Preview (Ctrl+Shift+V) renders the file side by side, live as you type. `codeviewer --preview notes.md` opens with the preview already on. - **Markdown compile**: Tools > Compile Markdown (F7) saves the active `.md` file, writes a clean standalone `.html` file next to it, and opens it in your browser. - **LaTeX compile**: Tools > Compile LaTeX (F6) runs pdflatex / xelatex / tectonic (whichever is installed) on the active .tex file and opens the PDF. Compile errors open in a tab. diff --git a/Theme.cs b/Theme.cs new file mode 100644 index 0000000..42c729a --- /dev/null +++ b/Theme.cs @@ -0,0 +1,132 @@ +namespace CodeViewer; + +/// Named UI surface colors for the app; a dark (default) and light instance. +sealed class Theme +{ + public bool IsDark; + + public Color EditorBack, EditorFore; + public Color LineNumberFore, LineNumberBack; + public Color CaretLineBack, CaretFore; + public Color SelectionBack; + public Color IndentGuideFore; + public Color SidebarBack, SidebarFore; + public Color TabStripBack, TabActiveBack, TabInactiveBack, TabActiveFore, TabInactiveFore; + public Color MenuBack, MenuFore, MenuHoverBack, MenuBorder; + public Color StatusBack, StatusFore; + public Color PanelBack, PanelFore, HeaderBack, HeaderFore, ListBack, ListFore; + + public static readonly Theme Dark = new() + { + IsDark = true, + EditorBack = Rgb(0x1E1E1E), EditorFore = Rgb(0xD4D4D4), + LineNumberFore = Rgb(0x858585), LineNumberBack = Rgb(0x1E1E1E), + CaretLineBack = Rgb(0x282828), CaretFore = Rgb(0xAEAFAD), + SelectionBack = Rgb(0x264F78), + IndentGuideFore = Rgb(0x404040), + SidebarBack = Rgb(0x252526), SidebarFore = Rgb(0xCCCCCC), + TabStripBack = Rgb(0x252526), TabActiveBack = Rgb(0x1E1E1E), TabInactiveBack = Rgb(0x2D2D2D), + TabActiveFore = Rgb(0xFFFFFF), TabInactiveFore = Rgb(0x969696), + MenuBack = Rgb(0x2D2D30), MenuFore = Rgb(0xCCCCCC), MenuHoverBack = Rgb(0x3E3E40), MenuBorder = Rgb(0x454545), + StatusBack = Rgb(0x007ACC), StatusFore = Color.White, + PanelBack = Rgb(0x1E1E1E), PanelFore = Rgb(0xD4D4D4), HeaderBack = Rgb(0x2D2D30), HeaderFore = Rgb(0xCCCCCC), + ListBack = Rgb(0x252526), ListFore = Rgb(0xCCCCCC), + }; + + public static readonly Theme Light = new() + { + IsDark = false, + EditorBack = Color.White, EditorFore = Color.Black, + LineNumberFore = Rgb(0x8C8C8C), LineNumberBack = Rgb(0xF8F8F8), + CaretLineBack = Rgb(0xF5F7FA), CaretFore = Color.Black, + SelectionBack = Rgb(0xADD6FF), + IndentGuideFore = Rgb(0xD3D3D3), + SidebarBack = Rgb(0xFAFAFA), SidebarFore = Color.Black, + TabStripBack = Rgb(0xF3F3F3), TabActiveBack = Color.White, TabInactiveBack = Rgb(0xECECEC), + TabActiveFore = Rgb(0x333333), TabInactiveFore = Rgb(0x6E6E6E), + MenuBack = Rgb(0xF0F0F0), MenuFore = Color.Black, MenuHoverBack = Rgb(0xD8D8D8), MenuBorder = Rgb(0xC0C0C0), + StatusBack = Rgb(0x007ACC), StatusFore = Color.White, + PanelBack = Color.White, PanelFore = Color.Black, HeaderBack = Rgb(0xF0F0F0), HeaderFore = Color.Black, + ListBack = Color.White, ListFore = Color.Black, + }; + + private static Color Rgb(int rgb) => Color.FromArgb((rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF); + + // ---------- persistence ---------- + + private static string SettingsPath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "codeviewer", "settings.txt"); + + /// Loads the persisted theme choice, defaulting to Dark on any error or absence. + public static Theme Load() + { + try + { + var text = File.ReadAllText(SettingsPath); + return text.Contains("theme=light", StringComparison.OrdinalIgnoreCase) ? Light : Dark; + } + catch { return Dark; } + } + + /// Best-effort persistence; swallows IO errors. + public static void Save(Theme theme) + { + try + { + var path = SettingsPath; + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, theme.IsDark ? "theme=dark" : "theme=light"); + } + catch { } + } +} + +sealed class ThemeColorTable : ProfessionalColorTable +{ + private readonly Theme _t; + public ThemeColorTable(Theme theme) => _t = theme; + + public override Color MenuStripGradientBegin => _t.MenuBack; + public override Color MenuStripGradientEnd => _t.MenuBack; + public override Color ToolStripDropDownBackground => _t.MenuBack; + public override Color ImageMarginGradientBegin => _t.MenuBack; + public override Color ImageMarginGradientMiddle => _t.MenuBack; + public override Color ImageMarginGradientEnd => _t.MenuBack; + public override Color MenuItemSelected => _t.MenuHoverBack; + public override Color MenuItemSelectedGradientBegin => _t.MenuHoverBack; + public override Color MenuItemSelectedGradientEnd => _t.MenuHoverBack; + public override Color MenuItemPressedGradientBegin => _t.MenuHoverBack; + public override Color MenuItemPressedGradientEnd => _t.MenuHoverBack; + public override Color MenuItemBorder => _t.MenuBorder; + public override Color MenuBorder => _t.MenuBorder; + public override Color SeparatorDark => _t.MenuBorder; + public override Color SeparatorLight => _t.MenuBorder; + public override Color StatusStripGradientBegin => _t.StatusBack; + public override Color StatusStripGradientEnd => _t.StatusBack; + public override Color ToolStripBorder => _t.MenuBorder; + public override Color CheckBackground => _t.MenuHoverBack; + public override Color CheckSelectedBackground => _t.MenuHoverBack; +} + +sealed class ThemeRenderer : ToolStripProfessionalRenderer +{ + private readonly Theme _theme; + + public ThemeRenderer(ThemeColorTable table, Theme theme) : base(table) + { + _theme = theme; + RoundedEdges = false; + } + + protected override void OnRenderItemText(ToolStripItemTextRenderEventArgs e) + { + e.TextColor = e.Item.Owner is StatusStrip ? _theme.StatusFore : _theme.MenuFore; + base.OnRenderItemText(e); + } + + protected override void OnRenderArrow(ToolStripArrowRenderEventArgs e) + { + e.ArrowColor = _theme.MenuFore; + base.OnRenderArrow(e); + } +} diff --git a/codeviewer.csproj b/codeviewer.csproj index 5e1408e..05f3399 100644 --- a/codeviewer.csproj +++ b/codeviewer.csproj @@ -8,7 +8,7 @@ enable codeviewer CodeViewer - 1.0.0 + 1.1.0 false false