diff --git a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs
index fd535f21a6e..be5fcff9af0 100644
--- a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs
+++ b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs
@@ -423,6 +423,12 @@ public DoublePinyinSchemas DoublePinyinSchema
public bool AlwaysPreview { get; set; } = false;
+ ///
+ /// Name of the syntax-highlighting theme used for code blocks in the markdown preview.
+ /// "Auto" follows the app colour scheme (light/dark); otherwise a name.
+ ///
+ public string CodeHighlightTheme { get; set; } = "Auto";
+
public bool AlwaysStartEn { get; set; } = false;
private SearchPrecisionScore _querySearchPrecision = SearchPrecisionScore.Regular;
@@ -696,6 +702,15 @@ public enum ColorSchemes
Dark
}
+ public enum CodeHighlightThemes
+ {
+ Auto,
+ VSCodeLight,
+ VSCodeDarkPlus,
+ CatppuccinMacchiato,
+ OneDark
+ }
+
public enum SearchWindowScreens
{
RememberLastLaunchLocation,
diff --git a/Flow.Launcher.Plugin/Result.cs b/Flow.Launcher.Plugin/Result.cs
index 9404d110705..6a0542ebd1d 100644
--- a/Flow.Launcher.Plugin/Result.cs
+++ b/Flow.Launcher.Plugin/Result.cs
@@ -287,6 +287,18 @@ public string PluginDirectory
///
public PreviewInfo Preview { get; set; } = PreviewInfo.Default;
+ ///
+ /// Controls whether Flow Launcher's preview pane is shown for this result.
+ ///
+ ///
+ /// This is independent of , which only decides how the
+ /// preview is rendered. Use this to force the pane open (for example a result whose whole purpose
+ /// is its markdown preview) or to hide it on results that have nothing useful to preview.
+ ///
+ ///
+ [JsonConverter(typeof(JsonStringEnumConverter))]
+ public PreviewVisibility PreviewVisibility { get; set; } = PreviewVisibility.Default;
+
///
/// Determines if the user selection count should be added to the score. This can be useful when set to false to allow the result sequence order to be the same everytime instead of changing based on selection.
///
@@ -365,6 +377,7 @@ public Result Clone()
ProgressBar = ProgressBar,
ProgressBarColor = ProgressBarColor,
Preview = Preview,
+ PreviewVisibility = PreviewVisibility,
AddSelectedCount = AddSelectedCount,
RecordKey = RecordKey,
ShowBadge = ShowBadge,
@@ -378,7 +391,7 @@ public Result Clone()
public record PreviewInfo
{
///
- /// Full image used for preview panel
+ /// Shown in the preview panel. Falls back to the result icon when not set.
///
public string PreviewImagePath { get; set; } = null;
@@ -388,7 +401,8 @@ public record PreviewInfo
public bool IsMedia { get; set; } = false;
///
- /// Result description text that is shown at the bottom of the preview panel.
+ /// Text shown in the preview panel. How it is displayed depends on
+ /// .
///
///
/// When a value is not set, the will be used.
@@ -406,6 +420,12 @@ public record PreviewInfo
///
public string FilePath { get; set; } = null;
+ ///
+ /// Controls the preview rendering mode. See .
+ ///
+ [JsonConverter(typeof(JsonStringEnumConverter))]
+ public PreviewContentType ContentType { get; set; } = PreviewContentType.ImageWithText;
+
///
/// Default instance of
///
@@ -416,7 +436,52 @@ public record PreviewInfo
IsMedia = false,
PreviewDelegate = null,
FilePath = null,
+ ContentType = PreviewContentType.ImageWithText,
};
}
}
+
+ ///
+ /// Supported preview description rendering modes.
+ ///
+ public enum PreviewContentType
+ {
+ ///
+ /// Shows the preview image above the result title, with the description
+ /// rendered as plain text below a separator.
+ ///
+ [JsonStringEnumMemberName("imageWithText")]
+ ImageWithText,
+
+ ///
+ /// Shows a scrollable panel with the description rendered as formatted markdown.
+ ///
+ [JsonStringEnumMemberName("markdown")]
+ Markdown,
+ }
+
+ ///
+ /// Controls whether the preview pane is shown for a .
+ ///
+ public enum PreviewVisibility
+ {
+ ///
+ /// Follow Flow Launcher's normal preview behaviour (the global "Always Preview" setting and the
+ /// user's manual preview toggle decide whether the pane is shown).
+ ///
+ [JsonStringEnumMemberName("default")]
+ Default,
+
+ ///
+ /// Never show the preview pane for this result, even when the global "Always Preview" setting is on.
+ ///
+ [JsonStringEnumMemberName("never")]
+ Never,
+
+ ///
+ /// Always show the preview pane for this result, even when the global "Always Preview" setting is off.
+ ///
+ [JsonStringEnumMemberName("always")]
+ Always,
+ }
}
diff --git a/Flow.Launcher.Test/CodeHighlightThemeTest.cs b/Flow.Launcher.Test/CodeHighlightThemeTest.cs
new file mode 100644
index 00000000000..5e4a25144b3
--- /dev/null
+++ b/Flow.Launcher.Test/CodeHighlightThemeTest.cs
@@ -0,0 +1,58 @@
+using Flow.Launcher.Resources.Controls;
+using NUnit.Framework;
+using NUnit.Framework.Legacy;
+
+namespace Flow.Launcher.Test
+{
+ [TestFixture]
+ internal class CodeHighlightThemeTest
+ {
+ private string _savedThemeName;
+
+ [SetUp]
+ public void SetUp() => _savedThemeName = PreviewMarkdownScrollViewer.ActiveThemeName;
+
+ [TearDown]
+ public void TearDown() => PreviewMarkdownScrollViewer.ApplyCodeHighlightTheme(_savedThemeName, isDark: true);
+
+ [Test]
+ public void GivenAutoSetting_WhenAppIsDark_ThenActiveThemeIsADarkTheme()
+ {
+ PreviewMarkdownScrollViewer.ApplyCodeHighlightTheme("Auto", isDark: true);
+
+ ClassicAssert.AreEqual("VS Code Dark+", PreviewMarkdownScrollViewer.ActiveThemeName);
+ }
+
+ [Test]
+ public void GivenAutoSetting_WhenAppIsLight_ThenActiveThemeIsALightTheme()
+ {
+ PreviewMarkdownScrollViewer.ApplyCodeHighlightTheme("Auto", isDark: false);
+
+ ClassicAssert.AreEqual("VS Code Light", PreviewMarkdownScrollViewer.ActiveThemeName);
+ }
+
+ [Test]
+ public void GivenExplicitTheme_WhenApplied_ThenActiveThemeMatchesRegardlessOfAppScheme()
+ {
+ PreviewMarkdownScrollViewer.ApplyCodeHighlightTheme("OneDark", isDark: false);
+
+ ClassicAssert.AreEqual("One Dark", PreviewMarkdownScrollViewer.ActiveThemeName);
+ }
+
+ [Test]
+ public void GivenEmptySetting_WhenApplied_ThenFallsBackToAutoBehaviour()
+ {
+ PreviewMarkdownScrollViewer.ApplyCodeHighlightTheme("", isDark: false);
+
+ ClassicAssert.AreEqual("VS Code Light", PreviewMarkdownScrollViewer.ActiveThemeName);
+ }
+
+ [Test]
+ public void GivenUnrecognisedSetting_WhenApplied_ThenFallsBackToAutoBehaviour()
+ {
+ PreviewMarkdownScrollViewer.ApplyCodeHighlightTheme("BogusTheme", isDark: true);
+
+ ClassicAssert.AreEqual("VS Code Dark+", PreviewMarkdownScrollViewer.ActiveThemeName);
+ }
+ }
+}
diff --git a/Flow.Launcher.Test/MainViewModelPreviewTest.cs b/Flow.Launcher.Test/MainViewModelPreviewTest.cs
new file mode 100644
index 00000000000..c4cbc558ba3
--- /dev/null
+++ b/Flow.Launcher.Test/MainViewModelPreviewTest.cs
@@ -0,0 +1,320 @@
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Threading.Tasks;
+using Flow.Launcher.Infrastructure.UserSettings;
+using Flow.Launcher.Plugin;
+using Flow.Launcher.ViewModel;
+using NUnit.Framework;
+using NUnit.Framework.Legacy;
+
+namespace Flow.Launcher.Test
+{
+ [TestFixture]
+ internal class MainViewModelPreviewTest
+ {
+ [Test]
+ public async Task GivenManualOverrideTrue_WhenNeverThenDefaultResultSelected_ThenInternalPreviewIsRestoredAsync()
+ {
+ var settings = new Settings
+ {
+ AlwaysPreview = false
+ };
+ var viewModel = CreatePreviewViewModel(settings, ResultAreaColumnPreviewShown);
+ SetManualPreviewOverride(viewModel, true);
+
+ viewModel.PreviewSelectedItem = ViewModel("Never", PreviewVisibility.Never, settings);
+ await InvokeUpdatePreviewAsync(viewModel);
+ ClassicAssert.IsFalse(viewModel.InternalPreviewVisible);
+
+ viewModel.PreviewSelectedItem = ViewModel("Normal", PreviewVisibility.Default, settings);
+ await InvokeUpdatePreviewAsync(viewModel);
+
+ ClassicAssert.IsTrue(viewModel.InternalPreviewVisible);
+ }
+
+ [Test]
+ public async Task GivenManualOverrideTrueAndExternalPreviewVisible_WhenNeverThenDefaultResultSelected_ThenPreviewIsRestoredAsync()
+ {
+ var settings = new Settings
+ {
+ AlwaysPreview = false
+ };
+ var viewModel = CreatePreviewViewModel(settings, ResultAreaColumnPreviewHidden);
+ SetExternalPreviewVisible(viewModel, true);
+ SetManualPreviewOverride(viewModel, true);
+
+ viewModel.PreviewSelectedItem = ViewModel("Never", PreviewVisibility.Never, settings);
+ await InvokeUpdatePreviewAsync(viewModel);
+ ClassicAssert.IsFalse(viewModel.ExternalPreviewVisible);
+ ClassicAssert.IsFalse(viewModel.InternalPreviewVisible);
+
+ viewModel.PreviewSelectedItem = ViewModel("Normal", PreviewVisibility.Default, settings);
+ await InvokeUpdatePreviewAsync(viewModel);
+
+ ClassicAssert.IsTrue(viewModel.InternalPreviewVisible);
+ }
+
+ [Test]
+ public async Task GivenPreviewHidden_WhenAlwaysVisibilityResultSelected_ThenInternalPreviewAutoOpensAsync()
+ {
+ var settings = new Settings
+ {
+ AlwaysPreview = false
+ };
+ var viewModel = CreatePreviewViewModel(settings, ResultAreaColumnPreviewHidden);
+
+ viewModel.PreviewSelectedItem = ViewModel("Forced", PreviewVisibility.Always, settings);
+ await InvokeUpdatePreviewAsync(viewModel);
+
+ ClassicAssert.IsTrue(viewModel.InternalPreviewVisible);
+ }
+
+ [Test]
+ public async Task GivenPreviewHidden_WhenMarkdownResultWithDefaultVisibilitySelected_ThenInternalPreviewStaysHiddenAsync()
+ {
+ // Content type only controls rendering; it must not force the pane open.
+ // Forcing is the job of PreviewVisibility.Always.
+ var settings = new Settings
+ {
+ AlwaysPreview = false
+ };
+ var viewModel = CreatePreviewViewModel(settings, ResultAreaColumnPreviewHidden);
+
+ viewModel.PreviewSelectedItem =
+ ViewModel("Markdown", PreviewVisibility.Default, settings, PreviewContentType.Markdown);
+ await InvokeUpdatePreviewAsync(viewModel);
+
+ ClassicAssert.IsFalse(viewModel.InternalPreviewVisible);
+ }
+
+ [Test]
+ public async Task GivenAlwaysResultSelected_WhenDefaultResultSelected_ThenInternalPreviewAutoClosesAsync()
+ {
+ var settings = new Settings
+ {
+ AlwaysPreview = false
+ };
+ var viewModel = CreatePreviewViewModel(settings, ResultAreaColumnPreviewHidden);
+
+ viewModel.PreviewSelectedItem = ViewModel("Forced", PreviewVisibility.Always, settings);
+ await InvokeUpdatePreviewAsync(viewModel);
+ ClassicAssert.IsTrue(viewModel.InternalPreviewVisible);
+
+ viewModel.PreviewSelectedItem = ViewModel("Normal", PreviewVisibility.Default, settings);
+ await InvokeUpdatePreviewAsync(viewModel);
+
+ ClassicAssert.IsFalse(viewModel.InternalPreviewVisible);
+ }
+
+ [Test]
+ public async Task GivenManualOverrideTrue_WhenDefaultResultSelected_ThenInternalPreviewStaysVisibleAsync()
+ {
+ var settings = new Settings
+ {
+ AlwaysPreview = false
+ };
+ var viewModel = CreatePreviewViewModel(settings, ResultAreaColumnPreviewShown);
+ SetManualPreviewOverride(viewModel, true);
+
+ viewModel.PreviewSelectedItem = ViewModel("Normal", PreviewVisibility.Default, settings);
+ await InvokeUpdatePreviewAsync(viewModel);
+
+ ClassicAssert.IsTrue(viewModel.InternalPreviewVisible);
+ }
+
+ [Test]
+ public async Task GivenAlwaysResultThenNever_WhenNormalResultSelected_ThenInternalPreviewStaysHiddenAsync()
+ {
+ var settings = new Settings
+ {
+ AlwaysPreview = false
+ };
+ var viewModel = CreatePreviewViewModel(settings, ResultAreaColumnPreviewHidden);
+
+ viewModel.PreviewSelectedItem = ViewModel("Forced", PreviewVisibility.Always, settings);
+ await InvokeUpdatePreviewAsync(viewModel);
+ ClassicAssert.IsTrue(viewModel.InternalPreviewVisible);
+
+ viewModel.PreviewSelectedItem = ViewModel("Never", PreviewVisibility.Never, settings);
+ await InvokeUpdatePreviewAsync(viewModel);
+ ClassicAssert.IsFalse(viewModel.InternalPreviewVisible);
+
+ viewModel.PreviewSelectedItem = ViewModel("Normal", PreviewVisibility.Default, settings);
+ await InvokeUpdatePreviewAsync(viewModel);
+
+ ClassicAssert.IsFalse(viewModel.InternalPreviewVisible);
+ }
+
+ [Test]
+ public async Task GivenManualOverrideFalseAndAlwaysPreviewOn_WhenDefaultResultSelected_ThenInternalPreviewStaysHiddenAsync()
+ {
+ // User closed the preview with F1 (_manualPreviewOverride = false) while
+ // AlwaysPreview is on. The manual override should still beat AlwaysPreview
+ // so selecting a normal result does not reopen the pane.
+ var settings = new Settings
+ {
+ AlwaysPreview = true
+ };
+ var viewModel = CreatePreviewViewModel(settings, ResultAreaColumnPreviewHidden);
+ SetManualPreviewOverride(viewModel, false);
+
+ viewModel.PreviewSelectedItem = ViewModel("Normal", PreviewVisibility.Default, settings);
+ await InvokeUpdatePreviewAsync(viewModel);
+
+ ClassicAssert.IsFalse(viewModel.InternalPreviewVisible);
+ }
+
+ [Test]
+ public async Task GivenAlwaysPreviewOnAndPreviewHidden_WhenNeverThenDefaultResultSelected_ThenInternalPreviewReopensAsync()
+ {
+ var settings = new Settings
+ {
+ AlwaysPreview = true
+ };
+ var viewModel = CreatePreviewViewModel(settings, ResultAreaColumnPreviewHidden);
+
+ viewModel.PreviewSelectedItem = ViewModel("Never", PreviewVisibility.Never, settings);
+ await InvokeUpdatePreviewAsync(viewModel);
+ ClassicAssert.IsFalse(viewModel.InternalPreviewVisible);
+
+ viewModel.PreviewSelectedItem = ViewModel("Normal", PreviewVisibility.Default, settings);
+ await InvokeUpdatePreviewAsync(viewModel);
+
+ ClassicAssert.IsTrue(viewModel.InternalPreviewVisible);
+ }
+
+ [Test]
+ public async Task GivenManualOverrideFalseAndAlwaysPreviewOn_WhenAlwaysResultSelected_ThenInternalPreviewReopensAsync()
+ {
+ // Always must force the pane open even when the user explicitly closed
+ // the preview with F1. Result-level visibility beats manual override.
+ var settings = new Settings
+ {
+ AlwaysPreview = true
+ };
+ var viewModel = CreatePreviewViewModel(settings, ResultAreaColumnPreviewHidden);
+ SetManualPreviewOverride(viewModel, false);
+
+ viewModel.PreviewSelectedItem = ViewModel("Normal", PreviewVisibility.Default, settings);
+ await InvokeUpdatePreviewAsync(viewModel);
+ ClassicAssert.IsFalse(viewModel.InternalPreviewVisible);
+
+ viewModel.PreviewSelectedItem = ViewModel("Forced", PreviewVisibility.Always, settings);
+ await InvokeUpdatePreviewAsync(viewModel);
+
+ ClassicAssert.IsTrue(viewModel.InternalPreviewVisible);
+ }
+
+ [Test]
+ public async Task GivenManualOverrideUnsetAndAlwaysPreviewOffAndAlwaysResult_WhenPreviewReset_ThenInternalPreviewOpens()
+ {
+ // ResetPreviewAsync clears _manualPreviewOverride (sets to null) and re-evaluates.
+ // With AlwaysPreview off but the result forcing its own preview (Always),
+ // the pane should open when the window reopens.
+ var settings = new Settings
+ {
+ AlwaysPreview = false
+ };
+ var viewModel = CreatePreviewViewModel(settings, ResultAreaColumnPreviewHidden);
+ viewModel.PreviewSelectedItem = ViewModel("Forced", PreviewVisibility.Always, settings);
+
+ await viewModel.ResetPreviewAsync();
+
+ ClassicAssert.IsTrue(viewModel.InternalPreviewVisible);
+ }
+
+ [Test]
+ public async Task GivenManualOverrideUnsetAndAlwaysPreviewOffAndDefaultResult_WhenPreviewReset_ThenInternalPreviewStaysHidden()
+ {
+ var settings = new Settings
+ {
+ AlwaysPreview = false
+ };
+ var viewModel = CreatePreviewViewModel(settings, ResultAreaColumnPreviewShown);
+ viewModel.PreviewSelectedItem = ViewModel("Normal", PreviewVisibility.Default, settings);
+
+ await viewModel.ResetPreviewAsync();
+
+ ClassicAssert.IsFalse(viewModel.InternalPreviewVisible);
+ }
+
+ [Test]
+ public async Task GivenManualOverrideFalseAndAlwaysPreviewOn_WhenNeverThenDefaultResult_ThenPreviewStaysHiddenAsync()
+ {
+ // Regression: user closes preview via F1 (_manualOverride = false),
+ // navigates through a Never result, then to a normal result.
+ // The AlwaysPreview restore path must not re-open the pane against
+ // the user's explicit intent.
+ var settings = new Settings
+ {
+ AlwaysPreview = true
+ };
+ var viewModel = CreatePreviewViewModel(settings, ResultAreaColumnPreviewHidden);
+ SetManualPreviewOverride(viewModel, false);
+
+ viewModel.PreviewSelectedItem = ViewModel("Never", PreviewVisibility.Never, settings);
+ await InvokeUpdatePreviewAsync(viewModel);
+ ClassicAssert.IsFalse(viewModel.InternalPreviewVisible);
+
+ viewModel.PreviewSelectedItem = ViewModel("Normal", PreviewVisibility.Default, settings);
+ await InvokeUpdatePreviewAsync(viewModel);
+
+ ClassicAssert.IsFalse(viewModel.InternalPreviewVisible);
+ }
+
+ private static MainViewModel CreatePreviewViewModel(Settings settings, int resultAreaColumn)
+ {
+ var viewModel = (MainViewModel)RuntimeHelpers.GetUninitializedObject(typeof(MainViewModel));
+ typeof(MainViewModel)
+ .GetField("k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)
+ .SetValue(viewModel, settings);
+ viewModel.ResultAreaColumn = resultAreaColumn;
+ return viewModel;
+ }
+
+ private static ResultViewModel ViewModel(
+ string title,
+ PreviewVisibility visibility,
+ Settings settings,
+ PreviewContentType contentType = PreviewContentType.ImageWithText)
+ => new(
+ new Result
+ {
+ Title = title,
+ PreviewVisibility = visibility,
+ Preview = new Result.PreviewInfo
+ {
+ ContentType = contentType
+ }
+ },
+ settings);
+
+ private static int ResultAreaColumnPreviewShown
+ => (int)typeof(MainViewModel)
+ .GetField("ResultAreaColumnPreviewShown", BindingFlags.NonPublic | BindingFlags.Static)
+ .GetValue(null);
+
+ private static int ResultAreaColumnPreviewHidden
+ => (int)typeof(MainViewModel)
+ .GetField("ResultAreaColumnPreviewHidden", BindingFlags.NonPublic | BindingFlags.Static)
+ .GetValue(null);
+
+ private static async Task InvokeUpdatePreviewAsync(MainViewModel viewModel)
+ {
+ var task = (Task)typeof(MainViewModel)
+ .GetMethod("UpdatePreviewAsync", BindingFlags.NonPublic | BindingFlags.Instance)
+ .Invoke(viewModel, null);
+ await task;
+ }
+
+ private static void SetExternalPreviewVisible(MainViewModel viewModel, bool visible)
+ => typeof(MainViewModel)
+ .GetField("k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)
+ .SetValue(viewModel, visible);
+
+ private static void SetManualPreviewOverride(MainViewModel viewModel, bool? value)
+ => typeof(MainViewModel)
+ .GetField("_manualPreviewOverride", BindingFlags.Instance | BindingFlags.NonPublic)
+ .SetValue(viewModel, value);
+ }
+}
diff --git a/Flow.Launcher.Test/Plugins/JsonRPCPluginTest.cs b/Flow.Launcher.Test/Plugins/JsonRPCPluginTest.cs
index 497f874e70f..94d8c8d5153 100644
--- a/Flow.Launcher.Test/Plugins/JsonRPCPluginTest.cs
+++ b/Flow.Launcher.Test/Plugins/JsonRPCPluginTest.cs
@@ -7,6 +7,7 @@
using System.Threading;
using System.Text;
using System.Collections.Generic;
+using System.Linq;
namespace Flow.Launcher.Test.Plugins
{
@@ -61,5 +62,120 @@ public async Task GivenVariousJsonText_WhenVariousNamingCase_ThenExpectNotNullRe
}
})
};
+
+ [Test]
+ public async Task GivenMarkdownPreviewContentType_WhenDeserializeJsonRpcResult_ThenPreviewContentTypeIsMarkdown()
+ {
+ const string resultText =
+ """
+ {
+ "result": [
+ {
+ "title": "Answer",
+ "subTitle": "*args in Python",
+ "preview": {
+ "description": "**`*args`** collects extra positional arguments.",
+ "contentType": "markdown"
+ }
+ }
+ ],
+ "debugMessage": null
+ }
+ """;
+
+ var results = await QueryAsync(new Query
+ {
+ Search = resultText
+ }, default);
+
+ var result = results.Single();
+
+ ClassicAssert.AreEqual(PreviewContentType.Markdown, result.Preview.ContentType);
+ ClassicAssert.AreEqual("**`*args`** collects extra positional arguments.", result.Preview.Description);
+ }
+
+ [Test]
+ public async Task GivenNoPreviewContentType_WhenDeserializeJsonRpcResult_ThenPreviewContentTypeIsImageWithTextAsync()
+ {
+ const string resultText =
+ """
+ {
+ "result": [
+ {
+ "title": "Answer",
+ "subTitle": "Plain result",
+ "preview": {
+ "description": "Plain description."
+ }
+ }
+ ],
+ "debugMessage": null
+ }
+ """;
+
+ var results = await QueryAsync(new Query
+ {
+ Search = resultText
+ }, default);
+
+ var result = results.Single();
+
+ ClassicAssert.AreEqual(PreviewContentType.ImageWithText, result.Preview.ContentType);
+ }
+
+ [Test]
+ public async Task GivenPreviewVisibilityNever_WhenDeserializeJsonRpcResult_ThenPreviewVisibilityIsNeverAsync()
+ {
+ const string resultText =
+ """
+ {
+ "result": [
+ {
+ "title": "Ask",
+ "subTitle": "Type a question",
+ "previewVisibility": "never"
+ }
+ ],
+ "debugMessage": null
+ }
+ """;
+
+ var results = await QueryAsync(new Query
+ {
+ Search = resultText
+ }, default);
+
+ var result = results.Single();
+
+ ClassicAssert.AreEqual(PreviewVisibility.Never, result.PreviewVisibility);
+ }
+
+ [Test]
+ public async Task GivenPreviewVisibilityAlways_WhenDeserializeJsonRpcResult_ThenPreviewVisibilityIsAlwaysAsync()
+ {
+ const string resultText =
+ """
+ {
+ "result": [
+ {
+ "title": "Answer",
+ "subTitle": "Markdown answer",
+ "preview": { "contentType": "markdown" },
+ "previewVisibility": "always"
+ }
+ ],
+ "debugMessage": null
+ }
+ """;
+
+ var results = await QueryAsync(new Query
+ {
+ Search = resultText
+ }, default);
+
+ var result = results.Single();
+
+ ClassicAssert.AreEqual(PreviewVisibility.Always, result.PreviewVisibility);
+ }
}
}
diff --git a/Flow.Launcher.Test/PreviewMarkdownScrollViewerTest.cs b/Flow.Launcher.Test/PreviewMarkdownScrollViewerTest.cs
new file mode 100644
index 00000000000..512ddd0463d
--- /dev/null
+++ b/Flow.Launcher.Test/PreviewMarkdownScrollViewerTest.cs
@@ -0,0 +1,117 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Windows;
+using System.Windows.Documents;
+using System.Windows.Media;
+using Flow.Launcher.Resources.Controls;
+using NUnit.Framework;
+using NUnit.Framework.Legacy;
+
+namespace Flow.Launcher.Test
+{
+ [TestFixture]
+ internal class PreviewMarkdownScrollViewerTest
+ {
+ [Test]
+ [Apartment(ApartmentState.STA)]
+ public void GivenMarkdownLink_WhenRendered_ThenHyperlinkUsesAccentForeground()
+ {
+ var accentBrush = new SolidColorBrush(Color.FromRgb(0x31, 0xA8, 0xFF));
+ var viewer = new PreviewMarkdownScrollViewer
+ {
+ MarkdownStyle = CreateMarkdownStyle(accentBrush),
+ Markdown = "Links should use the theme accent: [Flow Launcher](https://www.flowlauncher.com/)."
+ };
+
+ var hyperlink = EnumerateInlines(viewer.Document.Blocks).OfType().Single();
+ var foreground = (SolidColorBrush)hyperlink.Foreground;
+
+ ClassicAssert.AreEqual(accentBrush.Color, foreground.Color);
+ }
+
+ [Test]
+ [Apartment(ApartmentState.STA)]
+ public void GivenConstrainedPreviewWidth_WhenMarkdownIsRendered_ThenDocumentPageWidthMatchesViewerWidth()
+ {
+ var viewer = new PreviewMarkdownScrollViewer
+ {
+ Width = 280,
+ Height = 200,
+ Markdown = "This long paragraph should wrap inside the preview pane instead of clipping at the right edge."
+ };
+
+ viewer.Measure(new Size(280, 200));
+ viewer.Arrange(new Rect(0, 0, 280, 200));
+ viewer.UpdateLayout();
+
+ ClassicAssert.AreEqual(280, viewer.Document.PageWidth);
+ ClassicAssert.AreEqual(280, viewer.Document.MaxPageWidth);
+ ClassicAssert.AreEqual(280, viewer.Document.ColumnWidth);
+ }
+
+ private static Style CreateMarkdownStyle(Brush accentBrush)
+ {
+ var documentStyle = new Style(typeof(FlowDocument));
+ documentStyle.Setters.Add(new Setter(TextElement.ForegroundProperty, Brushes.LightGray));
+
+ var hyperlinkStyle = new Style(typeof(Hyperlink));
+ hyperlinkStyle.Setters.Add(new Setter(TextElement.ForegroundProperty, accentBrush));
+ hyperlinkStyle.Setters.Add(new Setter(Inline.TextDecorationsProperty, null));
+ documentStyle.Resources.Add(typeof(Hyperlink), hyperlinkStyle);
+
+ return documentStyle;
+ }
+
+ private static IEnumerable EnumerateInlines(BlockCollection blocks)
+ {
+ foreach (var block in blocks)
+ {
+ switch (block)
+ {
+ case Paragraph paragraph:
+ foreach (var inline in EnumerateInlines(paragraph.Inlines))
+ {
+ yield return inline;
+ }
+
+ break;
+
+ case Section section:
+ foreach (var inline in EnumerateInlines(section.Blocks))
+ {
+ yield return inline;
+ }
+
+ break;
+
+ case List list:
+ foreach (var inline in list.ListItems
+ .Cast()
+ .SelectMany(item => EnumerateInlines(item.Blocks)))
+ {
+ yield return inline;
+ }
+
+ break;
+ }
+ }
+ }
+
+ private static IEnumerable EnumerateInlines(InlineCollection inlines)
+ {
+ foreach (var inline in inlines)
+ {
+ yield return inline;
+ if (inline is Span span)
+ {
+ foreach (var child in EnumerateInlines(span.Inlines))
+ {
+ yield return child;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Flow.Launcher.Test/PreviewMarkdownStyleTest.cs b/Flow.Launcher.Test/PreviewMarkdownStyleTest.cs
new file mode 100644
index 00000000000..5f218b3e1dc
--- /dev/null
+++ b/Flow.Launcher.Test/PreviewMarkdownStyleTest.cs
@@ -0,0 +1,64 @@
+using System.IO;
+using System.Linq;
+using System.Xml.Linq;
+using NUnit.Framework;
+using NUnit.Framework.Legacy;
+
+namespace Flow.Launcher.Test
+{
+ [TestFixture]
+ internal class PreviewMarkdownStyleTest
+ {
+ private static readonly XNamespace PresentationNamespace = "http://schemas.microsoft.com/winfx/2006/xaml/presentation";
+ private static readonly XNamespace XamlNamespace = "http://schemas.microsoft.com/winfx/2006/xaml";
+
+ [Test]
+ public void GivenPreviewMarkdownStyle_WhenRendered_ThenBodyAndBoldUseAppTextFont()
+ {
+ var style = GetPreviewMarkdownStyle();
+
+ ClassicAssert.AreEqual("{DynamicResource ContentControlThemeFontFamily}", GetSetterValue(style, "FontFamily"));
+ ClassicAssert.AreEqual("Normal", GetSetterValue(style, "FontWeight"));
+
+ var boldStyle = style.Descendants(PresentationNamespace + "Style")
+ .First(element => element.Attribute("TargetType")?.Value == "{x:Type Bold}");
+
+ ClassicAssert.AreEqual("{DynamicResource ContentControlThemeFontFamily}", GetSetterValue(boldStyle, "FontFamily"));
+ ClassicAssert.AreEqual("SemiBold", GetSetterValue(boldStyle, "FontWeight"));
+ }
+
+ private static XElement GetPreviewMarkdownStyle()
+ {
+ var baseThemePath = FindBaseThemePath();
+ var document = XDocument.Load(baseThemePath);
+
+ return document.Descendants(PresentationNamespace + "Style")
+ .First(element => element.Attribute(XamlNamespace + "Key")?.Value == "PreviewMarkdownStyle");
+ }
+
+ private static string FindBaseThemePath()
+ {
+ var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory);
+ while (directory is not null)
+ {
+ var path = Path.Combine(directory.FullName, "Flow.Launcher", "Themes", "Base.xaml");
+ if (File.Exists(path))
+ {
+ return path;
+ }
+
+ directory = directory.Parent;
+ }
+
+ throw new FileNotFoundException("Could not find Flow.Launcher/Themes/Base.xaml from test directory.");
+ }
+
+ private static string GetSetterValue(XElement style, string propertyName)
+ {
+ return style.Elements(PresentationNamespace + "Setter")
+ .Where(element => element.Attribute("Property")?.Value == propertyName)
+ .Select(element => element.Attribute("Value")?.Value)
+ .FirstOrDefault();
+ }
+ }
+}
diff --git a/Flow.Launcher/Flow.Launcher.csproj b/Flow.Launcher/Flow.Launcher.csproj
index 2a1332b4260..7b0f4a04db7 100644
--- a/Flow.Launcher/Flow.Launcher.csproj
+++ b/Flow.Launcher/Flow.Launcher.csproj
@@ -137,6 +137,7 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
+
diff --git a/Flow.Launcher/Languages/en.xaml b/Flow.Launcher/Languages/en.xaml
index cf67bffeafa..0f8172f79df 100644
--- a/Flow.Launcher/Languages/en.xaml
+++ b/Flow.Launcher/Languages/en.xaml
@@ -322,6 +322,13 @@
System Default
Light
Dark
+ Code Highlight Theme
+ Syntax highlighting theme for code blocks in the markdown preview pane.
+ Auto (match color scheme)
+ VS Code Light
+ VS Code Dark+
+ Catppuccin Macchiato
+ One Dark
Sound Effect
Play a small sound when the search window opens
Sound Effect Volume
diff --git a/Flow.Launcher/MainWindow.xaml b/Flow.Launcher/MainWindow.xaml
index 2950d2f6555..79a74609730 100644
--- a/Flow.Launcher/MainWindow.xaml
+++ b/Flow.Launcher/MainWindow.xaml
@@ -3,6 +3,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:Flow.Launcher.Converters"
+ xmlns:controls="clr-namespace:Flow.Launcher.Resources.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:flowlauncher="clr-namespace:Flow.Launcher"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
@@ -403,9 +404,9 @@
-
+
-
+
@@ -454,11 +457,12 @@
@@ -470,7 +474,10 @@
-
+
@@ -514,7 +521,20 @@
TextAlignment="Center"
TextWrapping="Wrap" />
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Flow.Launcher/Themes/Midnight.xaml b/Flow.Launcher/Themes/Midnight.xaml
index 134b006d987..58eed2f036c 100644
--- a/Flow.Launcher/Themes/Midnight.xaml
+++ b/Flow.Launcher/Themes/Midnight.xaml
@@ -110,6 +110,26 @@
x:Key="ScrollBarStyle"
BasedOn="{StaticResource BaseScrollBarStyle}"
TargetType="{x:Type ScrollBar}" />
+
+
+
+
+
+
F1 M20,20z M0,0z M14.75,1A5.24,5.24,0,0,0,10,4A5.24,5.24,0,0,0,0,6.25C0,11.75 10,19 10,19 10,19 20,11.75 20,6.25A5.25,5.25,0,0,0,14.75,1z
+
+
+
+
+
diff --git a/Flow.Launcher/Themes/Ubuntu.xaml b/Flow.Launcher/Themes/Ubuntu.xaml
index 330d57b299c..dc2e8f3c9fd 100644
--- a/Flow.Launcher/Themes/Ubuntu.xaml
+++ b/Flow.Launcher/Themes/Ubuntu.xaml
@@ -127,6 +127,26 @@
x:Key="ScrollBarStyle"
BasedOn="{StaticResource BaseScrollBarStyle}"
TargetType="{x:Type ScrollBar}" />
+
+
+
+
+
+