Public Access
Improve settings logs agent and renderer
PR and Push Build/Test / build-and-test (push) Successful in 8m13s
PR and Push Build/Test / build-and-test (push) Successful in 8m13s
This commit is contained in:
@@ -704,7 +704,7 @@ public sealed class WorkflowRulesEditorTests
|
||||
public void MarkdownParserRecognizesRequestedInlineStyles()
|
||||
{
|
||||
var inlines = WorkflowRulesEditorMarkdown.ParseInline(
|
||||
"Normal *italic* **bold** and `code`.");
|
||||
"Normal *italic* **bold** ***both*** ~~gone~~ [docs](https://example.test) <https://auto.test> <test@example.com>  and `code`.");
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
@@ -712,6 +712,30 @@ public sealed class WorkflowRulesEditorTests
|
||||
new WorkflowRulesEditorMarkdownInline("italic", WorkflowRulesEditorMarkdownInlineStyle.Italic),
|
||||
new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
|
||||
new WorkflowRulesEditorMarkdownInline("bold", WorkflowRulesEditorMarkdownInlineStyle.Bold),
|
||||
new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
|
||||
new WorkflowRulesEditorMarkdownInline("both", WorkflowRulesEditorMarkdownInlineStyle.BoldItalic),
|
||||
new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
|
||||
new WorkflowRulesEditorMarkdownInline("gone", WorkflowRulesEditorMarkdownInlineStyle.Strikethrough),
|
||||
new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
|
||||
new WorkflowRulesEditorMarkdownInline(
|
||||
"docs",
|
||||
WorkflowRulesEditorMarkdownInlineStyle.Link,
|
||||
"https://example.test"),
|
||||
new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
|
||||
new WorkflowRulesEditorMarkdownInline(
|
||||
"https://auto.test",
|
||||
WorkflowRulesEditorMarkdownInlineStyle.Link,
|
||||
"https://auto.test"),
|
||||
new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
|
||||
new WorkflowRulesEditorMarkdownInline(
|
||||
"test@example.com",
|
||||
WorkflowRulesEditorMarkdownInlineStyle.Link,
|
||||
"mailto:test@example.com"),
|
||||
new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
|
||||
new WorkflowRulesEditorMarkdownInline(
|
||||
"logo",
|
||||
WorkflowRulesEditorMarkdownInlineStyle.Image,
|
||||
"C:/tmp/logo.png"),
|
||||
new WorkflowRulesEditorMarkdownInline(" and ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
|
||||
new WorkflowRulesEditorMarkdownInline("code", WorkflowRulesEditorMarkdownInlineStyle.Code),
|
||||
new WorkflowRulesEditorMarkdownInline(".", WorkflowRulesEditorMarkdownInlineStyle.Normal)
|
||||
@@ -719,6 +743,138 @@ public sealed class WorkflowRulesEditorTests
|
||||
inlines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkdownParserRecognizesHeadingsBlockquotesAndCheckboxes()
|
||||
{
|
||||
var blocks = WorkflowRulesEditorMarkdown.Parse("""
|
||||
# Main
|
||||
## Detail
|
||||
|
||||
> quoted **line**
|
||||
> second
|
||||
|
||||
- [x] done
|
||||
- [ ] open
|
||||
""");
|
||||
|
||||
Assert.Collection(
|
||||
blocks,
|
||||
block =>
|
||||
{
|
||||
var heading = Assert.IsType<WorkflowRulesEditorMarkdownHeading>(block);
|
||||
Assert.Equal(1, heading.Level);
|
||||
Assert.Equal("# Main", Assert.Single(heading.Inlines).Text);
|
||||
},
|
||||
block =>
|
||||
{
|
||||
var heading = Assert.IsType<WorkflowRulesEditorMarkdownHeading>(block);
|
||||
Assert.Equal(2, heading.Level);
|
||||
Assert.Equal("## Detail", Assert.Single(heading.Inlines).Text);
|
||||
},
|
||||
block =>
|
||||
{
|
||||
var quote = Assert.IsType<WorkflowRulesEditorMarkdownBlockQuote>(block);
|
||||
Assert.Contains(quote.Inlines, inline => inline.Text.Contains("quoted", StringComparison.Ordinal));
|
||||
Assert.Contains(quote.Inlines, inline => inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Bold);
|
||||
},
|
||||
block =>
|
||||
{
|
||||
var tasks = Assert.IsType<WorkflowRulesEditorMarkdownTaskList>(block);
|
||||
Assert.Collection(
|
||||
tasks.Items,
|
||||
item =>
|
||||
{
|
||||
Assert.True(item.IsChecked);
|
||||
Assert.Equal("done", Assert.Single(item.Inlines).Text);
|
||||
},
|
||||
item =>
|
||||
{
|
||||
Assert.False(item.IsChecked);
|
||||
Assert.Equal("open", Assert.Single(item.Inlines).Text);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkdownParserRecognizesStandaloneImageEmbeds()
|
||||
{
|
||||
var blocks = WorkflowRulesEditorMarkdown.Parse("""
|
||||
Before
|
||||
|
||||

|
||||
|
||||
After
|
||||
""");
|
||||
|
||||
Assert.Collection(
|
||||
blocks,
|
||||
block => Assert.IsType<WorkflowRulesEditorMarkdownParagraph>(block),
|
||||
block =>
|
||||
{
|
||||
var image = Assert.IsType<WorkflowRulesEditorMarkdownImage>(block);
|
||||
Assert.Equal("Architecture diagram", image.AltText);
|
||||
Assert.Equal("C:/tmp/architecture.png", image.Target);
|
||||
},
|
||||
block => Assert.IsType<WorkflowRulesEditorMarkdownParagraph>(block));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkdownLinkResolverFindsScreenshotAttachmentsUnderArtifactRoots()
|
||||
{
|
||||
var temp = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var contextRoot = Path.Combine(temp, "Meetings", "Assistant Context");
|
||||
var attachmentPath = Path.Combine(
|
||||
contextRoot,
|
||||
"2026",
|
||||
"06",
|
||||
"Attachments",
|
||||
"20260601-120000-001.png");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(attachmentPath)!);
|
||||
File.WriteAllBytes(attachmentPath, [1, 2, 3]);
|
||||
var resolver = new WorkflowRulesEditorMarkdownLinkResolver([contextRoot], [contextRoot]);
|
||||
|
||||
Assert.True(resolver.TryResolveImageUri("Attachments/20260601-120000-001.png", out var uri));
|
||||
Assert.Equal(new Uri(attachmentPath), uri);
|
||||
Assert.Equal(attachmentPath, resolver.ResolveOpenTarget("Attachments/20260601-120000-001.png"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(temp))
|
||||
{
|
||||
Directory.Delete(temp, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkdownLinkResolverDoesNotCacheMissingFiles()
|
||||
{
|
||||
var temp = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var contextRoot = Path.Combine(temp, "Meetings", "Assistant Context");
|
||||
var attachmentPath = Path.Combine(contextRoot, "Attachments", "late.png");
|
||||
var resolver = new WorkflowRulesEditorMarkdownLinkResolver([contextRoot], [contextRoot]);
|
||||
|
||||
Assert.False(resolver.TryResolveImageUri("Attachments/late.png", out _));
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(attachmentPath)!);
|
||||
File.WriteAllBytes(attachmentPath, [1, 2, 3]);
|
||||
|
||||
Assert.True(resolver.TryResolveImageUri("Attachments/late.png", out var uri));
|
||||
Assert.Equal(new Uri(attachmentPath), uri);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(temp))
|
||||
{
|
||||
Directory.Delete(temp, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkdownParserPreservesParagraphLineBreaks()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
global using System.IO;
|
||||
global using System.Net.Http;
|
||||
@@ -12,6 +12,7 @@
|
||||
<OutputType>WinExe</OutputType>
|
||||
<ApplicationIcon>Assets\meeting-assistant.ico</ApplicationIcon>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<UseWPF>true</UseWPF>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -34,7 +35,6 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
|
||||
<PackageReference Include="Aprillz.MewUI.Windows" Version="0.15.2" />
|
||||
<PackageReference Include="H.NotifyIcon.Uno.WinUI" Version="2.4.1" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
<Compile Remove="MeetingNotes\OutlookClassicMeetingMetadataProvider.Windows.cs" />
|
||||
<Compile Remove="Screenshots\ActiveWindowScreenshotCapture.Windows.cs" />
|
||||
<Compile Remove="Taskbar\UnoTaskbarIconService.Windows.cs" />
|
||||
<Compile Remove="Workflow\MewUiWorkflowRulesEditorWindowService.Windows.cs" />
|
||||
<Compile Remove="Workflow\WpfWorkflowRulesEditorWindowService.Windows.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -71,7 +71,9 @@ builder.Services.AddSingleton<IWorkflowRulesEditorSamplePlaybackQueue, WorkflowR
|
||||
builder.Services.AddSingleton<IWorkflowRulesEditorChatPipeline, WorkflowRulesEditorChatPipeline>();
|
||||
builder.Services.AddTransient<WorkflowRulesEditorChatViewModel>();
|
||||
#if WINDOWS
|
||||
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, MewUiWorkflowRulesEditorWindowService>();
|
||||
builder.Services.AddSingleton(services => WorkflowRulesEditorMarkdownLinkResolver.FromOptions(
|
||||
services.GetRequiredService<IOptions<MeetingAssistantOptions>>().Value));
|
||||
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, WpfWorkflowRulesEditorWindowService>();
|
||||
#else
|
||||
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, NoopWorkflowRulesEditorWindowService>();
|
||||
#endif
|
||||
|
||||
@@ -1,387 +0,0 @@
|
||||
#if WINDOWS
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using Aprillz.MewUI;
|
||||
using Aprillz.MewUI.Controls;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public sealed class MewUiWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService
|
||||
{
|
||||
internal const string WindowTitle = "Settings and logs";
|
||||
|
||||
private readonly IServiceProvider services;
|
||||
private readonly ILogger<MewUiWorkflowRulesEditorWindowService> logger;
|
||||
private readonly object sync = new();
|
||||
private bool isRunning;
|
||||
|
||||
public MewUiWorkflowRulesEditorWindowService(
|
||||
IServiceProvider services,
|
||||
ILogger<MewUiWorkflowRulesEditorWindowService> logger)
|
||||
{
|
||||
this.services = services;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public void Show()
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
if (isRunning)
|
||||
{
|
||||
logger.LogInformation("Workflow rules editor UI is already running");
|
||||
if (WorkflowRulesEditorWindowActivation.TryActivate(WindowTitle))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogWarning("Workflow rules editor UI was marked running, but no window could be activated; starting a new UI thread");
|
||||
isRunning = false;
|
||||
}
|
||||
|
||||
isRunning = true;
|
||||
}
|
||||
|
||||
logger.LogInformation("Starting workflow rules editor UI thread");
|
||||
var thread = new Thread(RunWindow)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "Meeting Assistant Rules Editor"
|
||||
};
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
}
|
||||
|
||||
private void RunWindow()
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.LogInformation("Workflow rules editor UI thread started");
|
||||
var viewModel = services.GetRequiredService<WorkflowRulesEditorChatViewModel>();
|
||||
var window = new WorkflowRulesEditorMewWindow(viewModel);
|
||||
window.Run();
|
||||
logger.LogInformation("Workflow rules editor UI window closed");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Workflow rules editor UI failed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
isRunning = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class WorkflowRulesEditorMewWindow
|
||||
{
|
||||
private readonly WorkflowRulesEditorChatViewModel viewModel;
|
||||
private readonly StackPanel conversationPanel = new();
|
||||
private readonly ScrollViewer conversationScroll = new();
|
||||
private readonly MultiLineTextBox input = new();
|
||||
private readonly Button sendButton = new();
|
||||
private readonly EventHandler changedHandler;
|
||||
|
||||
public WorkflowRulesEditorMewWindow(WorkflowRulesEditorChatViewModel viewModel)
|
||||
{
|
||||
this.viewModel = viewModel;
|
||||
changedHandler = (_, _) => RequestRender();
|
||||
this.viewModel.Changed += changedHandler;
|
||||
}
|
||||
|
||||
public void Run()
|
||||
{
|
||||
ConfigureTheme();
|
||||
|
||||
conversationPanel
|
||||
.Vertical()
|
||||
.Spacing(8);
|
||||
input
|
||||
.Height(92)
|
||||
.Wrap(true)
|
||||
.Placeholder("ask me what I can do for you")
|
||||
.OnTextChanged(text => viewModel.Draft = text)
|
||||
.OnKeyDown(args =>
|
||||
{
|
||||
if (args.Key == Key.Enter && !args.ShiftKey)
|
||||
{
|
||||
args.Handled = true;
|
||||
_ = SendAsync();
|
||||
}
|
||||
});
|
||||
sendButton
|
||||
.Content("Send")
|
||||
.Width(84)
|
||||
.OnClick(() => _ = SendAsync());
|
||||
|
||||
var window = new Window()
|
||||
.Title(MewUiWorkflowRulesEditorWindowService.WindowTitle)
|
||||
.Resizable(680, 760, 520, 460)
|
||||
.Padding(0)
|
||||
.OnLoaded(Render)
|
||||
.OnClosed(() => viewModel.Changed -= changedHandler)
|
||||
.Content(
|
||||
new DockPanel()
|
||||
.LastChildFill()
|
||||
.Padding(12)
|
||||
.Spacing(10)
|
||||
.Children(
|
||||
new DockPanel()
|
||||
.LastChildFill()
|
||||
.Spacing(8)
|
||||
.DockBottom()
|
||||
.Children(
|
||||
sendButton.DockRight(),
|
||||
input),
|
||||
conversationScroll
|
||||
.AutoVerticalScroll()
|
||||
.NoHorizontalScroll()
|
||||
.Content(conversationPanel)));
|
||||
var icon = LoadWindowIcon();
|
||||
if (icon is not null)
|
||||
{
|
||||
window.Icon = icon;
|
||||
}
|
||||
|
||||
Application.Create()
|
||||
.UseTheme(ThemeVariant.Dark)
|
||||
.UseWin32()
|
||||
.UseDirect2D()
|
||||
.Run(window);
|
||||
}
|
||||
|
||||
private static IconSource? LoadWindowIcon()
|
||||
{
|
||||
var iconPath = Path.Combine(AppContext.BaseDirectory, "Assets", "meeting-assistant.ico");
|
||||
return File.Exists(iconPath)
|
||||
? IconSource.FromFile(iconPath)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static void ConfigureTheme()
|
||||
{
|
||||
ThemeManager.DefaultLightSeed = ThemeSeed.DefaultLight with
|
||||
{
|
||||
ButtonFace = Color.FromRgb(245, 245, 245)
|
||||
};
|
||||
ThemeManager.DefaultDarkSeed = ThemeSeed.DefaultDark with
|
||||
{
|
||||
ButtonFace = Color.FromRgb(42, 46, 54)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task SendAsync()
|
||||
{
|
||||
var sendTask = viewModel.SendAsync();
|
||||
input.Text = viewModel.Draft;
|
||||
await sendTask;
|
||||
}
|
||||
|
||||
private void RequestRender()
|
||||
{
|
||||
var dispatcher = Application.Current?.Dispatcher;
|
||||
if (dispatcher is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
dispatcher.BeginInvoke(Render);
|
||||
}
|
||||
|
||||
private void Render()
|
||||
{
|
||||
var shouldAutoScroll = WorkflowRulesEditorScrollPolicy.ShouldAutoScroll(
|
||||
conversationScroll.VerticalOffset,
|
||||
conversationScroll.ViewportHeight,
|
||||
conversationPanel.ActualHeight);
|
||||
|
||||
conversationPanel.Clear();
|
||||
|
||||
foreach (var message in viewModel.Messages)
|
||||
{
|
||||
conversationPanel.Add(CreateMessageCard(message));
|
||||
}
|
||||
|
||||
if (viewModel.IsThinking)
|
||||
{
|
||||
conversationPanel.Add(new TextBlock()
|
||||
.Text("Thinking...")
|
||||
.Foreground(Color.FromRgb(156, 166, 181))
|
||||
.Margin(4, 2, 4, 2));
|
||||
}
|
||||
|
||||
sendButton.IsEnabled = !viewModel.IsThinking;
|
||||
|
||||
if (shouldAutoScroll)
|
||||
{
|
||||
QueueScrollConversationToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
private void QueueScrollConversationToBottom()
|
||||
{
|
||||
var dispatcher = Application.Current?.Dispatcher;
|
||||
if (dispatcher is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
dispatcher.BeginInvoke(DispatcherPriority.Idle, () =>
|
||||
{
|
||||
ScrollConversationToBottom();
|
||||
dispatcher.BeginInvoke(DispatcherPriority.Idle, ScrollConversationToBottom);
|
||||
});
|
||||
}
|
||||
|
||||
private void ScrollConversationToBottom()
|
||||
{
|
||||
conversationScroll.SetScrollOffsets(
|
||||
conversationScroll.HorizontalOffset,
|
||||
WorkflowRulesEditorScrollPolicy.GetBottomOffset(
|
||||
conversationScroll.ViewportHeight,
|
||||
conversationPanel.ActualHeight));
|
||||
}
|
||||
|
||||
private static Element CreateMessageCard(WorkflowRulesEditorChatMessage message)
|
||||
{
|
||||
var isUser = message.Role == WorkflowRulesEditorChatRole.User;
|
||||
var copyButton = new Button()
|
||||
.Content("⧉")
|
||||
.Width(28)
|
||||
.Height(24)
|
||||
.ToolTip("Copy message")
|
||||
.OnClick(() => WorkflowRulesEditorClipboard.SetText(message.Content));
|
||||
copyButton.IsVisible = false;
|
||||
|
||||
var copyRow = new DockPanel()
|
||||
.LastChildFill(false)
|
||||
.Children(copyButton.DockRight());
|
||||
var cardContent = new StackPanel()
|
||||
.Vertical()
|
||||
.Spacing(4)
|
||||
.Children(
|
||||
copyRow,
|
||||
WorkflowRulesEditorMarkdownRenderer.CreateContent(message.Content));
|
||||
|
||||
var card = new Border()
|
||||
.Padding(10)
|
||||
.Margin(isUser ? new Thickness(42, 0, 4, 0) : new Thickness(0, 0, 42, 0))
|
||||
.CornerRadius(8)
|
||||
.BorderThickness(1)
|
||||
.BorderBrush(isUser ? Color.FromRgb(68, 95, 132) : Color.FromRgb(69, 75, 86))
|
||||
.Background(isUser ? Color.FromRgb(26, 48, 78) : Color.FromRgb(35, 39, 46))
|
||||
.OnMouseEnter(() => copyButton.IsVisible = true)
|
||||
.OnMouseLeave(() => copyButton.IsVisible = false)
|
||||
.Child(cardContent);
|
||||
return card;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class WorkflowRulesEditorWindowActivation
|
||||
{
|
||||
private const int SwRestore = 9;
|
||||
|
||||
public static bool TryActivate(string title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var handle = FindWindow(null, title);
|
||||
if (handle == IntPtr.Zero)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ShowWindow(handle, SwRestore);
|
||||
return SetForegroundWindow(handle);
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern IntPtr FindWindow(string? lpClassName, string lpWindowName);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
}
|
||||
|
||||
internal static class WorkflowRulesEditorClipboard
|
||||
{
|
||||
private const uint CfUnicodeText = 13;
|
||||
private const uint GmemMoveable = 0x0002;
|
||||
private const uint GmemZeroInit = 0x0040;
|
||||
|
||||
public static void SetText(string text)
|
||||
{
|
||||
var bytes = Encoding.Unicode.GetBytes((text ?? "") + '\0');
|
||||
var handle = GlobalAlloc(GmemMoveable | GmemZeroInit, (UIntPtr)bytes.Length);
|
||||
if (handle == IntPtr.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var locked = GlobalLock(handle);
|
||||
if (locked == IntPtr.Zero)
|
||||
{
|
||||
GlobalFree(handle);
|
||||
return;
|
||||
}
|
||||
|
||||
Marshal.Copy(bytes, 0, locked, bytes.Length);
|
||||
GlobalUnlock(handle);
|
||||
|
||||
if (!OpenClipboard(IntPtr.Zero))
|
||||
{
|
||||
GlobalFree(handle);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
EmptyClipboard();
|
||||
if (SetClipboardData(CfUnicodeText, handle) != IntPtr.Zero)
|
||||
{
|
||||
handle = IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseClipboard();
|
||||
if (handle != IntPtr.Zero)
|
||||
{
|
||||
GlobalFree(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool OpenClipboard(IntPtr hWndNewOwner);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool EmptyClipboard();
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern IntPtr SetClipboardData(uint uFormat, IntPtr hMem);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool CloseClipboard();
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern IntPtr GlobalAlloc(uint uFlags, UIntPtr dwBytes);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern IntPtr GlobalLock(IntPtr hMem);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool GlobalUnlock(IntPtr hMem);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern IntPtr GlobalFree(IntPtr hMem);
|
||||
}
|
||||
#endif
|
||||
@@ -5,6 +5,10 @@ internal enum WorkflowRulesEditorMarkdownInlineStyle
|
||||
Normal,
|
||||
Italic,
|
||||
Bold,
|
||||
BoldItalic,
|
||||
Strikethrough,
|
||||
Link,
|
||||
Image,
|
||||
Code
|
||||
}
|
||||
|
||||
@@ -13,6 +17,24 @@ internal abstract record WorkflowRulesEditorMarkdownBlock;
|
||||
internal sealed record WorkflowRulesEditorMarkdownParagraph(
|
||||
IReadOnlyList<WorkflowRulesEditorMarkdownInline> Inlines) : WorkflowRulesEditorMarkdownBlock;
|
||||
|
||||
internal sealed record WorkflowRulesEditorMarkdownHeading(
|
||||
int Level,
|
||||
IReadOnlyList<WorkflowRulesEditorMarkdownInline> Inlines) : WorkflowRulesEditorMarkdownBlock;
|
||||
|
||||
internal sealed record WorkflowRulesEditorMarkdownBlockQuote(
|
||||
IReadOnlyList<WorkflowRulesEditorMarkdownInline> Inlines) : WorkflowRulesEditorMarkdownBlock;
|
||||
|
||||
internal sealed record WorkflowRulesEditorMarkdownImage(
|
||||
string AltText,
|
||||
string Target) : WorkflowRulesEditorMarkdownBlock;
|
||||
|
||||
internal sealed record WorkflowRulesEditorMarkdownTaskList(
|
||||
IReadOnlyList<WorkflowRulesEditorMarkdownTaskItem> Items) : WorkflowRulesEditorMarkdownBlock;
|
||||
|
||||
internal sealed record WorkflowRulesEditorMarkdownTaskItem(
|
||||
bool IsChecked,
|
||||
IReadOnlyList<WorkflowRulesEditorMarkdownInline> Inlines);
|
||||
|
||||
internal sealed record WorkflowRulesEditorMarkdownCodeBlock(string Text) : WorkflowRulesEditorMarkdownBlock;
|
||||
|
||||
internal sealed record WorkflowRulesEditorMarkdownTable(
|
||||
@@ -21,7 +43,8 @@ internal sealed record WorkflowRulesEditorMarkdownTable(
|
||||
|
||||
internal sealed record WorkflowRulesEditorMarkdownInline(
|
||||
string Text,
|
||||
WorkflowRulesEditorMarkdownInlineStyle Style);
|
||||
WorkflowRulesEditorMarkdownInlineStyle Style,
|
||||
string? LinkTarget = null);
|
||||
|
||||
internal static class WorkflowRulesEditorMarkdown
|
||||
{
|
||||
@@ -64,6 +87,50 @@ internal static class WorkflowRulesEditorMarkdown
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseStandaloneImage(line, out var image))
|
||||
{
|
||||
FlushParagraph(blocks, paragraph);
|
||||
blocks.Add(image);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseHeading(line, out var heading))
|
||||
{
|
||||
FlushParagraph(blocks, paragraph);
|
||||
blocks.Add(heading);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsBlockQuote(line))
|
||||
{
|
||||
FlushParagraph(blocks, paragraph);
|
||||
var quote = new List<string>();
|
||||
while (index < lines.Length && IsBlockQuote(lines[index]))
|
||||
{
|
||||
quote.Add(lines[index].TrimStart()[1..].TrimStart());
|
||||
index++;
|
||||
}
|
||||
|
||||
blocks.Add(new WorkflowRulesEditorMarkdownBlockQuote(ParseInline(string.Join('\n', quote))));
|
||||
index--;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseTaskLine(line, out _))
|
||||
{
|
||||
FlushParagraph(blocks, paragraph);
|
||||
var tasks = new List<WorkflowRulesEditorMarkdownTaskItem>();
|
||||
while (index < lines.Length && TryParseTaskLine(lines[index], out var task))
|
||||
{
|
||||
tasks.Add(task);
|
||||
index++;
|
||||
}
|
||||
|
||||
blocks.Add(new WorkflowRulesEditorMarkdownTaskList(tasks));
|
||||
index--;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsTableStart(lines, index))
|
||||
{
|
||||
FlushParagraph(blocks, paragraph);
|
||||
@@ -128,6 +195,72 @@ internal static class WorkflowRulesEditorMarkdown
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if (text.AsSpan(next).StartsWith("![", StringComparison.Ordinal))
|
||||
{
|
||||
if (TryParseImage(text, next, out var altText, out var target, out var end))
|
||||
{
|
||||
AddInline(
|
||||
result,
|
||||
altText,
|
||||
WorkflowRulesEditorMarkdownInlineStyle.Image,
|
||||
target);
|
||||
index = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if (text[next] == '[')
|
||||
{
|
||||
var endLabel = text.IndexOf(']', next + 1);
|
||||
if (endLabel > next &&
|
||||
endLabel + 1 < text.Length &&
|
||||
text[endLabel + 1] == '(')
|
||||
{
|
||||
var endTarget = text.IndexOf(')', endLabel + 2);
|
||||
if (endTarget > endLabel + 2)
|
||||
{
|
||||
AddInline(
|
||||
result,
|
||||
text[(next + 1)..endLabel],
|
||||
WorkflowRulesEditorMarkdownInlineStyle.Link,
|
||||
text[(endLabel + 2)..endTarget]);
|
||||
index = endTarget + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (text[next] == '<')
|
||||
{
|
||||
if (TryParseAutolink(text, next, out var label, out var target, out var end))
|
||||
{
|
||||
AddInline(
|
||||
result,
|
||||
label,
|
||||
WorkflowRulesEditorMarkdownInlineStyle.Link,
|
||||
target);
|
||||
index = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if (text.AsSpan(next).StartsWith("***", StringComparison.Ordinal))
|
||||
{
|
||||
var end = text.IndexOf("***", next + 3, StringComparison.Ordinal);
|
||||
if (end > next)
|
||||
{
|
||||
AddInline(result, text[(next + 3)..end], WorkflowRulesEditorMarkdownInlineStyle.BoldItalic);
|
||||
index = end + 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if (text.AsSpan(next).StartsWith("~~", StringComparison.Ordinal))
|
||||
{
|
||||
var end = text.IndexOf("~~", next + 2, StringComparison.Ordinal);
|
||||
if (end > next)
|
||||
{
|
||||
AddInline(result, text[(next + 2)..end], WorkflowRulesEditorMarkdownInlineStyle.Strikethrough);
|
||||
index = end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if (text.AsSpan(next).StartsWith("**", StringComparison.Ordinal))
|
||||
{
|
||||
var end = text.IndexOf("**", next + 2, StringComparison.Ordinal);
|
||||
@@ -169,6 +302,141 @@ internal static class WorkflowRulesEditorMarkdown
|
||||
paragraph.Clear();
|
||||
}
|
||||
|
||||
private static bool TryParseHeading(string line, out WorkflowRulesEditorMarkdownHeading heading)
|
||||
{
|
||||
heading = null!;
|
||||
var trimmed = line.TrimStart();
|
||||
var level = trimmed.StartsWith("# ", StringComparison.Ordinal)
|
||||
? 1
|
||||
: trimmed.StartsWith("## ", StringComparison.Ordinal)
|
||||
? 2
|
||||
: 0;
|
||||
if (level == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
heading = new WorkflowRulesEditorMarkdownHeading(level, ParseInline(trimmed));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsBlockQuote(string line)
|
||||
{
|
||||
return line.TrimStart().StartsWith('>');
|
||||
}
|
||||
|
||||
private static bool TryParseTaskLine(string line, out WorkflowRulesEditorMarkdownTaskItem task)
|
||||
{
|
||||
task = null!;
|
||||
var trimmedStart = line.TrimStart();
|
||||
if (trimmedStart.StartsWith("- [x] ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
task = new WorkflowRulesEditorMarkdownTaskItem(true, ParseInline(trimmedStart[6..]));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (trimmedStart.StartsWith("- [ ] ", StringComparison.Ordinal))
|
||||
{
|
||||
task = new WorkflowRulesEditorMarkdownTaskItem(false, ParseInline(trimmedStart[6..]));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryParseStandaloneImage(
|
||||
string line,
|
||||
out WorkflowRulesEditorMarkdownImage image)
|
||||
{
|
||||
image = null!;
|
||||
var trimmed = line.Trim();
|
||||
if (!TryParseImage(trimmed, 0, out var altText, out var target, out var end) ||
|
||||
end != trimmed.Length - 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
image = new WorkflowRulesEditorMarkdownImage(altText, target);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseImage(
|
||||
string text,
|
||||
int start,
|
||||
out string altText,
|
||||
out string target,
|
||||
out int end)
|
||||
{
|
||||
altText = "";
|
||||
target = "";
|
||||
end = -1;
|
||||
if (!text.AsSpan(start).StartsWith("![", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var endLabel = text.IndexOf(']', start + 2);
|
||||
if (endLabel <= start ||
|
||||
endLabel + 1 >= text.Length ||
|
||||
text[endLabel + 1] != '(')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var endTarget = text.IndexOf(')', endLabel + 2);
|
||||
if (endTarget <= endLabel + 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
altText = text[(start + 2)..endLabel];
|
||||
target = text[(endLabel + 2)..endTarget];
|
||||
end = endTarget;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseAutolink(
|
||||
string text,
|
||||
int start,
|
||||
out string label,
|
||||
out string target,
|
||||
out int end)
|
||||
{
|
||||
label = "";
|
||||
target = "";
|
||||
end = text.IndexOf('>', start + 1);
|
||||
if (end <= start + 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
label = text[(start + 1)..end];
|
||||
if (label.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||
label.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
|
||||
label.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
target = label;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IsEmailAddress(label))
|
||||
{
|
||||
target = "mailto:" + label;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsEmailAddress(string text)
|
||||
{
|
||||
var at = text.IndexOf('@');
|
||||
return at > 0 &&
|
||||
at < text.Length - 1 &&
|
||||
text.IndexOf('.', at + 1) > at + 1 &&
|
||||
!text.Any(char.IsWhiteSpace);
|
||||
}
|
||||
|
||||
private static bool IsTableStart(IReadOnlyList<string> lines, int index)
|
||||
{
|
||||
return index + 1 < lines.Count &&
|
||||
@@ -215,33 +483,41 @@ internal static class WorkflowRulesEditorMarkdown
|
||||
private static void AddInline(
|
||||
List<WorkflowRulesEditorMarkdownInline> result,
|
||||
string text,
|
||||
WorkflowRulesEditorMarkdownInlineStyle style)
|
||||
WorkflowRulesEditorMarkdownInlineStyle style,
|
||||
string? linkTarget = null)
|
||||
{
|
||||
if (text.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.Count > 0 && result[^1].Style == style)
|
||||
if (result.Count > 0 && result[^1].Style == style && result[^1].LinkTarget == linkTarget)
|
||||
{
|
||||
result[^1] = result[^1] with { Text = result[^1].Text + text };
|
||||
return;
|
||||
}
|
||||
|
||||
result.Add(new WorkflowRulesEditorMarkdownInline(text, style));
|
||||
result.Add(new WorkflowRulesEditorMarkdownInline(text, style, linkTarget));
|
||||
}
|
||||
|
||||
private static int FindNextMarker(string text, int start)
|
||||
{
|
||||
var code = text.IndexOf('`', start);
|
||||
var image = text.IndexOf("![", start, StringComparison.Ordinal);
|
||||
var link = text.IndexOf('[', start);
|
||||
var autolink = text.IndexOf('<', start);
|
||||
var boldItalic = text.IndexOf("***", start, StringComparison.Ordinal);
|
||||
var strikethrough = text.IndexOf("~~", start, StringComparison.Ordinal);
|
||||
var bold = text.IndexOf("**", start, StringComparison.Ordinal);
|
||||
var italic = text.IndexOf('*', start);
|
||||
if (italic >= 0 && italic + 1 < text.Length && text[italic + 1] == '*')
|
||||
while (italic >= 0 &&
|
||||
italic + 1 < text.Length &&
|
||||
text[italic + 1] == '*')
|
||||
{
|
||||
italic = text.IndexOf('*', italic + 2);
|
||||
}
|
||||
|
||||
return new[] { code, bold, italic }
|
||||
return new[] { code, image, link, autolink, boldItalic, strikethrough, bold, italic }
|
||||
.Where(index => index >= 0)
|
||||
.DefaultIfEmpty(-1)
|
||||
.Min();
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
internal sealed class WorkflowRulesEditorMarkdownLinkResolver
|
||||
{
|
||||
private static readonly string[] ImageUriSchemes = ["http", "https", "file"];
|
||||
private readonly IReadOnlyList<string> rootPaths;
|
||||
private readonly IReadOnlyList<string> recursiveRootPaths;
|
||||
private readonly Dictionary<string, string> resolvedFiles = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public WorkflowRulesEditorMarkdownLinkResolver(
|
||||
IEnumerable<string> rootPaths,
|
||||
IEnumerable<string>? recursiveRootPaths = null)
|
||||
{
|
||||
this.rootPaths = rootPaths
|
||||
.Where(path => !string.IsNullOrWhiteSpace(path))
|
||||
.Select(path => Path.GetFullPath(path))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
this.recursiveRootPaths = (recursiveRootPaths ?? [])
|
||||
.Where(path => !string.IsNullOrWhiteSpace(path))
|
||||
.Select(path => Path.GetFullPath(path))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public static WorkflowRulesEditorMarkdownLinkResolver Empty { get; } = new([]);
|
||||
|
||||
public static WorkflowRulesEditorMarkdownLinkResolver FromOptions(MeetingAssistantOptions options)
|
||||
{
|
||||
var artifactRoots = new[]
|
||||
{
|
||||
VaultPath.Resolve(options.Vault, options.Vault.AssistantContextFolder),
|
||||
VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder),
|
||||
VaultPath.Resolve(options.Vault, options.Vault.SummariesFolder),
|
||||
VaultPath.Resolve(options.Vault, options.Vault.TranscriptsFolder),
|
||||
VaultPath.Resolve(options.Vault, options.Vault.ProjectsFolder)
|
||||
};
|
||||
|
||||
return new WorkflowRulesEditorMarkdownLinkResolver([
|
||||
..artifactRoots,
|
||||
Environment.CurrentDirectory,
|
||||
AppContext.BaseDirectory
|
||||
], artifactRoots);
|
||||
}
|
||||
|
||||
public bool TryResolveImageUri(string? target, out Uri uri)
|
||||
{
|
||||
uri = null!;
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Uri.TryCreate(target, UriKind.Absolute, out uri!) &&
|
||||
ImageUriSchemes.Contains(uri.Scheme, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryResolveExistingFilePath(target, out var path))
|
||||
{
|
||||
uri = new Uri(path);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public string ResolveOpenTarget(string? target)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
if (Uri.TryCreate(target, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return uri.Scheme.Equals("file", StringComparison.OrdinalIgnoreCase)
|
||||
? uri.LocalPath
|
||||
: uri.ToString();
|
||||
}
|
||||
|
||||
return TryResolveExistingFilePath(target, out var path)
|
||||
? path
|
||||
: target;
|
||||
}
|
||||
|
||||
private bool TryResolveExistingFilePath(string target, out string path)
|
||||
{
|
||||
path = "";
|
||||
if (Path.IsPathRooted(target) && File.Exists(target))
|
||||
{
|
||||
path = Path.GetFullPath(target);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (resolvedFiles.TryGetValue(target, out var cachedPath))
|
||||
{
|
||||
path = cachedPath;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryResolveDirectRelativePath(target, out path) ||
|
||||
TryFindRelativePathInRoots(target, out path))
|
||||
{
|
||||
resolvedFiles[target] = path;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryResolveDirectRelativePath(string target, out string path)
|
||||
{
|
||||
foreach (var root in rootPaths)
|
||||
{
|
||||
try
|
||||
{
|
||||
var candidate = Path.GetFullPath(target, root);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
path = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
path = "";
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryFindRelativePathInRoots(string target, out string path)
|
||||
{
|
||||
path = "";
|
||||
var normalizedSuffix = NormalizeRelativePath(target);
|
||||
if (string.IsNullOrWhiteSpace(normalizedSuffix))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileName(normalizedSuffix);
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var root in recursiveRootPaths.Where(Directory.Exists))
|
||||
{
|
||||
try
|
||||
{
|
||||
var match = Directory
|
||||
.EnumerateFiles(root, fileName, SearchOption.AllDirectories)
|
||||
.Where(candidate => NormalizeRelativePath(Path.GetRelativePath(root, candidate))
|
||||
.EndsWith(normalizedSuffix, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(File.GetLastWriteTimeUtc)
|
||||
.FirstOrDefault();
|
||||
if (match is not null)
|
||||
{
|
||||
path = match;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string NormalizeRelativePath(string path)
|
||||
{
|
||||
return path
|
||||
.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar)
|
||||
.TrimStart(Path.DirectorySeparatorChar);
|
||||
}
|
||||
}
|
||||
@@ -1,128 +1,150 @@
|
||||
#if WINDOWS
|
||||
using Aprillz.MewUI;
|
||||
using Aprillz.MewUI.Controls;
|
||||
using System.Diagnostics;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Documents;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using static MeetingAssistant.Workflow.WorkflowRulesEditorWpfTheme;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
internal static class WorkflowRulesEditorMarkdownRenderer
|
||||
{
|
||||
public static UIElement CreateContent(string content)
|
||||
public static UIElement CreateContent(
|
||||
string content,
|
||||
WorkflowRulesEditorMarkdownLinkResolver? linkResolver = null)
|
||||
{
|
||||
var panel = new StackPanel()
|
||||
.Vertical()
|
||||
.Spacing(7);
|
||||
foreach (var block in WorkflowRulesEditorMarkdown.Parse(content))
|
||||
linkResolver ??= WorkflowRulesEditorMarkdownLinkResolver.Empty;
|
||||
var panel = new StackPanel
|
||||
{
|
||||
panel.Add(block switch
|
||||
Orientation = Orientation.Vertical
|
||||
};
|
||||
var blocks = WorkflowRulesEditorMarkdown.Parse(content);
|
||||
for (var index = 0; index < blocks.Count; index++)
|
||||
{
|
||||
var element = blocks[index] switch
|
||||
{
|
||||
WorkflowRulesEditorMarkdownHeading heading => CreateHeading(heading, linkResolver),
|
||||
WorkflowRulesEditorMarkdownBlockQuote quote => CreateBlockQuote(quote, linkResolver),
|
||||
WorkflowRulesEditorMarkdownImage image => CreateImageBlock(image, linkResolver),
|
||||
WorkflowRulesEditorMarkdownTaskList tasks => CreateTaskList(tasks, linkResolver),
|
||||
WorkflowRulesEditorMarkdownCodeBlock codeBlock => CreateCodeBlock(codeBlock),
|
||||
WorkflowRulesEditorMarkdownTable table => CreateTable(table),
|
||||
WorkflowRulesEditorMarkdownParagraph paragraph => CreateParagraph(paragraph),
|
||||
WorkflowRulesEditorMarkdownParagraph paragraph => CreateParagraph(paragraph, linkResolver),
|
||||
_ => new TextBlock()
|
||||
});
|
||||
};
|
||||
if (index < blocks.Count - 1)
|
||||
{
|
||||
element.SetValue(FrameworkElement.MarginProperty, new Thickness(0, 0, 0, 7));
|
||||
}
|
||||
|
||||
panel.Children.Add(element);
|
||||
}
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
private static UIElement CreateParagraph(WorkflowRulesEditorMarkdownParagraph paragraph)
|
||||
private static UIElement CreateHeading(
|
||||
WorkflowRulesEditorMarkdownHeading heading,
|
||||
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
|
||||
{
|
||||
var lines = SplitInlineLines(paragraph.Inlines);
|
||||
if (lines.Count == 1)
|
||||
{
|
||||
return CreateParagraphLine(lines[0]);
|
||||
}
|
||||
var paragraph = CreateInlineParagraph(heading.Inlines, linkResolver, heading.Level == 1 ? 22 : 20);
|
||||
paragraph.FontWeight = FontWeights.SemiBold;
|
||||
|
||||
var panel = new StackPanel()
|
||||
.Vertical()
|
||||
.Spacing(3);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
panel.Add(CreateParagraphLine(line));
|
||||
}
|
||||
|
||||
return panel;
|
||||
return CreateSelectableDocument([paragraph], fontSize: heading.Level == 1 ? 16.5 : 15.5);
|
||||
}
|
||||
|
||||
private static UIElement CreateParagraphLine(IReadOnlyList<WorkflowRulesEditorMarkdownInline> inlines)
|
||||
private static UIElement CreateBlockQuote(
|
||||
WorkflowRulesEditorMarkdownBlockQuote quote,
|
||||
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
|
||||
{
|
||||
var linePanel = new WrapPanel()
|
||||
.Spacing(1);
|
||||
foreach (var inline in inlines)
|
||||
var paragraph = CreateInlineParagraph(quote.Inlines, linkResolver);
|
||||
|
||||
return new Border
|
||||
{
|
||||
foreach (var element in CreateInlineElements(inline))
|
||||
Padding = new Thickness(10, 7, 10, 7),
|
||||
BorderThickness = new Thickness(3, 0, 0, 0),
|
||||
BorderBrush = QuoteBorder,
|
||||
Background = QuoteBackground,
|
||||
Child = CreateSelectableDocument([paragraph])
|
||||
};
|
||||
}
|
||||
|
||||
private static UIElement CreateParagraph(
|
||||
WorkflowRulesEditorMarkdownParagraph paragraph,
|
||||
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
|
||||
{
|
||||
var flowParagraph = CreateInlineParagraph(paragraph.Inlines, linkResolver);
|
||||
|
||||
return CreateSelectableDocument([flowParagraph]);
|
||||
}
|
||||
|
||||
private static UIElement CreateImageBlock(
|
||||
WorkflowRulesEditorMarkdownImage image,
|
||||
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
|
||||
{
|
||||
return CreateImagePreview(image.AltText, image.Target, linkResolver);
|
||||
}
|
||||
|
||||
private static UIElement CreateTaskList(
|
||||
WorkflowRulesEditorMarkdownTaskList tasks,
|
||||
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
|
||||
{
|
||||
var grid = new Grid();
|
||||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(24) });
|
||||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||
|
||||
for (var row = 0; row < tasks.Items.Count; row++)
|
||||
{
|
||||
var item = tasks.Items[row];
|
||||
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
|
||||
|
||||
var glyph = new TextBlock
|
||||
{
|
||||
linePanel.Add(element);
|
||||
}
|
||||
Text = item.IsChecked ? "☑" : "☐",
|
||||
FontFamily = new FontFamily("Segoe UI Symbol"),
|
||||
FontSize = 17,
|
||||
Foreground = PrimaryText,
|
||||
LineHeight = 19,
|
||||
Margin = new Thickness(0, 0, 6, 3),
|
||||
VerticalAlignment = VerticalAlignment.Top
|
||||
};
|
||||
Grid.SetRow(glyph, row);
|
||||
Grid.SetColumn(glyph, 0);
|
||||
grid.Children.Add(glyph);
|
||||
|
||||
var paragraph = CreateInlineParagraph(item.Inlines, linkResolver);
|
||||
|
||||
var text = CreateSelectableDocument([paragraph]);
|
||||
text.Margin = new Thickness(0, 0, 0, 3);
|
||||
Grid.SetRow(text, row);
|
||||
Grid.SetColumn(text, 1);
|
||||
grid.Children.Add(text);
|
||||
}
|
||||
|
||||
return linePanel;
|
||||
return grid;
|
||||
}
|
||||
|
||||
private static IEnumerable<UIElement> CreateInlineElements(WorkflowRulesEditorMarkdownInline inline)
|
||||
private static UIElement CreateCodeBlock(WorkflowRulesEditorMarkdownCodeBlock codeBlock)
|
||||
{
|
||||
if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Code)
|
||||
var run = new Run(codeBlock.Text)
|
||||
{
|
||||
yield return CreateInlineCode(inline.Text);
|
||||
yield break;
|
||||
}
|
||||
|
||||
var text = new TextBlock()
|
||||
.Text(inline.Text)
|
||||
.TextWrapping(TextWrapping.Wrap)
|
||||
.Foreground(Color.FromRgb(230, 235, 243));
|
||||
if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Bold)
|
||||
FontFamily = new FontFamily("Consolas")
|
||||
};
|
||||
var paragraph = CreateParagraphBlock();
|
||||
paragraph.Inlines.Add(run);
|
||||
var document = CreateSelectableDocument([paragraph], CodeText);
|
||||
return new Border
|
||||
{
|
||||
text.FontWeight(FontWeight.Bold);
|
||||
}
|
||||
else if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Italic)
|
||||
{
|
||||
text.FontFamily("Segoe UI Italic");
|
||||
}
|
||||
|
||||
yield return text;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<IReadOnlyList<WorkflowRulesEditorMarkdownInline>> SplitInlineLines(
|
||||
IReadOnlyList<WorkflowRulesEditorMarkdownInline> inlines)
|
||||
{
|
||||
var lines = new List<IReadOnlyList<WorkflowRulesEditorMarkdownInline>>();
|
||||
var current = new List<WorkflowRulesEditorMarkdownInline>();
|
||||
foreach (var inline in inlines)
|
||||
{
|
||||
var parts = inline.Text.Split('\n');
|
||||
for (var index = 0; index < parts.Length; index++)
|
||||
{
|
||||
if (parts[index].Length > 0)
|
||||
{
|
||||
current.Add(inline with { Text = parts[index] });
|
||||
}
|
||||
|
||||
if (index < parts.Length - 1)
|
||||
{
|
||||
lines.Add(current);
|
||||
current = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.Add(current);
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static UIElement CreateInlineCode(string text)
|
||||
{
|
||||
return new Border()
|
||||
.Padding(4, 1, 4, 2)
|
||||
.Margin(1, 0, 1, 0)
|
||||
.CornerRadius(4)
|
||||
.BorderThickness(1)
|
||||
.BorderBrush(Color.FromRgb(86, 94, 108))
|
||||
.Background(Color.FromRgb(24, 28, 35))
|
||||
.Child(new TextBlock()
|
||||
.Text(text)
|
||||
.FontFamily("Consolas")
|
||||
.Foreground(Color.FromRgb(237, 241, 247)));
|
||||
Padding = new Thickness(9, 7, 9, 7),
|
||||
CornerRadius = new CornerRadius(6),
|
||||
BorderThickness = new Thickness(1),
|
||||
BorderBrush = CodeBorder,
|
||||
Background = CodeBackground,
|
||||
Child = document
|
||||
};
|
||||
}
|
||||
|
||||
private static UIElement CreateTable(WorkflowRulesEditorMarkdownTable table)
|
||||
@@ -130,46 +152,330 @@ internal static class WorkflowRulesEditorMarkdownRenderer
|
||||
var rows = table.Rows
|
||||
.Select(row => new MarkdownTableRow(PadCells(row, table.Header.Count)))
|
||||
.ToArray();
|
||||
var gridView = new GridView()
|
||||
.HeaderHeight(30)
|
||||
.RowHeight(30)
|
||||
.CellPadding(new Thickness(8, 4, 8, 4))
|
||||
.ShowGridLines(true)
|
||||
.ZebraStriping(true);
|
||||
|
||||
for (var index = 0; index < table.Header.Count; index++)
|
||||
var grid = new Grid();
|
||||
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
|
||||
for (var column = 0; column < table.Header.Count; column++)
|
||||
{
|
||||
var columnIndex = index;
|
||||
gridView.AddColumn<MarkdownTableRow>(
|
||||
table.Header[index],
|
||||
GetMarkdownTableColumnWidth(table, index),
|
||||
_ => new TextBlock()
|
||||
.TextWrapping(TextWrapping.NoWrap)
|
||||
.Foreground(Color.FromRgb(230, 235, 243)),
|
||||
(element, row, _, _) => ((TextBlock)element).Text(row.GetCell(columnIndex)),
|
||||
minWidth: 64,
|
||||
resizable: true);
|
||||
grid.ColumnDefinitions.Add(new ColumnDefinition
|
||||
{
|
||||
Width = new GridLength(GetMarkdownTableColumnWidth(table, column))
|
||||
});
|
||||
AddTableCell(grid, 0, column, table.Header[column], TableHeaderBackground, FontWeights.SemiBold);
|
||||
}
|
||||
|
||||
return gridView
|
||||
.ItemsSource(rows)
|
||||
.MinWidth(Math.Min(900, table.Header.Count * 120))
|
||||
.Height(Math.Min(420, 30 + Math.Max(1, rows.Length) * 30));
|
||||
for (var row = 0; row < rows.Length; row++)
|
||||
{
|
||||
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
|
||||
for (var column = 0; column < table.Header.Count; column++)
|
||||
{
|
||||
AddTableCell(grid, row + 1, column, rows[row].GetCell(column), TableCellBackground, FontWeights.Normal);
|
||||
}
|
||||
}
|
||||
|
||||
return new ScrollViewer
|
||||
{
|
||||
Content = grid,
|
||||
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
|
||||
VerticalScrollBarVisibility = ScrollBarVisibility.Disabled,
|
||||
MaxHeight = 420
|
||||
};
|
||||
}
|
||||
|
||||
private static UIElement CreateCodeBlock(WorkflowRulesEditorMarkdownCodeBlock codeBlock)
|
||||
private static RichTextBox CreateSelectableDocument(
|
||||
IEnumerable<Block> blocks,
|
||||
Brush? foreground = null,
|
||||
double fontSize = 14)
|
||||
{
|
||||
return new Border()
|
||||
.Padding(9)
|
||||
.CornerRadius(6)
|
||||
.BorderThickness(1)
|
||||
.BorderBrush(Color.FromRgb(86, 94, 108))
|
||||
.Background(Color.FromRgb(20, 24, 31))
|
||||
.Child(new TextBlock()
|
||||
.Text(codeBlock.Text)
|
||||
.TextWrapping(TextWrapping.Wrap)
|
||||
.FontFamily("Consolas")
|
||||
.Foreground(Color.FromRgb(237, 241, 247)));
|
||||
var document = new FlowDocument
|
||||
{
|
||||
PagePadding = new Thickness(0),
|
||||
FontFamily = new FontFamily("Segoe UI"),
|
||||
FontSize = fontSize,
|
||||
Foreground = foreground ?? PrimaryText
|
||||
};
|
||||
foreach (var block in blocks)
|
||||
{
|
||||
document.Blocks.Add(block);
|
||||
}
|
||||
|
||||
return new RichTextBox
|
||||
{
|
||||
Document = document,
|
||||
IsReadOnly = true,
|
||||
IsDocumentEnabled = true,
|
||||
BorderThickness = new Thickness(0),
|
||||
Background = Brushes.Transparent,
|
||||
Foreground = foreground ?? PrimaryText,
|
||||
Padding = new Thickness(0),
|
||||
Margin = new Thickness(0),
|
||||
MinHeight = 0,
|
||||
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
|
||||
VerticalScrollBarVisibility = ScrollBarVisibility.Disabled
|
||||
};
|
||||
}
|
||||
|
||||
private static Paragraph CreateParagraphBlock(double lineHeight = 19)
|
||||
{
|
||||
return new Paragraph
|
||||
{
|
||||
Margin = new Thickness(0),
|
||||
LineHeight = lineHeight
|
||||
};
|
||||
}
|
||||
|
||||
private static Paragraph CreateInlineParagraph(
|
||||
IReadOnlyList<WorkflowRulesEditorMarkdownInline> inlines,
|
||||
WorkflowRulesEditorMarkdownLinkResolver linkResolver,
|
||||
double lineHeight = 19)
|
||||
{
|
||||
var paragraph = CreateParagraphBlock(lineHeight);
|
||||
foreach (var inline in inlines)
|
||||
{
|
||||
paragraph.Inlines.Add(CreateInline(inline, linkResolver));
|
||||
}
|
||||
|
||||
return paragraph;
|
||||
}
|
||||
|
||||
private static Inline CreateInline(
|
||||
WorkflowRulesEditorMarkdownInline inline,
|
||||
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
|
||||
{
|
||||
if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Link)
|
||||
{
|
||||
return CreateLink(inline, linkResolver);
|
||||
}
|
||||
|
||||
if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Image)
|
||||
{
|
||||
return CreateImage(inline, linkResolver);
|
||||
}
|
||||
|
||||
if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Code)
|
||||
{
|
||||
return CreateInlineCode(inline.Text);
|
||||
}
|
||||
|
||||
var run = new Run(inline.Text);
|
||||
switch (inline.Style)
|
||||
{
|
||||
case WorkflowRulesEditorMarkdownInlineStyle.Bold:
|
||||
run.FontWeight = FontWeights.SemiBold;
|
||||
break;
|
||||
case WorkflowRulesEditorMarkdownInlineStyle.BoldItalic:
|
||||
run.FontWeight = FontWeights.SemiBold;
|
||||
run.FontStyle = FontStyles.Italic;
|
||||
break;
|
||||
case WorkflowRulesEditorMarkdownInlineStyle.Italic:
|
||||
run.FontStyle = FontStyles.Italic;
|
||||
break;
|
||||
case WorkflowRulesEditorMarkdownInlineStyle.Strikethrough:
|
||||
run.TextDecorations = TextDecorations.Strikethrough;
|
||||
break;
|
||||
}
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
private static Inline CreateLink(
|
||||
WorkflowRulesEditorMarkdownInline inline,
|
||||
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
|
||||
{
|
||||
var hyperlink = new Hyperlink(new Run(inline.Text))
|
||||
{
|
||||
Foreground = LinkText,
|
||||
TextDecorations = TextDecorations.Underline,
|
||||
ToolTip = inline.LinkTarget,
|
||||
Cursor = Cursors.Hand
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(inline.LinkTarget))
|
||||
{
|
||||
hyperlink.Click += (_, args) =>
|
||||
{
|
||||
OpenTarget(inline.LinkTarget, linkResolver);
|
||||
args.Handled = true;
|
||||
};
|
||||
}
|
||||
|
||||
return hyperlink;
|
||||
}
|
||||
|
||||
private static Inline CreateInlineCode(string text)
|
||||
{
|
||||
var code = new TextBlock
|
||||
{
|
||||
Text = text,
|
||||
FontFamily = new FontFamily("Consolas"),
|
||||
FontSize = 13,
|
||||
Foreground = CodeText,
|
||||
Background = CodeBackground,
|
||||
Padding = new Thickness(0),
|
||||
Margin = new Thickness(0),
|
||||
LineHeight = 16,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
|
||||
return new InlineUIContainer(new Border
|
||||
{
|
||||
BorderThickness = new Thickness(1),
|
||||
BorderBrush = CodeBorder,
|
||||
CornerRadius = new CornerRadius(4),
|
||||
Background = CodeBackground,
|
||||
Padding = new Thickness(4, 1, 4, 1),
|
||||
Margin = new Thickness(1, 0, 1, 0),
|
||||
Child = code
|
||||
})
|
||||
{
|
||||
BaselineAlignment = BaselineAlignment.Center
|
||||
};
|
||||
}
|
||||
|
||||
private static Inline CreateImage(
|
||||
WorkflowRulesEditorMarkdownInline inline,
|
||||
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
|
||||
{
|
||||
var preview = CreateImagePreview(inline.Text, inline.LinkTarget, linkResolver);
|
||||
return new InlineUIContainer(preview)
|
||||
{
|
||||
BaselineAlignment = BaselineAlignment.Center
|
||||
};
|
||||
}
|
||||
|
||||
private static UIElement CreateImagePreview(
|
||||
string altText,
|
||||
string? target,
|
||||
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
|
||||
{
|
||||
var border = new Border
|
||||
{
|
||||
Width = 180,
|
||||
Height = 112,
|
||||
Margin = new Thickness(0, 4, 0, 4),
|
||||
Padding = new Thickness(6),
|
||||
BorderThickness = new Thickness(1),
|
||||
BorderBrush = CodeBorder,
|
||||
CornerRadius = new CornerRadius(6),
|
||||
Background = ImagePlaceholderBackground,
|
||||
ToolTip = target,
|
||||
Cursor = Cursors.Hand
|
||||
};
|
||||
border.AddHandler(UIElement.MouseLeftButtonUpEvent, new MouseButtonEventHandler((_, args) =>
|
||||
{
|
||||
OpenTarget(target, linkResolver);
|
||||
args.Handled = true;
|
||||
}), handledEventsToo: true);
|
||||
|
||||
if (TryCreateBitmap(target, linkResolver, out var bitmap))
|
||||
{
|
||||
var image = new Image
|
||||
{
|
||||
Source = bitmap,
|
||||
Stretch = Stretch.Uniform,
|
||||
ToolTip = target
|
||||
};
|
||||
image.ImageFailed += (_, _) => border.Child = CreateImagePlaceholder(altText);
|
||||
border.Child = image;
|
||||
}
|
||||
else
|
||||
{
|
||||
border.Child = CreateImagePlaceholder(altText);
|
||||
}
|
||||
|
||||
return border;
|
||||
}
|
||||
|
||||
private static UIElement CreateImagePlaceholder(string altText)
|
||||
{
|
||||
return new TextBlock
|
||||
{
|
||||
Text = string.IsNullOrWhiteSpace(altText) ? "Image" : altText,
|
||||
Foreground = PrimaryText,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
HorizontalAlignment = HorizontalAlignment.Center
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryCreateBitmap(
|
||||
string? target,
|
||||
WorkflowRulesEditorMarkdownLinkResolver linkResolver,
|
||||
out BitmapImage bitmap)
|
||||
{
|
||||
bitmap = null!;
|
||||
if (string.IsNullOrWhiteSpace(target) || !linkResolver.TryResolveImageUri(target, out var uri))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
bitmap = new BitmapImage();
|
||||
bitmap.BeginInit();
|
||||
bitmap.CacheOption = BitmapCacheOption.OnLoad;
|
||||
bitmap.UriSource = uri;
|
||||
bitmap.DecodePixelWidth = 360;
|
||||
bitmap.EndInit();
|
||||
bitmap.Freeze();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OpenTarget(
|
||||
string? target,
|
||||
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var openTarget = linkResolver.ResolveOpenTarget(target);
|
||||
if (string.IsNullOrWhiteSpace(openTarget))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Process.Start(new ProcessStartInfo(openTarget)
|
||||
{
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddTableCell(
|
||||
Grid grid,
|
||||
int row,
|
||||
int column,
|
||||
string text,
|
||||
Brush background,
|
||||
FontWeight fontWeight)
|
||||
{
|
||||
var paragraph = CreateParagraphBlock();
|
||||
paragraph.Inlines.Add(new Run(text) { FontWeight = fontWeight });
|
||||
var document = CreateSelectableDocument([paragraph], PrimaryText);
|
||||
document.HorizontalScrollBarVisibility = ScrollBarVisibility.Hidden;
|
||||
|
||||
var border = new Border
|
||||
{
|
||||
Padding = new Thickness(8, 4, 8, 4),
|
||||
BorderThickness = new Thickness(0, 0, 1, 1),
|
||||
BorderBrush = CodeBorder,
|
||||
Background = background,
|
||||
Child = document
|
||||
};
|
||||
Grid.SetRow(border, row);
|
||||
Grid.SetColumn(border, column);
|
||||
grid.Children.Add(border);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> PadCells(IReadOnlyList<string> cells, int count)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
#if WINDOWS
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
internal static class WorkflowRulesEditorWpfTheme
|
||||
{
|
||||
public static readonly Brush WindowBackground = Brush(29, 31, 36);
|
||||
public static readonly Brush UserCardBackground = Brush(26, 48, 78);
|
||||
public static readonly Brush AgentCardBackground = Brush(35, 39, 46);
|
||||
public static readonly Brush UserCardBorder = Brush(68, 95, 132);
|
||||
public static readonly Brush AgentCardBorder = Brush(69, 75, 86);
|
||||
public static readonly Brush PrimaryText = Brush(230, 235, 243);
|
||||
public static readonly Brush MutedText = Brush(156, 166, 181);
|
||||
public static readonly Brush InputBackground = Brush(20, 22, 26);
|
||||
public static readonly Brush InputBorder = Brush(68, 95, 132);
|
||||
public static readonly Brush InputFocusedBorder = Brush(84, 135, 214);
|
||||
public static readonly Brush ButtonBackground = Brush(42, 46, 54);
|
||||
public static readonly Brush ButtonHoverBackground = Brush(50, 56, 68);
|
||||
public static readonly Brush ButtonPressedBackground = Brush(31, 36, 45);
|
||||
public static readonly Brush DisabledButtonBackground = Brush(32, 35, 41);
|
||||
public static readonly Brush DisabledButtonBorder = Brush(52, 57, 66);
|
||||
public static readonly Brush DisabledText = Brush(115, 124, 139);
|
||||
public static readonly Brush CodeText = Brush(237, 241, 247);
|
||||
public static readonly Brush CodeBackground = Brush(20, 24, 31);
|
||||
public static readonly Brush CodeBorder = Brush(86, 94, 108);
|
||||
public static readonly Brush TableHeaderBackground = Brush(42, 46, 54);
|
||||
public static readonly Brush TableCellBackground = Brush(31, 35, 42);
|
||||
public static readonly Brush LinkText = Brush(119, 166, 255);
|
||||
public static readonly Brush QuoteBorder = Brush(92, 111, 140);
|
||||
public static readonly Brush QuoteBackground = Brush(31, 35, 43);
|
||||
public static readonly Brush ImagePlaceholderBackground = Brush(24, 28, 35);
|
||||
|
||||
private static SolidColorBrush Brush(byte red, byte green, byte blue)
|
||||
{
|
||||
var brush = new SolidColorBrush(Color.FromRgb(red, green, blue));
|
||||
brush.Freeze();
|
||||
return brush;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,404 @@
|
||||
#if WINDOWS
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using static MeetingAssistant.Workflow.WorkflowRulesEditorWpfTheme;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
internal sealed class WpfWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService
|
||||
{
|
||||
internal const string WindowTitle = "Settings and logs";
|
||||
|
||||
private readonly IServiceProvider services;
|
||||
private readonly WorkflowRulesEditorMarkdownLinkResolver linkResolver;
|
||||
private readonly ILogger<WpfWorkflowRulesEditorWindowService> logger;
|
||||
private readonly object sync = new();
|
||||
private Dispatcher? dispatcher;
|
||||
private WorkflowRulesEditorWpfWindow? window;
|
||||
private bool isStarted;
|
||||
|
||||
public WpfWorkflowRulesEditorWindowService(
|
||||
IServiceProvider services,
|
||||
WorkflowRulesEditorMarkdownLinkResolver linkResolver,
|
||||
ILogger<WpfWorkflowRulesEditorWindowService> logger)
|
||||
{
|
||||
this.services = services;
|
||||
this.linkResolver = linkResolver;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public void Show()
|
||||
{
|
||||
EnsureUiThread();
|
||||
dispatcher?.BeginInvoke(ShowOnUiThread);
|
||||
}
|
||||
|
||||
private void EnsureUiThread()
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
if (isStarted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
isStarted = true;
|
||||
var ready = new ManualResetEventSlim();
|
||||
var thread = new Thread(() => RunApplication(ready))
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "Meeting Assistant Settings and Logs"
|
||||
};
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
|
||||
if (!ready.Wait(TimeSpan.FromSeconds(10)))
|
||||
{
|
||||
isStarted = false;
|
||||
throw new TimeoutException("Settings and logs UI thread did not start.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RunApplication(ManualResetEventSlim ready)
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.LogInformation("Settings and logs WPF thread started");
|
||||
var application = new Application
|
||||
{
|
||||
ShutdownMode = ShutdownMode.OnExplicitShutdown
|
||||
};
|
||||
dispatcher = application.Dispatcher;
|
||||
ready.Set();
|
||||
application.Run();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Settings and logs WPF thread failed");
|
||||
ready.Set();
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
dispatcher = null;
|
||||
window = null;
|
||||
isStarted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowOnUiThread()
|
||||
{
|
||||
try
|
||||
{
|
||||
window ??= new WorkflowRulesEditorWpfWindow(
|
||||
services.GetRequiredService<WorkflowRulesEditorChatViewModel>(),
|
||||
linkResolver,
|
||||
() => window = null);
|
||||
window.Show();
|
||||
if (window.WindowState == WindowState.Minimized)
|
||||
{
|
||||
window.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
window.Activate();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Settings and logs WPF window failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class WorkflowRulesEditorWpfWindow : Window
|
||||
{
|
||||
private readonly WorkflowRulesEditorChatViewModel viewModel;
|
||||
private readonly WorkflowRulesEditorMarkdownLinkResolver linkResolver;
|
||||
private readonly Action closed;
|
||||
private readonly StackPanel conversationPanel = new() { Orientation = Orientation.Vertical };
|
||||
private readonly ScrollViewer conversationScroll = new();
|
||||
private readonly Border inputFrame = new();
|
||||
private readonly TextBox input = new();
|
||||
private readonly TextBlock inputPlaceholder = new();
|
||||
private readonly Button sendButton = new();
|
||||
private readonly EventHandler changedHandler;
|
||||
|
||||
public WorkflowRulesEditorWpfWindow(
|
||||
WorkflowRulesEditorChatViewModel viewModel,
|
||||
WorkflowRulesEditorMarkdownLinkResolver linkResolver,
|
||||
Action closed)
|
||||
{
|
||||
this.viewModel = viewModel;
|
||||
this.linkResolver = linkResolver;
|
||||
this.closed = closed;
|
||||
changedHandler = (_, _) => RequestRender();
|
||||
this.viewModel.Changed += changedHandler;
|
||||
|
||||
Title = WpfWorkflowRulesEditorWindowService.WindowTitle;
|
||||
Width = 840;
|
||||
Height = 760;
|
||||
MinWidth = 520;
|
||||
MinHeight = 460;
|
||||
Background = WindowBackground;
|
||||
Content = CreateLayout();
|
||||
|
||||
var iconPath = Path.Combine(AppContext.BaseDirectory, "Assets", "meeting-assistant.ico");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
Icon = System.Windows.Media.Imaging.BitmapFrame.Create(new Uri(iconPath));
|
||||
}
|
||||
|
||||
Closed += (_, _) =>
|
||||
{
|
||||
viewModel.Changed -= changedHandler;
|
||||
closed();
|
||||
};
|
||||
|
||||
ConfigureInput();
|
||||
Render();
|
||||
}
|
||||
|
||||
private UIElement CreateLayout()
|
||||
{
|
||||
var root = new Grid
|
||||
{
|
||||
Margin = new Thickness(12)
|
||||
};
|
||||
root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
|
||||
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
|
||||
|
||||
conversationScroll.Content = conversationPanel;
|
||||
conversationScroll.VerticalScrollBarVisibility = ScrollBarVisibility.Auto;
|
||||
conversationScroll.HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled;
|
||||
conversationScroll.Padding = new Thickness(0, 0, 4, 0);
|
||||
Grid.SetRow(conversationScroll, 0);
|
||||
root.Children.Add(conversationScroll);
|
||||
|
||||
var inputRow = new Grid { Margin = new Thickness(0, 10, 0, 0) };
|
||||
inputRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||
inputRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
Grid.SetRow(inputRow, 1);
|
||||
|
||||
Grid.SetColumn(inputFrame, 0);
|
||||
inputRow.Children.Add(inputFrame);
|
||||
Grid.SetColumn(sendButton, 1);
|
||||
inputRow.Children.Add(sendButton);
|
||||
root.Children.Add(inputRow);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
private void ConfigureInput()
|
||||
{
|
||||
input.AcceptsReturn = true;
|
||||
input.TextWrapping = TextWrapping.Wrap;
|
||||
input.MinHeight = 86;
|
||||
input.MaxHeight = 124;
|
||||
input.Padding = new Thickness(12, 9, 12, 9);
|
||||
input.Background = Brushes.Transparent;
|
||||
input.Foreground = PrimaryText;
|
||||
input.BorderThickness = new Thickness(0);
|
||||
input.CaretBrush = PrimaryText;
|
||||
input.FontSize = 14;
|
||||
input.VerticalScrollBarVisibility = ScrollBarVisibility.Auto;
|
||||
input.TextChanged += (_, _) =>
|
||||
{
|
||||
viewModel.Draft = input.Text;
|
||||
inputPlaceholder.Visibility = string.IsNullOrEmpty(input.Text)
|
||||
? Visibility.Visible
|
||||
: Visibility.Collapsed;
|
||||
};
|
||||
input.PreviewKeyDown += OnInputPreviewKeyDown;
|
||||
input.GotKeyboardFocus += (_, _) => inputFrame.BorderBrush = InputFocusedBorder;
|
||||
input.LostKeyboardFocus += (_, _) => inputFrame.BorderBrush = InputBorder;
|
||||
|
||||
inputPlaceholder.Text = "ask me what I can do for you";
|
||||
inputPlaceholder.Foreground = MutedText;
|
||||
inputPlaceholder.Margin = new Thickness(13, 9, 13, 9);
|
||||
inputPlaceholder.IsHitTestVisible = false;
|
||||
|
||||
var inputGrid = new Grid();
|
||||
inputGrid.Children.Add(input);
|
||||
inputGrid.Children.Add(inputPlaceholder);
|
||||
inputFrame.Child = inputGrid;
|
||||
inputFrame.Margin = new Thickness(0, 0, 10, 0);
|
||||
inputFrame.Background = InputBackground;
|
||||
inputFrame.BorderBrush = InputBorder;
|
||||
inputFrame.BorderThickness = new Thickness(1);
|
||||
inputFrame.CornerRadius = new CornerRadius(7);
|
||||
|
||||
sendButton.Content = "Send";
|
||||
sendButton.Width = 104;
|
||||
sendButton.MinHeight = 86;
|
||||
sendButton.Background = ButtonBackground;
|
||||
sendButton.Foreground = PrimaryText;
|
||||
sendButton.BorderBrush = InputBorder;
|
||||
sendButton.BorderThickness = new Thickness(1);
|
||||
sendButton.FontWeight = FontWeights.SemiBold;
|
||||
sendButton.Cursor = Cursors.Hand;
|
||||
sendButton.Style = CreateSendButtonStyle();
|
||||
sendButton.Click += async (_, _) => await SendAsync();
|
||||
}
|
||||
|
||||
private async void OnInputPreviewKeyDown(object sender, KeyEventArgs args)
|
||||
{
|
||||
if (IsEnterKey(args) && Keyboard.Modifiers.HasFlag(ModifierKeys.Shift) is false)
|
||||
{
|
||||
args.Handled = true;
|
||||
await SendAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsEnterKey(KeyEventArgs args)
|
||||
{
|
||||
return args.Key is Key.Enter or Key.Return ||
|
||||
args.SystemKey is Key.Enter or Key.Return ||
|
||||
args.ImeProcessedKey is Key.Enter or Key.Return;
|
||||
}
|
||||
|
||||
private async Task SendAsync()
|
||||
{
|
||||
var sendTask = viewModel.SendAsync();
|
||||
input.Text = viewModel.Draft;
|
||||
await sendTask;
|
||||
}
|
||||
|
||||
private void RequestRender()
|
||||
{
|
||||
if (Dispatcher.CheckAccess())
|
||||
{
|
||||
Render();
|
||||
return;
|
||||
}
|
||||
|
||||
Dispatcher.BeginInvoke(Render);
|
||||
}
|
||||
|
||||
private void Render()
|
||||
{
|
||||
var shouldAutoScroll = WorkflowRulesEditorScrollPolicy.ShouldAutoScroll(
|
||||
conversationScroll.VerticalOffset,
|
||||
conversationScroll.ViewportHeight,
|
||||
conversationPanel.ActualHeight);
|
||||
|
||||
conversationPanel.Children.Clear();
|
||||
foreach (var message in viewModel.Messages)
|
||||
{
|
||||
conversationPanel.Children.Add(CreateMessageCard(message));
|
||||
}
|
||||
|
||||
if (viewModel.IsThinking)
|
||||
{
|
||||
conversationPanel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "Thinking...",
|
||||
Foreground = MutedText,
|
||||
Margin = new Thickness(4, 2, 4, 2)
|
||||
});
|
||||
}
|
||||
|
||||
sendButton.IsEnabled = !viewModel.IsThinking;
|
||||
sendButton.Cursor = sendButton.IsEnabled ? Cursors.Hand : Cursors.Arrow;
|
||||
if (shouldAutoScroll)
|
||||
{
|
||||
Dispatcher.BeginInvoke(DispatcherPriority.ContextIdle, ScrollConversationToBottom);
|
||||
Dispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, ScrollConversationToBottom);
|
||||
}
|
||||
}
|
||||
|
||||
private void ScrollConversationToBottom()
|
||||
{
|
||||
conversationScroll.ScrollToVerticalOffset(WorkflowRulesEditorScrollPolicy.GetBottomOffset(
|
||||
conversationScroll.ViewportHeight,
|
||||
conversationPanel.ActualHeight));
|
||||
}
|
||||
|
||||
private UIElement CreateMessageCard(WorkflowRulesEditorChatMessage message)
|
||||
{
|
||||
var isUser = message.Role == WorkflowRulesEditorChatRole.User;
|
||||
var cardContent = new StackPanel { Orientation = Orientation.Vertical };
|
||||
cardContent.Children.Add(WorkflowRulesEditorMarkdownRenderer.CreateContent(message.Content, linkResolver));
|
||||
|
||||
var card = new Border
|
||||
{
|
||||
Padding = new Thickness(12, 9, 12, 9),
|
||||
Margin = isUser ? new Thickness(42, 0, 4, 8) : new Thickness(0, 0, 42, 8),
|
||||
CornerRadius = new CornerRadius(8),
|
||||
BorderThickness = new Thickness(1),
|
||||
BorderBrush = isUser ? UserCardBorder : AgentCardBorder,
|
||||
Background = isUser ? UserCardBackground : AgentCardBackground,
|
||||
Child = cardContent
|
||||
};
|
||||
return card;
|
||||
}
|
||||
|
||||
private static Style CreateSendButtonStyle()
|
||||
{
|
||||
var style = new Style(typeof(Button));
|
||||
style.Setters.Add(new Setter(Control.PaddingProperty, new Thickness(12, 0, 12, 0)));
|
||||
style.Setters.Add(new Setter(Control.TemplateProperty, CreateSendButtonTemplate()));
|
||||
|
||||
style.Triggers.Add(new Trigger
|
||||
{
|
||||
Property = UIElement.IsMouseOverProperty,
|
||||
Value = true,
|
||||
Setters =
|
||||
{
|
||||
new Setter(Control.BackgroundProperty, ButtonHoverBackground)
|
||||
}
|
||||
});
|
||||
style.Triggers.Add(new Trigger
|
||||
{
|
||||
Property = ButtonBase.IsPressedProperty,
|
||||
Value = true,
|
||||
Setters =
|
||||
{
|
||||
new Setter(Control.BackgroundProperty, ButtonPressedBackground)
|
||||
}
|
||||
});
|
||||
style.Triggers.Add(new Trigger
|
||||
{
|
||||
Property = UIElement.IsEnabledProperty,
|
||||
Value = false,
|
||||
Setters =
|
||||
{
|
||||
new Setter(Control.BackgroundProperty, DisabledButtonBackground),
|
||||
new Setter(Control.BorderBrushProperty, DisabledButtonBorder),
|
||||
new Setter(Control.ForegroundProperty, DisabledText),
|
||||
new Setter(UIElement.OpacityProperty, 1.0)
|
||||
}
|
||||
});
|
||||
|
||||
return style;
|
||||
}
|
||||
|
||||
private static ControlTemplate CreateSendButtonTemplate()
|
||||
{
|
||||
var border = new FrameworkElementFactory(typeof(Border));
|
||||
border.SetValue(Border.CornerRadiusProperty, new CornerRadius(7));
|
||||
border.SetValue(Border.BackgroundProperty, new TemplateBindingExtension(Control.BackgroundProperty));
|
||||
border.SetValue(Border.BorderBrushProperty, new TemplateBindingExtension(Control.BorderBrushProperty));
|
||||
border.SetValue(Border.BorderThicknessProperty, new TemplateBindingExtension(Control.BorderThicknessProperty));
|
||||
|
||||
var content = new FrameworkElementFactory(typeof(TextBlock));
|
||||
content.SetValue(FrameworkElement.HorizontalAlignmentProperty, HorizontalAlignment.Center);
|
||||
content.SetValue(FrameworkElement.VerticalAlignmentProperty, VerticalAlignment.Center);
|
||||
content.SetValue(TextBlock.TextProperty, new TemplateBindingExtension(ContentControl.ContentProperty));
|
||||
content.SetValue(TextBlock.ForegroundProperty, new TemplateBindingExtension(Control.ForegroundProperty));
|
||||
content.SetValue(TextBlock.FontWeightProperty, new TemplateBindingExtension(Control.FontWeightProperty));
|
||||
border.AppendChild(content);
|
||||
|
||||
return new ControlTemplate(typeof(Button))
|
||||
{
|
||||
VisualTree = border
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
@@ -6,9 +6,9 @@ The tray icon SHALL be implemented through the Uno notification icon stack.
|
||||
|
||||
The tray icon menu SHALL show every configured launch profile when recording can be started and SHALL include each profile's configured toggle hotkey in the corresponding start or switch menu item.
|
||||
|
||||
When the user selects `Settings and logs`, Meeting Assistant SHALL open a basic MewUI chat window for editing the configured workflow rules file, managing speaker identities, inspecting configuration, and reading logs.
|
||||
When the user selects `Settings and logs`, Meeting Assistant SHALL open a basic WPF chat window for editing the configured workflow rules file, managing speaker identities, inspecting configuration, and reading logs.
|
||||
|
||||
The chat window SHALL be titled `Settings and logs`, SHALL display user and assistant messages as visually distinct cards, SHALL display basic markdown emphasis, inline code, fenced code blocks, pipe tables rendered as grid views, and line breaks in agent responses, SHALL display a plain `Thinking...` line while the agent is working, SHALL provide a multiline text input at the bottom with placeholder text `ask me what I can do for you`, SHALL send on Enter, SHALL insert a newline on Shift+Enter, and SHALL provide an explicit Send button.
|
||||
The chat window SHALL be titled `Settings and logs`, SHALL display user and assistant messages as visually distinct cards, SHALL render message text with selectable rich text, SHALL display basic markdown emphasis including bold, italic, bold-italic, and strikethrough text, clickable markdown links and autolinks with URL tooltips, clickable markdown image embeds with URL tooltips and a small preview or placeholder, boxed inline code, fenced code blocks, blockquotes, level-one and level-two headings with modestly larger text, markdown task checkboxes as aligned Unicode checkbox glyphs, pipe tables rendered as grid views, and line breaks in agent responses, SHALL display a plain `Thinking...` line while the agent is working, SHALL provide a multiline text input at the bottom with placeholder text `ask me what I can do for you`, SHALL send on Enter, SHALL insert a newline on Shift+Enter, and SHALL provide an explicit Send button.
|
||||
|
||||
When a new chat message or thinking state is appended, the chat window SHALL scroll to the bottom of the newly rendered content if the conversation was already near the bottom or did not need scrolling before the append.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user