diff --git a/CHANGELOG.md b/CHANGELOG.md index c5a9214..dfc5daa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # CHANGELOG +## v0.15.18 + +- 完善窗口日志颜色标记 +- 修复预览图背景颜色为透明 +- 修复面板高度首次还原错误 +- 增加托盘图标 +- 增加可选预览背景画面和填充模式 +- 增强支持的纹理格式(例如 webp) + ## v0.15.17 - 修改图标配色 diff --git a/NLog.Windows.Wpf/NLog.Windows.Wpf.csproj b/NLog.Windows.Wpf/NLog.Windows.Wpf.csproj index 69401ba..3359c0f 100644 --- a/NLog.Windows.Wpf/NLog.Windows.Wpf.csproj +++ b/NLog.Windows.Wpf/NLog.Windows.Wpf.csproj @@ -1,4 +1,4 @@ - + enable @@ -7,7 +7,7 @@ net8.0-windows $(SolutionDir)out false - 0.15.4 + 0.15.18 true diff --git a/NLog.Windows.Wpf/RichTextBoxRowColoringRule.cs b/NLog.Windows.Wpf/RichTextBoxRowColoringRule.cs index 9999a41..5bbe8c5 100644 --- a/NLog.Windows.Wpf/RichTextBoxRowColoringRule.cs +++ b/NLog.Windows.Wpf/RichTextBoxRowColoringRule.cs @@ -1,92 +1,53 @@ -// -// Copyright (c) 2004-2011 Jaroslaw Kowalski -// -// All rights reserved. -// -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions -// are met: -// -// * Redistributions of source code must retain the above copyright notice, -// this list of conditions and the following disclaimer. -// -// * Redistributions in binary form must reproduce the above copyright notice, -// this list of conditions and the following disclaimer in the documentation -// and/or other materials provided with the distribution. -// -// * Neither the name of Jaroslaw Kowalski nor the names of its -// contributors may be used to endorse or promote products derived from this -// software without specific prior written permission. -// -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE -// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF -// THE POSSIBILITY OF SUCH DAMAGE. -// - +using NLog; using NLog.Conditions; using NLog.Config; -using NLog; -using System.ComponentModel; +using NLog.Layouts; using System.Windows; + namespace NLog.Windows.Wpf { [NLogConfigurationItem] public class RichTextBoxRowColoringRule { - static RichTextBoxRowColoringRule() - { - Default = new RichTextBoxRowColoringRule(); - } - - public RichTextBoxRowColoringRule() - : this(null, "Empty", "Empty", FontStyles.Normal, FontWeights.Normal) - { - } - - public RichTextBoxRowColoringRule(string condition, string fontColor, string backColor, FontStyle fontStyle, FontWeight fontWeight) - { - Condition = condition; - FontColor = fontColor; - BackgroundColor = backColor; - Style = fontStyle; - Weight = fontWeight; - } - - public RichTextBoxRowColoringRule(string condition, string fontColor, string backColor) - { - Condition = condition; - FontColor = fontColor; - BackgroundColor = backColor; - Style = FontStyles.Normal; - Weight = FontWeights.Normal; - } - public static RichTextBoxRowColoringRule Default { get; private set; } [RequiredParameter] public ConditionExpression Condition { get; set; } - [DefaultValue("Empty")] - public string FontColor { get; set; } + public Layout FontColor { get; set; } + public Layout BackgroundColor { get; set; } - [DefaultValue("Empty")] - public string BackgroundColor { get; set; } + public FontStyle FontStyle { get; set; } + public FontWeight FontWeight { get; set; } - public FontStyle Style { get; set; } + static RichTextBoxRowColoringRule() + { + RichTextBoxRowColoringRule.Default = new RichTextBoxRowColoringRule(); + } - public FontWeight Weight { get; set; } + public RichTextBoxRowColoringRule() : this(null, "Empty", "Empty", FontStyles.Normal, FontWeights.Normal) { } + + public RichTextBoxRowColoringRule(string condition, string fontColor, string backColor) + { + this.Condition = (ConditionExpression)condition; + this.FontColor = Layout.FromString(fontColor); + this.BackgroundColor = Layout.FromString(backColor); + this.FontStyle = FontStyles.Normal; + this.FontWeight = FontWeights.Normal; + } + + public RichTextBoxRowColoringRule(string condition, string fontColor, string backColor, FontStyle fontStyle, FontWeight fontWeight) + { + this.Condition = (ConditionExpression)condition; + this.FontColor = Layout.FromString(fontColor); + this.BackgroundColor = Layout.FromString(backColor); + this.FontStyle = fontStyle; + this.FontWeight = fontWeight; + } public bool CheckCondition(LogEventInfo logEvent) { - return true.Equals(Condition.Evaluate(logEvent)); + return true.Equals(this.Condition.Evaluate(logEvent)); } } } diff --git a/NLog.Windows.Wpf/RichTextBoxTarget.cs b/NLog.Windows.Wpf/RichTextBoxTarget.cs index 04d905d..81e18c3 100644 --- a/NLog.Windows.Wpf/RichTextBoxTarget.cs +++ b/NLog.Windows.Wpf/RichTextBoxTarget.cs @@ -1,34 +1,27 @@ -using NLog.Config; -using NLog.Layouts; +using NLog; +using NLog.Common; +using NLog.Config; using NLog.Targets; -using NLog; using System; using System.Collections.Generic; using System.Collections.ObjectModel; -using System.ComponentModel; -using System.Diagnostics; using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Text.RegularExpressions; +using System.Windows; using System.Windows.Controls; using System.Windows.Documents; using System.Windows.Media; -using System.Windows; namespace NLog.Windows.Wpf { - // TODO: 完善日志实现 [Target("RichTextBox")] public sealed class RichTextBoxTarget : TargetWithLayout { - private int lineCount; - private int _width = 500; - private int _height = 500; - private static readonly TypeConverter colorConverter = new ColorConverter(); + public static ReadOnlyCollection DefaultRowColoringRules { get; } = CreateDefaultColoringRules(); - static RichTextBoxTarget() + private static ReadOnlyCollection CreateDefaultColoringRules() { - var rules = new List() + return new List() { new RichTextBoxRowColoringRule("level == LogLevel.Fatal", "White", "Red", FontStyles.Normal, FontWeights.Bold), new RichTextBoxRowColoringRule("level == LogLevel.Error", "Red", "Empty", FontStyles.Italic, FontWeights.Bold), @@ -36,221 +29,253 @@ namespace NLog.Windows.Wpf new RichTextBoxRowColoringRule("level == LogLevel.Info", "Black", "Empty"), new RichTextBoxRowColoringRule("level == LogLevel.Debug", "Gray", "Empty"), new RichTextBoxRowColoringRule("level == LogLevel.Trace", "DarkGray", "Empty", FontStyles.Italic, FontWeights.Normal), - }; - - DefaultRowColoringRules = rules.AsReadOnly(); + }.AsReadOnly(); } - public RichTextBoxTarget() - { - WordColoringRules = new List(); - RowColoringRules = new List(); - ToolWindow = true; - } - - private delegate void DelSendTheMessageToRichTextBox(string logMessage, RichTextBoxRowColoringRule rule); - - private delegate void FormCloseDelegate(); - - public static ReadOnlyCollection DefaultRowColoringRules { get; private set; } + public RichTextBoxTarget() { } public string ControlName { get; set; } - public string FormName { get; set; } + public string WindowName { get; set; } - [DefaultValue(false)] public bool UseDefaultRowColoringRules { get; set; } - [ArrayParameter(typeof(RichTextBoxRowColoringRule), "row-coloring")] - public IList RowColoringRules { get; private set; } - - [ArrayParameter(typeof(RichTextBoxWordColoringRule), "word-coloring")] - public IList WordColoringRules { get; private set; } - - [DefaultValue(true)] - public bool ToolWindow { get; set; } - - public bool ShowMinimized { get; set; } - - public int Width - { - get { return _width; } - set { _width = value; } - } - - public int Height - { - get { return _height; } - set { _height = value; } - } - public bool AutoScroll { get; set; } public int MaxLines { get; set; } - internal Window TargetForm { get; set; } + [ArrayParameter(typeof(RichTextBoxRowColoringRule), "row-coloring")] + public IList RowColoringRules { get; } = new List(); - internal RichTextBox TargetRichTextBox { get; set; } + [ArrayParameter(typeof(RichTextBoxWordColoringRule), "word-coloring")] + public IList WordColoringRules { get; } = new List(); - internal bool CreatedForm { get; set; } + [NLogConfigurationIgnoreProperty] + public Window TargetWindow { get; set; } + + [NLogConfigurationIgnoreProperty] + public RichTextBox TargetRichTextBox { get; set; } protected override void InitializeTarget() { - TargetRichTextBox = Application.Current.MainWindow.FindName(ControlName) as RichTextBox; + base.InitializeTarget(); + if (TargetRichTextBox != null) + return; - if (TargetRichTextBox != null) return; - //this.TargetForm = FormHelper.CreateForm(this.FormName, this.Width, this.Height, false, this.ShowMinimized, this.ToolWindow); - //this.CreatedForm = true; - - var openFormByName = Application.Current.Windows.Cast().FirstOrDefault(x => x.GetType().Name == FormName); - if (openFormByName != null) + if (WindowName == null) { - TargetForm = openFormByName; - if (string.IsNullOrEmpty(ControlName)) - { - // throw new NLogConfigurationException("Rich text box control name must be specified for " + GetType().Name + "."); - Trace.WriteLine("Rich text box control name must be specified for " + GetType().Name + "."); - } - - CreatedForm = false; - TargetRichTextBox = TargetForm.FindName(ControlName) as RichTextBox; - - if (TargetRichTextBox == null) - { - // throw new NLogConfigurationException("Rich text box control '" + ControlName + "' cannot be found on form '" + FormName + "'."); - Trace.WriteLine("Rich text box control '" + ControlName + "' cannot be found on form '" + FormName + "'."); - } + HandleError("WindowName should be specified for {0}.{1}", GetType().Name, Name); + return; } - if (TargetRichTextBox == null) + if (string.IsNullOrEmpty(ControlName)) { - TargetForm = new Window - { - Name = FormName, - Width = Width, - Height = Height, - WindowStyle = ToolWindow ? WindowStyle.ToolWindow : WindowStyle.None, - WindowState = ShowMinimized ? WindowState.Minimized : WindowState.Normal, - Title = "NLog Messages" - }; - TargetForm.Show(); - - TargetRichTextBox = new RichTextBox { Name = ControlName }; - var style = new Style(typeof(Paragraph)); - TargetRichTextBox.VerticalScrollBarVisibility = ScrollBarVisibility.Auto; - style.Setters.Add(new Setter(Block.MarginProperty, new Thickness(0, 0, 0, 0))); - TargetRichTextBox.Resources.Add(typeof(Paragraph), style); - TargetForm.Content = TargetRichTextBox; - - CreatedForm = true; + HandleError("Rich text box control name must be specified for {0}.{1}", GetType().Name, Name); + return; } + + var targetWindow = Application.Current.Windows.OfType().FirstOrDefault(w => w.Name == WindowName); + if (targetWindow == null) + { + InternalLogger.Info("{0}: WindowName '{1}' not found", this, WindowName); + return; + } + + var targetControl = targetWindow.FindName(ControlName) as RichTextBox; + if (targetControl == null) + { + InternalLogger.Info("{0}: WIndowName '{1}' does not contain ControlName '{2}'", this, WindowName, ControlName); + return; + } + + AttachToControl(targetWindow, targetControl); + } + + private static void HandleError(string message, params object[] args) + { + if (LogManager.ThrowExceptions) + { + throw new NLogConfigurationException(string.Format(message, args)); + } + InternalLogger.Error(message, args); + } + + private void AttachToControl(Window window, RichTextBox textboxControl) + { + InternalLogger.Info("{0}: Attaching target to textbox {1}.{2}", this, window.Name, textboxControl.Name); + DetachFromControl(); + TargetWindow = window; + TargetRichTextBox = textboxControl; + } + + private void DetachFromControl() + { + TargetWindow = null; + TargetRichTextBox = null; } protected override void CloseTarget() { - if (CreatedForm) - { - try - { - TargetForm.Dispatcher.Invoke(() => - { - TargetForm.Close(); - TargetForm = null; - }); - } - catch - { - } - - - - } + DetachFromControl(); } protected override void Write(LogEventInfo logEvent) { - RichTextBoxRowColoringRule matchingRule = RowColoringRules.FirstOrDefault(rr => rr.CheckCondition(logEvent)); - - if (UseDefaultRowColoringRules && matchingRule == null) + RichTextBox textbox = TargetRichTextBox; + if (textbox == null || textbox.Dispatcher.HasShutdownStarted || textbox.Dispatcher.HasShutdownFinished) { - foreach (var rr in DefaultRowColoringRules.Where(rr => rr.CheckCondition(logEvent))) - { - matchingRule = rr; - break; - } + //no last logged textbox + InternalLogger.Trace("{0}: Attached Textbox is {1}, skipping logging", this, textbox == null ? "null" : "disposed"); + return; } - if (matchingRule == null) - { - matchingRule = RichTextBoxRowColoringRule.Default; - } - - var logMessage = Layout.Render(logEvent); - - if (Application.Current == null) return; + string logMessage = RenderLogEvent(Layout, logEvent); + RichTextBoxRowColoringRule matchingRule = FindMatchingRule(logEvent); + _ = DoSendMessageToTextbox(logMessage, matchingRule, logEvent); + } + private bool DoSendMessageToTextbox(string logMessage, RichTextBoxRowColoringRule rule, LogEventInfo logEvent) + { + RichTextBox textbox = TargetRichTextBox; try { - if (Application.Current.Dispatcher.CheckAccess() == false) + if (textbox != null && !textbox.Dispatcher.HasShutdownStarted && !textbox.Dispatcher.HasShutdownFinished) { - Application.Current.Dispatcher.Invoke(() => SendTheMessageToRichTextBox(logMessage, matchingRule)); - } - else - { - SendTheMessageToRichTextBox(logMessage, matchingRule); + if (!textbox.Dispatcher.CheckAccess()) + { + textbox.Dispatcher.BeginInvoke(() => SendTheMessageToRichTextBox(textbox, logMessage, rule, logEvent)); + } + else + { + SendTheMessageToRichTextBox(textbox, logMessage, rule, logEvent); + } + return true; } } catch (Exception ex) { - Debug.WriteLine(ex); - } + InternalLogger.Warn(ex, "{0}: Failed to append RichTextBox", this); - } - - - private static Color GetColorFromString(string color, Brush defaultColor) - { - - if (color == "Empty") - { - return defaultColor is SolidColorBrush solidBrush ? solidBrush.Color : Colors.White; - } - - return (Color)colorConverter.ConvertFromString(color); - } - - - private void SendTheMessageToRichTextBox(string logMessage, RichTextBoxRowColoringRule rule) - { - RichTextBox rtbx = TargetRichTextBox; - - var tr = new TextRange(rtbx.Document.ContentEnd, rtbx.Document.ContentEnd); - tr.Text = logMessage + "\n"; - tr.ApplyPropertyValue(TextElement.ForegroundProperty, - new SolidColorBrush(GetColorFromString(rule.FontColor, (Brush)tr.GetPropertyValue(TextElement.ForegroundProperty))) - ); - tr.ApplyPropertyValue(TextElement.BackgroundProperty, - new SolidColorBrush(GetColorFromString(rule.BackgroundColor, (Brush)tr.GetPropertyValue(TextElement.BackgroundProperty))) - ); - tr.ApplyPropertyValue(TextElement.FontStyleProperty, rule.Style); - tr.ApplyPropertyValue(TextElement.FontWeightProperty, rule.Weight); - - - if (MaxLines > 0) - { - lineCount++; - if (lineCount > MaxLines) + if (LogManager.ThrowExceptions) { - tr = new TextRange(rtbx.Document.ContentStart, rtbx.Document.ContentEnd); - tr.Text.Remove(0, tr.Text.IndexOf('\n')); - lineCount--; + throw; + } + } + return false; + } + + private RichTextBoxRowColoringRule FindMatchingRule(LogEventInfo logEvent) + { + //custom rules first + if (RowColoringRules.Count > 0) + { + foreach (RichTextBoxRowColoringRule coloringRule in RowColoringRules) + { + if (coloringRule.CheckCondition(logEvent)) + { + return coloringRule; + } } } + if (UseDefaultRowColoringRules && DefaultRowColoringRules != null) + { + foreach (RichTextBoxRowColoringRule coloringRule in DefaultRowColoringRules) + { + if (coloringRule.CheckCondition(logEvent)) + { + return coloringRule; + } + } + } + + return RichTextBoxRowColoringRule.Default; + } + + private void SendTheMessageToRichTextBox(RichTextBox textBox, string logMessage, RichTextBoxRowColoringRule rule, LogEventInfo logEvent) + { + if (textBox == null) return; + + var document = textBox.Document; + + // 插入文本(带换行) + var tr = new TextRange(document.ContentEnd, document.ContentEnd) + { + Text = logMessage + Environment.NewLine + }; + + // 设置行级样式 + var fgColor = rule.FontColor?.Render(logEvent); + var bgColor = rule.BackgroundColor?.Render(logEvent); + + tr.ApplyPropertyValue(TextElement.ForegroundProperty, + string.IsNullOrEmpty(fgColor) || fgColor == "Empty" + ? textBox.Foreground + : new SolidColorBrush((Color)ColorConverter.ConvertFromString(fgColor))); + + tr.ApplyPropertyValue(TextElement.BackgroundProperty, + string.IsNullOrEmpty(bgColor) || bgColor == "Empty" + ? Brushes.Transparent + : new SolidColorBrush((Color)ColorConverter.ConvertFromString(bgColor))); + + tr.ApplyPropertyValue(TextElement.FontStyleProperty, rule.FontStyle); + tr.ApplyPropertyValue(TextElement.FontWeightProperty, rule.FontWeight); + + // Word coloring(在刚插入的范围内做匹配) + if (WordColoringRules.Count > 0) + { + foreach (var wordRule in WordColoringRules) + { + var pattern = wordRule.Regex?.Render(logEvent) ?? string.Empty; + var text = wordRule.Text?.Render(logEvent) ?? string.Empty; + var wholeWords = wordRule.WholeWords.RenderValue(logEvent); + var ignoreCase = wordRule.IgnoreCase.RenderValue(logEvent); + + var regex = wordRule.ResolveRegEx(pattern, text, wholeWords, ignoreCase); + var matches = regex.Matches(tr.Text); + + foreach (Match match in matches) + { + // 匹配到的部分范围 + var start = tr.Start.GetPositionAtOffset(match.Index, LogicalDirection.Forward); + var endPos = tr.Start.GetPositionAtOffset(match.Index + match.Length, LogicalDirection.Backward); + if (start == null || endPos == null) continue; + + var wordRange = new TextRange(start, endPos); + + var wordFg = wordRule.FontColor?.Render(logEvent); + var wordBg = wordRule.BackgroundColor?.Render(logEvent); + + wordRange.ApplyPropertyValue(TextElement.ForegroundProperty, + string.IsNullOrEmpty(wordFg) || wordFg == "Empty" + ? tr.GetPropertyValue(TextElement.ForegroundProperty) + : new SolidColorBrush((Color)ColorConverter.ConvertFromString(wordFg))); + + wordRange.ApplyPropertyValue(TextElement.BackgroundProperty, + string.IsNullOrEmpty(wordBg) || wordBg == "Empty" + ? tr.GetPropertyValue(TextElement.BackgroundProperty) + : new SolidColorBrush((Color)ColorConverter.ConvertFromString(wordBg))); + + wordRange.ApplyPropertyValue(TextElement.FontStyleProperty, wordRule.FontStyle); + wordRange.ApplyPropertyValue(TextElement.FontWeightProperty, wordRule.FontWeight); + } + } + } + + // 限制最大行数 + if (MaxLines > 0) + { + while (document.Blocks.Count > MaxLines) + { + document.Blocks.Remove(document.Blocks.FirstBlock); + } + } + + // 自动滚动到最后 if (AutoScroll) { - rtbx.ScrollToEnd(); + textBox.ScrollToEnd(); } } } -} +} \ No newline at end of file diff --git a/NLog.Windows.Wpf/RichTextBoxWordColoringRule.cs b/NLog.Windows.Wpf/RichTextBoxWordColoringRule.cs index b2e79c5..e577ad9 100644 --- a/NLog.Windows.Wpf/RichTextBoxWordColoringRule.cs +++ b/NLog.Windows.Wpf/RichTextBoxWordColoringRule.cs @@ -1,119 +1,59 @@ -// -// Copyright (c) 2004-2011 Jaroslaw Kowalski -// -// All rights reserved. -// -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions -// are met: -// -// * Redistributions of source code must retain the above copyright notice, -// this list of conditions and the following disclaimer. -// -// * Redistributions in binary form must reproduce the above copyright notice, -// this list of conditions and the following disclaimer in the documentation -// and/or other materials provided with the distribution. -// -// * Neither the name of Jaroslaw Kowalski nor the names of its -// contributors may be used to endorse or promote products derived from this -// software without specific prior written permission. -// -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE -// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF -// THE POSSIBILITY OF SUCH DAMAGE. -// - +using NLog.Config; +using NLog.Layouts; using System.ComponentModel; using System.Text.RegularExpressions; using System.Windows; -using NLog.Config; namespace NLog.Windows.Wpf { - [NLogConfigurationItem] + [NLogConfigurationItem] public class RichTextBoxWordColoringRule { - private Regex compiledRegex; + public Layout Regex { get; set; } + public Layout Text { get; set; } + public Layout WholeWords { get; set; } + public Layout IgnoreCase { get; set; } - public RichTextBoxWordColoringRule() + public Layout FontColor { get; set; } + public Layout BackgroundColor { get; set; } + + public FontStyle FontStyle { get; set; } + public FontWeight FontWeight { get; set; } + + internal Regex ResolveRegEx(string pattern, string text, bool wholeWords, bool ignoreCase) { - FontColor = "Empty"; - BackgroundColor = "Empty"; + if (string.IsNullOrEmpty(pattern) && text != null) + { + pattern = System.Text.RegularExpressions.Regex.Escape(text); + if (wholeWords) + pattern = "\b" + pattern + "\b"; + } + + RegexOptions options = RegexOptions.None; + if (ignoreCase) + options |= RegexOptions.IgnoreCase; + + return new Regex(pattern, options); // RegEx-Cache } + public RichTextBoxWordColoringRule() : this(null, "Empty", "Empty", FontStyles.Normal, FontWeights.Normal) { } + public RichTextBoxWordColoringRule(string text, string fontColor, string backgroundColor) { - Text = text; - FontColor = fontColor; - BackgroundColor = backgroundColor; - Style = FontStyles.Normal; - Weight = FontWeights.Normal; + this.Text = text; + this.FontColor = Layout.FromString(fontColor); + this.BackgroundColor = Layout.FromString(backgroundColor); + this.FontStyle = FontStyles.Normal; + this.FontWeight = FontWeights.Normal; } public RichTextBoxWordColoringRule(string text, string textColor, string backgroundColor, FontStyle fontStyle, FontWeight fontWeight) { - Text = text; - FontColor = textColor; - BackgroundColor = backgroundColor; - Style = fontStyle; - Weight = fontWeight; + this.Text = text; + this.FontColor = Layout.FromString(textColor); + this.BackgroundColor = Layout.FromString(backgroundColor); + this.FontStyle = fontStyle; + this.FontWeight = fontWeight; } - - public string Regex { get; set; } - - public string Text { get; set; } - - [DefaultValue(false)] - public bool WholeWords { get; set; } - - [DefaultValue(false)] - public bool IgnoreCase { get; set; } - - public FontStyle Style { get; set; } - - public FontWeight Weight { get; set; } - - public Regex CompiledRegex - { - get - { - if (compiledRegex == null) - { - string regexpression = Regex; - if (regexpression == null && Text != null) - { - regexpression = System.Text.RegularExpressions.Regex.Escape(Text); - if (WholeWords) - { - regexpression = "\b" + regexpression + "\b"; - } - } - - RegexOptions regexOptions = RegexOptions.Compiled; - if (IgnoreCase) - { - regexOptions |= RegexOptions.IgnoreCase; - } - - compiledRegex = new Regex(regexpression, regexOptions); - } - - return compiledRegex; - } - } - - [DefaultValue("Empty")] - public string FontColor { get; set; } - - [DefaultValue("Empty")] - public string BackgroundColor { get; set; } } } diff --git a/README.en.md b/README.en.md index 1cec28a..2de873b 100644 --- a/README.en.md +++ b/README.en.md @@ -29,6 +29,7 @@ A simple and user-friendly Spine file viewer and exporter with multi-language su * FFmpeg custom export support. * Program parameter saving. * File name extension association. +* Supports texture image formats other than PNG. * ... ### Supported Spine Versions diff --git a/README.md b/README.md index 639faab..089e52c 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ - 支持 FFmpeg 自定义导出 - 支持程序参数保存 - 支持文件后缀关联 +- 支持非 png 格式的纹理图片格式 - ...... ### Spine 版本支持 diff --git a/Spine/Spine.csproj b/Spine/Spine.csproj index b3637bc..06d2bd7 100644 --- a/Spine/Spine.csproj +++ b/Spine/Spine.csproj @@ -7,7 +7,7 @@ net8.0-windows $(SolutionDir)out false - 0.15.16 + 0.15.18 diff --git a/Spine/SpineWrappers/TextureLoader.cs b/Spine/SpineWrappers/TextureLoader.cs index 8d86128..0e25705 100644 --- a/Spine/SpineWrappers/TextureLoader.cs +++ b/Spine/SpineWrappers/TextureLoader.cs @@ -1,4 +1,6 @@ -using System; +using SFML.Graphics; +using SkiaSharp; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -40,37 +42,26 @@ namespace Spine.SpineWrappers /// public bool ForceMipmap { get; set; } - private SFML.Graphics.Texture ReadTexture(string path) + private Texture ReadTexture(string path) { - if (ForcePremul) - { - using var image = new SFML.Graphics.Image(path); - var width = image.Size.X; - var height = image.Size.Y; - var pixels = image.Pixels; - var size = width * height * 4; - for (int i = 0; i < size; i += 4) - { - byte a = pixels[i + 3]; - if (a == 0) - { - pixels[i + 0] = 0; - pixels[i + 1] = 0; - pixels[i + 2] = 0; - } - else if (a != 255) - { - float f = a / 255f; - pixels[i + 0] = (byte)(pixels[i + 0] * f); - pixels[i + 1] = (byte)(pixels[i + 1] * f); - pixels[i + 2] = (byte)(pixels[i + 2] * f); - } - } - var tex = new SFML.Graphics.Texture(width, height); - tex.Update(pixels); - return tex; - } - return new(path); + using var codec = SKCodec.Create(path, out var result); + if (codec is null || result != SKCodecResult.Success) + throw new InvalidOperationException($"Failed to create codec '{path}', {result}"); + + var width = codec.Info.Width; + var height = codec.Info.Height; + + // 判断是否需要强制预乘 + var alphaType = ForcePremul ? SKAlphaType.Premul : SKAlphaType.Unpremul; + var info = new SKImageInfo(width, height, SKColorType.Rgba8888, alphaType); + + result = codec.GetPixels(info, out var pixels); + if (result != SKCodecResult.Success) + throw new InvalidOperationException($"Failed to decode image '{path}', {result}"); + + Texture tex = new((uint)width, (uint)height); + tex.Update(pixels); + return tex; } public virtual void Load(SpineRuntime21.AtlasPage page, string path) @@ -394,7 +385,7 @@ namespace Spine.SpineWrappers public virtual void Unload(object texture) { - ((SFML.Graphics.Texture)texture).Dispose(); + ((Texture)texture).Dispose(); } } } diff --git a/SpineViewer/Models/LastStateModel.cs b/SpineViewer/Models/LastStateModel.cs index c61455f..639132a 100644 --- a/SpineViewer/Models/LastStateModel.cs +++ b/SpineViewer/Models/LastStateModel.cs @@ -33,6 +33,8 @@ namespace SpineViewer.Models public float Speed { get; set; } = 1f; public bool ShowAxis { get; set; } = true; public Color BackgroundColor { get; set; } = Color.FromRgb(105, 105, 105); + public string BackgroundImagePath { get; set; } + public Stretch BackgroundImageMode { get; set; } = Stretch.Uniform; #endregion diff --git a/SpineViewer/Models/WorkspaceModel.cs b/SpineViewer/Models/WorkspaceModel.cs index 88d963e..298514a 100644 --- a/SpineViewer/Models/WorkspaceModel.cs +++ b/SpineViewer/Models/WorkspaceModel.cs @@ -43,10 +43,9 @@ namespace SpineViewer.Models public Color BackgroundColor { get; set; } - // TODO: 背景图片 - //public string? BackgroundImagePath { get; set; } + public string BackgroundImagePath { get; set; } - //public ? BackgroundImageDisplayMode { get; set; } + public Stretch BackgroundImageMode { get; set; } = Stretch.Uniform; } public class SpineObjectWorkspaceConfigModel diff --git a/SpineViewer/Natives/Shell32.cs b/SpineViewer/Natives/Shell32.cs index a53286c..1a08aa6 100644 --- a/SpineViewer/Natives/Shell32.cs +++ b/SpineViewer/Natives/Shell32.cs @@ -14,10 +14,15 @@ namespace SpineViewer.Natives /// public static class Shell32 { - public const uint SHCNE_ASSOCCHANGED = 0x08000000; - public const uint SHCNF_IDLIST = 0x0000; + private const uint SHCNE_ASSOCCHANGED = 0x08000000; + private const uint SHCNF_IDLIST = 0x0000; [DllImport("shell32.dll")] - public static extern void SHChangeNotify(uint wEventId, uint uFlags, IntPtr dwItem1, IntPtr dwItem2); + private static extern void SHChangeNotify(uint wEventId, uint uFlags, IntPtr dwItem1, IntPtr dwItem2); + + public static void NotifyAssociationChanged() + { + SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, IntPtr.Zero, IntPtr.Zero); + } } } diff --git a/SpineViewer/Resources/Strings/en.xaml b/SpineViewer/Resources/Strings/en.xaml index f77be99..7cdda02 100644 --- a/SpineViewer/Resources/Strings/en.xaml +++ b/SpineViewer/Resources/Strings/en.xaml @@ -121,7 +121,8 @@ Render Selected Only Show Axis Background Color - Background Image + Background Image Path + Background Image Mode Stop diff --git a/SpineViewer/Resources/Strings/ja.xaml b/SpineViewer/Resources/Strings/ja.xaml index 6a84772..5f4ef84 100644 --- a/SpineViewer/Resources/Strings/ja.xaml +++ b/SpineViewer/Resources/Strings/ja.xaml @@ -121,7 +121,8 @@ 選択のみレンダリング 座標軸を表示 背景色 - 背景画像 + 背景画像のパス + 背景画像のモード 停止 diff --git a/SpineViewer/Resources/Strings/zh.xaml b/SpineViewer/Resources/Strings/zh.xaml index f138b4c..793fd7e 100644 --- a/SpineViewer/Resources/Strings/zh.xaml +++ b/SpineViewer/Resources/Strings/zh.xaml @@ -121,7 +121,8 @@ 仅渲染选中 显示坐标轴 背景颜色 - 背景图片 + 背景图片路径 + 背景图片模式 停止 diff --git a/SpineViewer/Services/DialogService.cs b/SpineViewer/Services/DialogService.cs index 6e11135..7587af2 100644 --- a/SpineViewer/Services/DialogService.cs +++ b/SpineViewer/Services/DialogService.cs @@ -90,6 +90,22 @@ namespace SpineViewer.Services return false; } + public static bool ShowOpenSFMLImageDialog(out string? fileName, string initialDirectory = "") + { + var dialog = new OpenFileDialog() + { + InitialDirectory = initialDirectory, + Filter = "SFML Image|*.png;*.jpg;*.jpeg;*.bmp;*.tga|All|*.*" + }; + if (dialog.ShowDialog() is true) + { + fileName = dialog.FileName; + return true; + } + fileName = null; + return false; + } + public static bool ShowOpenJsonDialog(out string? fileName, string initialDirectory = "") { var dialog = new OpenFileDialog() diff --git a/SpineViewer/SpineViewer.csproj b/SpineViewer/SpineViewer.csproj index 6802e32..6e87e0f 100644 --- a/SpineViewer/SpineViewer.csproj +++ b/SpineViewer/SpineViewer.csproj @@ -7,7 +7,7 @@ net8.0-windows $(SolutionDir)out false - 0.15.17 + 0.15.18 WinExe true @@ -22,7 +22,9 @@ PreserveNewest - + + PreserveNewest + diff --git a/SpineViewer/ViewModels/MainWindow/ExplorerListViewModel.cs b/SpineViewer/ViewModels/MainWindow/ExplorerListViewModel.cs index 2eccb6c..1f836ba 100644 --- a/SpineViewer/ViewModels/MainWindow/ExplorerListViewModel.cs +++ b/SpineViewer/ViewModels/MainWindow/ExplorerListViewModel.cs @@ -163,6 +163,7 @@ namespace SpineViewer.ViewModels.MainWindow Size = new(bounds.Width, -bounds.Height), Format = SkiaSharp.SKEncodedImageFormat.Webp, Quality = PreviewQuality, + BackgroundColor = SFML.Graphics.Color.Transparent, }; exporter.Export(m.PreviewFilePath, sp); } @@ -199,6 +200,7 @@ namespace SpineViewer.ViewModels.MainWindow { Format = SkiaSharp.SKEncodedImageFormat.Webp, Quality = PreviewQuality, + BackgroundColor = SFML.Graphics.Color.Transparent, }; for (int i = 0; i < totalCount; i++) { diff --git a/SpineViewer/ViewModels/MainWindow/MainWindowViewModel.cs b/SpineViewer/ViewModels/MainWindow/MainWindowViewModel.cs index 31bc195..e844d31 100644 --- a/SpineViewer/ViewModels/MainWindow/MainWindowViewModel.cs +++ b/SpineViewer/ViewModels/MainWindow/MainWindowViewModel.cs @@ -5,6 +5,7 @@ using SFMLRenderer; using SpineViewer.Models; using SpineViewer.Services; using SpineViewer.Utils; +using System.Windows; using System.Windows.Shell; namespace SpineViewer.ViewModels.MainWindow @@ -72,6 +73,8 @@ namespace SpineViewer.ViewModels.MainWindow public SFMLRendererViewModel SFMLRendererViewModel => _sfmlRendererViewModel; private readonly SFMLRendererViewModel _sfmlRendererViewModel; + public RelayCommand Cmd_Exit => new(App.Current.Shutdown); + /// /// 打开工作区 /// diff --git a/SpineViewer/ViewModels/MainWindow/PreferenceViewModel.cs b/SpineViewer/ViewModels/MainWindow/PreferenceViewModel.cs index 950c304..a5e3adc 100644 --- a/SpineViewer/ViewModels/MainWindow/PreferenceViewModel.cs +++ b/SpineViewer/ViewModels/MainWindow/PreferenceViewModel.cs @@ -310,7 +310,7 @@ namespace SpineViewer.ViewModels.MainWindow Registry.CurrentUser.DeleteSubKeyTree($@"Software\Classes\{App.ProgId}", false); } - Shell32.SHChangeNotify(Shell32.SHCNE_ASSOCCHANGED, Shell32.SHCNF_IDLIST, IntPtr.Zero, IntPtr.Zero); + Shell32.NotifyAssociationChanged(); }); } } diff --git a/SpineViewer/ViewModels/MainWindow/SFMLRendererViewModel.cs b/SpineViewer/ViewModels/MainWindow/SFMLRendererViewModel.cs index 348c80e..ad7c4dd 100644 --- a/SpineViewer/ViewModels/MainWindow/SFMLRendererViewModel.cs +++ b/SpineViewer/ViewModels/MainWindow/SFMLRendererViewModel.cs @@ -10,8 +10,10 @@ using SpineViewer.Services; using SpineViewer.Utils; using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Collections.Specialized; using System.Diagnostics; +using System.IO; using System.Linq; using System.Text; using System.Threading.Tasks; @@ -23,6 +25,8 @@ namespace SpineViewer.ViewModels.MainWindow { public class SFMLRendererViewModel : ObservableObject { + public ImmutableArray StretchOptions { get; } = Enum.GetValues().ToImmutableArray(); + /// /// 日志器 /// @@ -69,6 +73,13 @@ namespace SpineViewer.ViewModels.MainWindow private float _forwardDelta = 0; private readonly object _forwardDeltaLock = new(); + /// + /// 背景图片 + /// + private SFML.Graphics.Sprite? _backgroundImageSprite; // XXX: 暂时未使用 Dispose 释放 + private SFML.Graphics.Texture? _backgroundImageTexture; // XXX: 暂时未使用 Dispose 释放 + private readonly object _bgLock = new(); + /// /// 临时变量, 记录拖放世界源点 /// @@ -169,6 +180,64 @@ namespace SpineViewer.ViewModels.MainWindow } private SFML.Graphics.Color _backgroundColor = new(105, 105, 105); + public string BackgroundImagePath + { + get => _backgroundImagePath; + set => SetProperty(_backgroundImagePath, value, v => + { + if (string.IsNullOrWhiteSpace(v)) + { + lock (_bgLock) + { + _backgroundImageSprite?.Dispose(); + _backgroundImageTexture?.Dispose(); + _backgroundImageTexture = null; + _backgroundImageSprite = null; + } + _backgroundImagePath = v; + } + else + { + if (!File.Exists(v)) + { + _logger.Warn("Omit non-existed background image path, {0}", v); + return; + } + SFML.Graphics.Texture tex = null; + SFML.Graphics.Sprite sprite = null; + try + { + tex = new(v); + sprite = new(tex) { Origin = new(tex.Size.X / 2f, tex.Size.Y / 2f) }; + lock (_bgLock) + { + _backgroundImageSprite?.Dispose(); + _backgroundImageTexture?.Dispose(); + _backgroundImageTexture = tex; + _backgroundImageSprite = sprite; + } + _backgroundImagePath = v; + _logger.Info("Load background image from {0}", v); + _logger.LogCurrentProcessMemoryUsage(); + } + catch (Exception ex) + { + sprite?.Dispose(); + tex?.Dispose(); + _logger.Error("Failed to load background image from path: {0}, {1}", v, ex.Message); + } + } + }); + } + private string _backgroundImagePath; + + public Stretch BackgroundImageMode + { + get => _backgroundImageMode; + set => SetProperty(ref _backgroundImageMode, value); + } + private Stretch _backgroundImageMode = Stretch.Uniform; + public bool RenderSelectedOnly { get => _renderSelectedOnly; @@ -189,6 +258,14 @@ namespace SpineViewer.ViewModels.MainWindow } private bool _isUpdating = true; + public RelayCommand Cmd_SelectBackgroundImage => _cmd_SelectBackgroundImage ??= new(() => + { + if (!DialogService.ShowOpenSFMLImageDialog(out var fileName)) + return; + BackgroundImagePath = fileName; + }); + private RelayCommand? _cmd_SelectBackgroundImage; + public RelayCommand Cmd_Stop => _cmd_Stop ??= new(() => { IsUpdating = false; @@ -386,6 +463,38 @@ namespace SpineViewer.ViewModels.MainWindow _renderer.Clear(_backgroundColor); + // 渲染背景 + lock (_bgLock) + { + if (_backgroundImageSprite is not null) + { + using var view = _renderer.GetView(); + var bg = _backgroundImageSprite; + var viewSize = view.Size; + var bgSize = bg.Texture.Size; + var scaleX = Math.Abs(viewSize.X / bgSize.X); + var scaleY = Math.Abs(viewSize.Y / bgSize.Y); + var signX = Math.Sign(viewSize.X); + var signY = Math.Sign(viewSize.Y); + if (_backgroundImageMode == Stretch.None) + { + scaleX = scaleY = 1f / _renderer.Zoom; + } + else if (_backgroundImageMode == Stretch.Uniform) + { + scaleX = scaleY = Math.Min(scaleX, scaleY); + } + else if (_backgroundImageMode == Stretch.UniformToFill) + { + scaleX = scaleY = Math.Max(scaleX, scaleY); + } + bg.Scale = new(signX * scaleX, signY * scaleY); + bg.Position = view.Center; + bg.Rotation = view.Rotation; + _renderer.Draw(bg); + } + } + if (_showAxis) { // 画一个很长的坐标轴, 用 1e9 比较合适 @@ -442,7 +551,6 @@ namespace SpineViewer.ViewModels.MainWindow public RendererWorkspaceConfigModel WorkspaceConfig { - // TODO: 背景图片 get { return new() @@ -459,6 +567,8 @@ namespace SpineViewer.ViewModels.MainWindow Speed = Speed, ShowAxis = ShowAxis, BackgroundColor = BackgroundColor, + BackgroundImagePath = BackgroundImagePath, + BackgroundImageMode = BackgroundImageMode, }; } set @@ -474,6 +584,8 @@ namespace SpineViewer.ViewModels.MainWindow Speed = value.Speed; ShowAxis = value.ShowAxis; BackgroundColor = value.BackgroundColor; + BackgroundImagePath = value.BackgroundImagePath; + BackgroundImageMode = value.BackgroundImageMode; } } } diff --git a/SpineViewer/Views/MainWindow.xaml b/SpineViewer/Views/MainWindow.xaml index bda9156..965473a 100644 --- a/SpineViewer/Views/MainWindow.xaml +++ b/SpineViewer/Views/MainWindow.xaml @@ -9,6 +9,7 @@ xmlns:utils="clr-namespace:SpineViewer.Utils" xmlns:SFMLRenderer="clr-namespace:SFMLRenderer;assembly=SFMLRenderer" mc:Ignorable="d" + x:Name="_mainWindow" Title="{Binding Title}" Width="1500" Height="800" @@ -479,7 +480,7 @@ - +