first commit

This commit is contained in:
yiekheng 2025-12-23 12:05:01 +08:00
commit ac5da6939f
32 changed files with 3444 additions and 0 deletions

36
.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# Build output
bin/
obj/
publish/
artifacts/
TestResults/
# Visual Studio / MSBuild
.vs/
*.suo
*.user
*.userosscache
*.sln.docstates
*.csproj.user
*.tmp
*.log
# Rider / Resharper
.idea/
*.sln.iml
_ReSharper*/
*.DotSettings.user
# VS Code
.vscode/
# Cursor
.cursor/
# Python
__pycache__/
*.pyc
# OS files
.DS_Store
Thumbs.db

364
AGENTS.md Normal file
View File

@ -0,0 +1,364 @@
# AmebaPro3 Control Panel Development Agent Specification (agents.md)
## Project Overview
A Windows desktop control panel for Realtek AmebaPro3 SoC bring-up, debugging, and automation.
Target stack:
* C#
* .NET 8
* WPF (preferred over WinForms)
* Windows 11-like modern styling using native WPF only
* Phase 1: UI-only skeleton (baseline, no real hardware access)
Goals:
* Modern, clean, engineering-oriented UI
* Stable build and runtime (no third-party UI dependencies)
* Layout and component structure that supports future services (UART, J-Link, flashing)
---
## UI Framework & Styling (STRICT)
### Mandatory
* Framework: WPF
* Target: .NET 8 (e.g. net8.0-windows)
* No third-party UI libraries (no theme packages)
* Style target: Windows 11-like (cards, rounded corners, subtle borders, simple states)
* Styling technique: XAML ResourceDictionaries + Styles + ControlTemplates
### Forbidden
* x WPF-UI / lepoco, MahApps, MaterialDesign, FluentWPF, HandyControl, etc.
* x UWP / WinUI 3
* x Windows Store dependency
* x Runtime hardware access in Phase 1
---
## Architecture Principles
* UI-first development
* MVVM-friendly
* Clear separation between:
* UI (Views + ViewModels)
* Device services (UART/J-Link services)
* Debug services (future)
* Every device/session has independent console and command history
* Console/log UI must stay responsive under heavy output
---
## Solution Structure
* `App.xaml`
* Loads theme resources (Light by default)
* `Themes/Colors.Light.xaml` / `Themes/Colors.Dark.xaml`
* `Themes/Controls.xaml` (Button/TextBox/TabControl/List styles)
* `Views/MainWindow.xaml`
* `Views/Pages/MainPage.xaml`
* `Views/Pages/JLinkPage.xaml`
* `Views/Controls/ConsolePanel.xaml` (reusable)
* `ViewModels/*`
* `Services/Uart/*`
* `Scripts/uart_bridge.py`
---
## Styling Contract (native WPF)
* Font: Segoe UI Variable (fallback: Segoe UI)
* Buttons: height 36, radius 10, consistent padding
* Inputs: radius 10, clear focus border
* Cards: radius 12, subtle border, optional very light shadow
* Accent color used only for primary actions / selected tab / selected nav
---
## Main Application Layout
### MainWindow.xaml
Purpose: Host top navigation + page content.
Root Layout:
* `Grid`
* `Row 0` = `Auto` (TopBar)
* `Row 1` = `*` (PageContent)
* `Row 2` = `Auto` (StatusBar)
Row 0: TopBar (Auto, ~56px)
* Left: App title `TextBlock` ("AmebaPro3 Control Panel")
* Right: `ListBox` navigation for page switching
* Tab 1: Panel (MainPage)
* Tab 2: Debugger (JLinkPage)
* Optional theme toggle or window buttons
Row 1: PageContent
* `ContentControl` bound to `CurrentPageViewModel`
Row 2: StatusBar (Auto, ~28px)
* Left: Selected device + connection status text
* Right: `Last RX time`, `RX bytes`, `TX bytes`
Naming (important):
* Main container: `MainRootGrid`
* Page host: `MainContentHost`
---
## Pages
### 1) MainPage.xaml (Panel)
Purpose: Combine Flash Image, Arduino control, and AmebaPro3 UART in a split layout.
Root Layout:
* `Grid` with two columns
* Column 0 (Left, wider): `*` (AmebaPro3 UART)
* Column 1 (Right, narrower): ~480 (Flash + Arduino)
* Column gap: 12
Column 0: AmebaPro3 UART (largest area)
* A single `ConsolePanel` instance configured for AmebaPro3
* Header controls inside ConsolePanel:
* COM port dropdown
* Baud rate input (default 1500000)
* Connect / Disconnect button
* Optional TeraTerm launch button
Column 1: Right stack
* `Grid` with rows and a splitter:
* Row 0 = Auto (Flash Card)
* Row 1 = 8 (Spacing)
* Row 2 = * (Arduino Card)
#### Flash Image Card (Row 0, Auto)
Card container: `Border` (CardStyle)
Contents:
* Section title: `TextBlock` "Flash Image"
* `Grid` with rows:
* Bootloader file picker (`TextBox` readonly + `Browse` button)
* Application file picker (`TextBox` readonly + `Browse` button)
* `Flash Image` primary button (full width)
Control names:
* `BootloaderPathTextBox`, `BrowseBootloaderButton`
* `AppPathTextBox`, `BrowseAppButton`
* `FlashImageButton`
#### Arduino Controller Card (Row 2, *)
Card container: `Border` (CardStyle)
Contents (top to bottom):
1. Connection row:
* COM dropdown + Baud textbox (default 115200) + Connect button
2. Mode buttons (uniform size, aligned, share whole row width):
* Reset, Download Mode, Normal Mode, Test Mode
3. Test mode selector:
* `ComboBox` "Test 1" to "Test 7"
Control names:
* `ArduinoComComboBox`, `ArduinoBaudTextBox`, `ArduinoConnectButton`
* `ArduinoResetButton`, `ArduinoDownloadButton`, `ArduinoNormalButton`, `ArduinoTestModeButton`
* `ArduinoTestSelectComboBox`
---
### 2) JLinkPage.xaml (Debugger)
Purpose: Multi-AP debugging for AmebaPro3.
Root Layout:
* `Grid` with columns:
* Column 0 = `*` (Main AP console workspace)
* Column 1 = `280` (AP selector pane)
* Column gap: 12
Top area (inside Column 0, Row 0 Auto):
* J-Link selection row:
* `ComboBox` for J-Link serial number
* `Refresh` button (optional)
* `Connect` button (Phase 1 dummy)
Workspace area (Column 0, Row 1 *):
* `ContentControl` showing selected AP view
* Each AP view contains one `ConsolePanel` configured for that AP
Right AP selector pane (Column 1):
* Card container + `ListBox` (navigation style)
* Items:
* Cortex M33 - Processor NP
* Cortex M33 - Processor MP
* Cortex M23 - Processor FP
* Cortex CA32 - Processor AP
AP selector control name:
* `ApSelectorListBox`
AP sessions:
* Each AP has independent ConsolePanel state (log + history + command input)
Debugger command buttons (inside ConsolePanel `CommandButtons` slot):
* Halt, Continue, Step In, Step Over, Step Out
* File picker that lets user load multiple commands from file
* Command logic should support all JLink basic commands
Notes:
* When AP connected, Phase 2+ should not halt CPU by default
* Designed to integrate Segger J-Link + GDB later
---
## Reusable UI Components
### ConsolePanel.xaml (Core component)
Must be reusable across:
* AmebaPro3 UART
* Arduino UART (optional)
* J-Link AP sessions
#### ConsolePanel Layout (codex-ready)
Root: `Grid`
* Row 0 = Auto (Header, ~48px)
* Row 1 = * (Log viewer)
* Row 2 = Auto (GridSplitter)
* Row 3 = * (History list, min ~140px)
* Row 4 = Auto (CommandButtons slot)
* Row 5 = Auto (Actions row)
* Row 6 = Auto (Input row, ~44px)
Row 0: Header
* Left: `Title` + status text
* Right: header content slot (page-specific controls)
Row 1: Log viewer
* Must support horizontal + vertical scrolling
* Read-only
* Monospace option
* Performance: use a virtualization-friendly approach
* Can scroll up, down, left, right
* `ListBox`/`ItemsControl` virtualized lines (one line per item)
Row 3: Command history
* `ListBox` (virtualized)
* Behaviors:
* Latest command at bottom
* No duplicate command entries
* Up/Down navigates history
* Single click selects and fills command input
* Double click sends command
Row 5: Actions row
* Clear, Save, Load Commands
Row 6: Input row
* `Grid` with columns:
* Column 0 = * command input `TextBox`
* Column 1 = 8 spacing
* Column 2 = Auto send `Button`
* Press Enter triggers Send
* After send: clear input
Optional slot: `CommandButtons` area (for debugger step/halt/continue)
* Placed between history and actions row
#### ConsolePanel Public Bindings (ViewModel properties)
* `string Title`
* `bool IsConnected`
* `ObservableCollection<string> LogLines`
* `ObservableCollection<string> History`
* `string CurrentCommand`
* `ICommand SendCommand`
* `ICommand ClearLog`
* `ICommand SaveLog`
* `ICommand LoadCommandFileCommand`
* `UIElement CommandButtonsContent` (optional)
---
## Phase Roadmap
### Phase 1 (Baseline)
* UI-only
* All pages render correctly
* Dummy content / placeholder logs
* No hardware access
### Phase 2 (In progress)
* UART service (AmebaPro3) using Python + pyserial bridge (`Scripts/uart_bridge.py`)
* Optional TeraTerm launch integration
* Implement history behaviors
### Phase 3
* J-Link session management
* Multi-AP GDB integration
* Session lifecycle control
### Phase 4
* Flash pipeline
* C++ integration (Prefer)
* Python tool integration
* Automation & scripting
---
## Coding Rules for Agents
* Keep UI logic independent
* No blocking calls on UI thread
* One function = one job
* Prefer clarity over cleverness
* Engineering tool mindset
---
## Summary
This project is a professional SoC control panel focused on:
* Clarity
* Stability (no UI library dependencies)
* Expandability
* Multi-device / multi-session workflows
UI correctness and structure come before any hardware logic.

View File

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
</PropertyGroup>
<ItemGroup>
<None Include="Scripts\uart_bridge.py" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,3 @@
<Solution>
<Project Path="AmebaPro3_ControlPanel.csproj" />
</Solution>

51
App.xaml Normal file
View File

@ -0,0 +1,51 @@
<Application x:Class="AmebaPro3_ControlPanel.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:pages="clr-namespace:AmebaPro3_ControlPanel.Views.Pages"
xmlns:controls="clr-namespace:AmebaPro3_ControlPanel.Views.Controls"
xmlns:vm="clr-namespace:AmebaPro3_ControlPanel.ViewModels"
StartupUri="Views/MainWindow.xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Themes/Colors.Light.xaml"/>
<ResourceDictionary Source="Themes/Controls.xaml"/>
</ResourceDictionary.MergedDictionaries>
<DataTemplate DataType="{x:Type vm:MainPageViewModel}">
<pages:MainPage/>
</DataTemplate>
<DataTemplate DataType="{x:Type vm:JLinkPageViewModel}">
<pages:JLinkPage/>
</DataTemplate>
<DataTemplate DataType="{x:Type vm:JLinkApSessionViewModel}">
<controls:ConsolePanel>
<controls:ConsolePanel.CommandButtonsContent>
<UniformGrid Columns="5" HorizontalAlignment="Stretch">
<Button Content="Halt"
Command="{Binding HaltCommand}"
Style="{StaticResource BaseButtonStyle}"
Margin="0,0,4,0"/>
<Button Content="Continue"
Command="{Binding ContinueCommand}"
Style="{StaticResource BaseButtonStyle}"
Margin="4,0,4,0"/>
<Button Content="Step In"
Command="{Binding StepInCommand}"
Style="{StaticResource BaseButtonStyle}"
Margin="4,0,4,0"/>
<Button Content="Step Over"
Command="{Binding StepOverCommand}"
Style="{StaticResource BaseButtonStyle}"
Margin="4,0,4,0"/>
<Button Content="Step Out"
Command="{Binding StepOutCommand}"
Style="{StaticResource BaseButtonStyle}"
Margin="4,0,0,0"/>
</UniformGrid>
</controls:ConsolePanel.CommandButtonsContent>
</controls:ConsolePanel>
</DataTemplate>
</ResourceDictionary>
</Application.Resources>
</Application>

13
App.xaml.cs Normal file
View File

@ -0,0 +1,13 @@
using System.Configuration;
using System.Data;
using System.Windows;
namespace AmebaPro3_ControlPanel;
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application
{
}

10
AssemblyInfo.cs Normal file
View File

@ -0,0 +1,10 @@
using System.Windows;
[assembly:ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]

27
Scripts/build_portable.sh Normal file
View File

@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
project_path="$root_dir/AmebaPro3_ControlPanel.csproj"
runtime="${1:-win-x64}"
configuration="${2:-Release}"
publish_dir="${PUBLISH_DIR:-$root_dir/publish/portable-$runtime}"
echo "Publishing portable build"
echo "Project: $project_path"
echo "Runtime: $runtime"
echo "Config : $configuration"
echo "Output : $publish_dir"
dotnet publish "$project_path" \
-c "$configuration" \
-r "$runtime" \
--self-contained true \
-p:EnableWindowsTargeting=true \
-o "$publish_dir" \
-p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true \
-p:EnableCompressionInSingleFile=true
echo "Done. Output in: $publish_dir"

91
Scripts/uart_bridge.py Normal file
View File

@ -0,0 +1,91 @@
import argparse
import sys
import threading
import time
def _load_serial():
try:
import serial # type: ignore
except ImportError:
sys.stderr.write("pyserial is not installed. Run: pip install pyserial\n")
sys.stderr.flush()
sys.exit(2)
return serial
def _reader_loop(ser, stop_event):
buffer = bytearray()
while not stop_event.is_set():
try:
data = ser.read(ser.in_waiting or 1)
except Exception as exc:
sys.stderr.write(f"serial read error: {exc}\n")
sys.stderr.flush()
break
if data:
buffer.extend(data)
while b"\n" in buffer:
line, _, rest = buffer.partition(b"\n")
buffer = bytearray(rest)
text = line.decode("utf-8", errors="replace").rstrip("\r")
sys.stdout.write(text + "\n")
sys.stdout.flush()
else:
time.sleep(0.01)
if buffer:
text = buffer.decode("utf-8", errors="replace").rstrip("\r")
sys.stdout.write(text + "\n")
sys.stdout.flush()
def main():
parser = argparse.ArgumentParser(description="UART bridge using pyserial.")
parser.add_argument("--port", required=True, help="COM port name, e.g. COM3")
parser.add_argument("--baud", type=int, required=True, help="Baud rate")
args = parser.parse_args()
serial = _load_serial()
try:
ser = serial.Serial(args.port, args.baud, timeout=0.1, write_timeout=1)
except Exception as exc:
sys.stderr.write(f"failed to open {args.port} @ {args.baud}: {exc}\n")
sys.stderr.flush()
return 1
sys.stdout.write(f"[UART] OPEN {args.port} @ {args.baud}\n")
sys.stdout.flush()
stop_event = threading.Event()
reader = threading.Thread(target=_reader_loop, args=(ser, stop_event), daemon=True)
reader.start()
try:
for line in sys.stdin:
command = line.rstrip("\n")
if command == "__exit__":
break
if not command:
continue
try:
ser.write(command.encode("utf-8") + b"\r\n")
ser.flush()
except Exception as exc:
sys.stderr.write(f"serial write error: {exc}\n")
sys.stderr.flush()
break
finally:
stop_event.set()
try:
ser.close()
except Exception:
pass
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,34 @@
using System;
using System.Linq;
using Microsoft.Win32;
namespace AmebaPro3_ControlPanel.Services.Uart;
public static class PortDiscovery
{
public static string[] GetPorts()
{
try
{
using var key = Registry.LocalMachine.OpenSubKey(@"HARDWARE\\DEVICEMAP\\SERIALCOMM");
if (key == null)
{
return Array.Empty<string>();
}
var ports = key.GetValueNames()
.Select(name => key.GetValue(name))
.OfType<string>()
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase)
.ToArray();
return ports;
}
catch
{
return Array.Empty<string>();
}
}
}

View File

@ -0,0 +1,238 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace AmebaPro3_ControlPanel.Services.Uart;
public sealed class PythonUartSession : IDisposable
{
private readonly SynchronizationContext? _syncContext;
private readonly string _scriptPath;
private readonly SemaphoreSlim _stdinLock = new(1, 1);
private Process? _process;
private CancellationTokenSource? _cts;
public PythonUartSession(string scriptPath, SynchronizationContext? syncContext)
{
_scriptPath = scriptPath;
_syncContext = syncContext;
}
public event Action<string>? LineReceived;
public event Action<string>? ErrorReceived;
public event Action? Exited;
public bool IsRunning => _process is { HasExited: false };
public async Task<bool> StartAsync(string port, int baudRate, string? pythonExecutable = null)
{
if (IsRunning)
{
return true;
}
if (string.IsNullOrWhiteSpace(_scriptPath) || !File.Exists(_scriptPath))
{
PostError($"UART bridge script not found: {_scriptPath}");
return false;
}
var candidates = pythonExecutable is null ? GetPythonCandidates() : new[] { pythonExecutable };
string? lastError = null;
foreach (var candidate in candidates)
{
if (string.IsNullOrWhiteSpace(candidate))
{
continue;
}
var args = $"\"{_scriptPath}\" --port {port} --baud {baudRate}";
var startInfo = new ProcessStartInfo
{
FileName = candidate,
Arguments = args,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
var process = new Process
{
StartInfo = startInfo,
EnableRaisingEvents = true
};
try
{
if (!process.Start())
{
lastError = $"Failed to start {candidate}.";
continue;
}
}
catch (Exception ex)
{
lastError = $"Failed to start {candidate}: {ex.Message}";
continue;
}
_process = process;
_process.Exited += (_, _) => PostExit();
_cts = new CancellationTokenSource();
_ = Task.Run(() => ReadStreamAsync(_process.StandardOutput, PostLine, _cts.Token));
_ = Task.Run(() => ReadStreamAsync(_process.StandardError, PostError, _cts.Token));
return true;
}
PostError(lastError ?? "Python not found. Install Python 3 and pyserial.");
return false;
}
public async Task SendAsync(string command)
{
if (!IsRunning || _process == null)
{
return;
}
await _stdinLock.WaitAsync().ConfigureAwait(false);
try
{
if (_process.HasExited)
{
return;
}
await _process.StandardInput.WriteLineAsync(command).ConfigureAwait(false);
await _process.StandardInput.FlushAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
PostError($"UART send failed: {ex.Message}");
}
finally
{
_stdinLock.Release();
}
}
public async Task StopAsync()
{
if (_process == null)
{
return;
}
try
{
if (!_process.HasExited)
{
await SendAsync("__exit__").ConfigureAwait(false);
if (!_process.WaitForExit(1000))
{
_process.Kill(entireProcessTree: true);
}
}
}
catch
{
}
finally
{
CleanupProcess();
}
}
private async Task ReadStreamAsync(StreamReader reader, Action<string> handler, CancellationToken token)
{
while (!token.IsCancellationRequested)
{
string? line;
try
{
line = await reader.ReadLineAsync().ConfigureAwait(false);
}
catch
{
break;
}
if (line == null)
{
break;
}
if (!string.IsNullOrWhiteSpace(line))
{
handler(line);
}
}
}
private void Post(Action<string>? handler, string line)
{
if (handler == null)
{
return;
}
if (_syncContext != null)
{
_syncContext.Post(_ => handler(line), null);
}
else
{
handler(line);
}
}
private void PostLine(string line) => Post(LineReceived, line);
private void PostError(string line) => Post(ErrorReceived, line);
private void PostExit()
{
if (_syncContext != null)
{
_syncContext.Post(_ => Exited?.Invoke(), null);
}
else
{
Exited?.Invoke();
}
}
private void CleanupProcess()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
_process?.Dispose();
_process = null;
}
private static string?[] GetPythonCandidates()
{
var explicitPath = Environment.GetEnvironmentVariable("AMEBA_PYTHON");
if (!string.IsNullOrWhiteSpace(explicitPath))
{
return new[] { explicitPath, "python", "py" };
}
return new[] { "python", "py" };
}
public void Dispose()
{
_stdinLock.Dispose();
CleanupProcess();
}
}

View File

@ -0,0 +1,80 @@
using System;
using System.Diagnostics;
using System.IO;
namespace AmebaPro3_ControlPanel.Services.Uart;
public static class TeraTermLauncher
{
public static bool TryLaunch(string? port, int baudRate, out string message)
{
if (string.IsNullOrWhiteSpace(port))
{
message = "Select a COM port first.";
return false;
}
var exePath = ResolveTeraTermPath();
if (string.IsNullOrWhiteSpace(exePath))
{
message = "Tera Term not found. Set TERATERM_PATH or install it.";
return false;
}
var args = $"/C={port} /BAUD={baudRate}";
try
{
Process.Start(new ProcessStartInfo
{
FileName = exePath,
Arguments = args,
UseShellExecute = true
});
message = "Tera Term launched.";
return true;
}
catch (Exception ex)
{
message = $"Failed to start Tera Term: {ex.Message}";
return false;
}
}
private static string? ResolveTeraTermPath()
{
var envPath = Environment.GetEnvironmentVariable("TERATERM_PATH");
if (!string.IsNullOrWhiteSpace(envPath))
{
var candidate = Directory.Exists(envPath)
? Path.Combine(envPath, "ttermpro.exe")
: envPath;
if (File.Exists(candidate))
{
return candidate;
}
}
var programFiles = new[]
{
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86)
};
foreach (var root in programFiles)
{
if (string.IsNullOrWhiteSpace(root))
{
continue;
}
var candidate = Path.Combine(root, "teraterm", "ttermpro.exe");
if (File.Exists(candidate))
{
return candidate;
}
}
return "ttermpro.exe";
}
}

43
Themes/Colors.Dark.xaml Normal file
View File

@ -0,0 +1,43 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Dark theme color palette -->
<Color x:Key="AccentColor">#FF7BA7FF</Color>
<Color x:Key="AccentMutedColor">#FF9CBFFF</Color>
<Color x:Key="SurfaceColor">#FF0F172A</Color>
<Color x:Key="SurfaceAltColor">#FF1B2437</Color>
<Color x:Key="CardColor">#FF1F2B3D</Color>
<Color x:Key="BorderColor">#33475B</Color>
<Color x:Key="SubtleBorderColor">#222C3C</Color>
<Color x:Key="InputBackgroundColor">#FF1A2332</Color>
<Color x:Key="TextPrimaryColor">#FFE5EAF3</Color>
<Color x:Key="TextSecondaryColor">#FFC3CCDA</Color>
<Color x:Key="TextMutedColor">#FF9CA7BA</Color>
<Color x:Key="SuccessColor">#FF3AD88F</Color>
<Color x:Key="WarningColor">#FFEFB95C</Color>
<Color x:Key="DangerColor">#FFFF7A7A</Color>
<SolidColorBrush x:Key="AccentBrush" Color="{StaticResource AccentColor}"/>
<SolidColorBrush x:Key="AccentBrushMuted" Color="{StaticResource AccentMutedColor}"/>
<SolidColorBrush x:Key="SurfaceBrush" Color="{StaticResource SurfaceColor}"/>
<SolidColorBrush x:Key="SurfaceAltBrush" Color="{StaticResource SurfaceAltColor}"/>
<SolidColorBrush x:Key="CardBrush" Color="{StaticResource CardColor}"/>
<SolidColorBrush x:Key="BorderBrush" Color="{StaticResource BorderColor}"/>
<SolidColorBrush x:Key="SubtleBorderBrush" Color="{StaticResource SubtleBorderColor}"/>
<SolidColorBrush x:Key="InputBackgroundBrush" Color="{StaticResource InputBackgroundColor}"/>
<SolidColorBrush x:Key="InputBorderBrush" Color="{StaticResource BorderColor}"/>
<SolidColorBrush x:Key="InputBorderActiveBrush" Color="{StaticResource AccentColor}"/>
<SolidColorBrush x:Key="TextBrushPrimary" Color="{StaticResource TextPrimaryColor}"/>
<SolidColorBrush x:Key="TextBrushSecondary" Color="{StaticResource TextSecondaryColor}"/>
<SolidColorBrush x:Key="TextBrushMuted" Color="{StaticResource TextMutedColor}"/>
<SolidColorBrush x:Key="SuccessBrush" Color="{StaticResource SuccessColor}"/>
<SolidColorBrush x:Key="WarningBrush" Color="{StaticResource WarningColor}"/>
<SolidColorBrush x:Key="DangerBrush" Color="{StaticResource DangerColor}"/>
<SolidColorBrush x:Key="ShadowBrush" Color="#33000000"/>
<CornerRadius x:Key="ControlCornerRadius">10</CornerRadius>
<CornerRadius x:Key="CardCornerRadius">12</CornerRadius>
<Thickness x:Key="ControlPadding">12,8</Thickness>
<Thickness x:Key="CardPadding">16</Thickness>
<FontFamily x:Key="AppFontFamily">Segoe UI Variable, Segoe UI</FontFamily>
</ResourceDictionary>

43
Themes/Colors.Light.xaml Normal file
View File

@ -0,0 +1,43 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Light theme color palette -->
<Color x:Key="AccentColor">#FF4F6BED</Color>
<Color x:Key="AccentMutedColor">#FF8096F4</Color>
<Color x:Key="SurfaceColor">#FFF5F7FB</Color>
<Color x:Key="SurfaceAltColor">#FFFFFFFF</Color>
<Color x:Key="CardColor">#FFFFFFFF</Color>
<Color x:Key="BorderColor">#22000000</Color>
<Color x:Key="SubtleBorderColor">#11000000</Color>
<Color x:Key="InputBackgroundColor">#FFF7FAFF</Color>
<Color x:Key="TextPrimaryColor">#FF101828</Color>
<Color x:Key="TextSecondaryColor">#FF4B5565</Color>
<Color x:Key="TextMutedColor">#FF98A1B2</Color>
<Color x:Key="SuccessColor">#FF2BB673</Color>
<Color x:Key="WarningColor">#FFE0A500</Color>
<Color x:Key="DangerColor">#FFD14343</Color>
<SolidColorBrush x:Key="AccentBrush" Color="{StaticResource AccentColor}"/>
<SolidColorBrush x:Key="AccentBrushMuted" Color="{StaticResource AccentMutedColor}"/>
<SolidColorBrush x:Key="SurfaceBrush" Color="{StaticResource SurfaceColor}"/>
<SolidColorBrush x:Key="SurfaceAltBrush" Color="{StaticResource SurfaceAltColor}"/>
<SolidColorBrush x:Key="CardBrush" Color="{StaticResource CardColor}"/>
<SolidColorBrush x:Key="BorderBrush" Color="{StaticResource BorderColor}"/>
<SolidColorBrush x:Key="SubtleBorderBrush" Color="{StaticResource SubtleBorderColor}"/>
<SolidColorBrush x:Key="InputBackgroundBrush" Color="{StaticResource InputBackgroundColor}"/>
<SolidColorBrush x:Key="InputBorderBrush" Color="{StaticResource BorderColor}"/>
<SolidColorBrush x:Key="InputBorderActiveBrush" Color="{StaticResource AccentColor}"/>
<SolidColorBrush x:Key="TextBrushPrimary" Color="{StaticResource TextPrimaryColor}"/>
<SolidColorBrush x:Key="TextBrushSecondary" Color="{StaticResource TextSecondaryColor}"/>
<SolidColorBrush x:Key="TextBrushMuted" Color="{StaticResource TextMutedColor}"/>
<SolidColorBrush x:Key="SuccessBrush" Color="{StaticResource SuccessColor}"/>
<SolidColorBrush x:Key="WarningBrush" Color="{StaticResource WarningColor}"/>
<SolidColorBrush x:Key="DangerBrush" Color="{StaticResource DangerColor}"/>
<SolidColorBrush x:Key="ShadowBrush" Color="#19000000"/>
<CornerRadius x:Key="ControlCornerRadius">10</CornerRadius>
<CornerRadius x:Key="CardCornerRadius">12</CornerRadius>
<Thickness x:Key="ControlPadding">12,8</Thickness>
<Thickness x:Key="CardPadding">16</Thickness>
<FontFamily x:Key="AppFontFamily">Segoe UI Variable, Segoe UI</FontFamily>
</ResourceDictionary>

511
Themes/Controls.xaml Normal file
View File

@ -0,0 +1,511 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Typography -->
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource TextBrushPrimary}"/>
<Setter Property="FontFamily" Value="{StaticResource AppFontFamily}"/>
<Setter Property="TextWrapping" Value="Wrap"/>
</Style>
<!-- Window -->
<Style TargetType="Window">
<Setter Property="Background" Value="{StaticResource SurfaceBrush}"/>
<Setter Property="FontFamily" Value="{StaticResource AppFontFamily}"/>
<Setter Property="Foreground" Value="{StaticResource TextBrushPrimary}"/>
</Style>
<!-- Card container -->
<Style x:Key="CardStyle" TargetType="Border">
<Setter Property="Background" Value="{StaticResource CardBrush}"/>
<Setter Property="CornerRadius" Value="{StaticResource CardCornerRadius}"/>
<Setter Property="BorderBrush" Value="{StaticResource SubtleBorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="{StaticResource CardPadding}"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="Effect">
<Setter.Value>
<DropShadowEffect Color="#22000000"
BlurRadius="8"
ShadowDepth="1"
Opacity="0.35"/>
</Setter.Value>
</Setter>
</Style>
<!-- Buttons -->
<Style x:Key="BaseButtonStyle" TargetType="Button">
<Setter Property="Height" Value="40"/>
<Setter Property="Padding" Value="14,10"/>
<Setter Property="Margin" Value="0"/>
<Setter Property="Background" Value="{StaticResource SurfaceAltBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Foreground" Value="{StaticResource TextBrushPrimary}"/>
<Setter Property="FontFamily" Value="{StaticResource AppFontFamily}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Root"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{StaticResource ControlCornerRadius}">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Root" Property="Background" Value="{StaticResource SurfaceBrush}"/>
<Setter Property="BorderBrush" TargetName="Root" Value="{StaticResource AccentBrushMuted}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Root" Property="Background" Value="{StaticResource AccentBrushMuted}"/>
<Setter TargetName="Root" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
<Setter Property="Foreground" Value="White"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Root" Property="Opacity" Value="0.6"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="PrimaryButtonStyle" TargetType="Button" BasedOn="{StaticResource BaseButtonStyle}">
<Setter Property="Background" Value="{StaticResource AccentBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Root"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{StaticResource ControlCornerRadius}">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Root" Property="Background" Value="{StaticResource AccentBrushMuted}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Root" Property="Background" Value="{StaticResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Root" Property="Opacity" Value="0.6"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="GhostButtonStyle" TargetType="Button" BasedOn="{StaticResource BaseButtonStyle}">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
</Style>
<!-- TextBox -->
<Style TargetType="TextBox">
<Setter Property="Height" Value="40"/>
<Setter Property="Padding" Value="12,8"/>
<Setter Property="Background" Value="{StaticResource InputBackgroundBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource InputBorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="FontFamily" Value="{StaticResource AppFontFamily}"/>
<Setter Property="Foreground" Value="{StaticResource TextBrushPrimary}"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border x:Name="Root"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{StaticResource ControlCornerRadius}">
<ScrollViewer x:Name="PART_ContentHost" Margin="0"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="Root" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
<Setter TargetName="Root" Property="BorderThickness" Value="2"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Root" Property="Opacity" Value="0.6"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ComboBox -->
<Style TargetType="ComboBox">
<Setter Property="Height" Value="40"/>
<Setter Property="Padding" Value="12,6"/>
<Setter Property="Background" Value="{StaticResource InputBackgroundBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource InputBorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="FontFamily" Value="{StaticResource AppFontFamily}"/>
<Setter Property="Foreground" Value="{StaticResource TextBrushPrimary}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<Border x:Name="Root"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{StaticResource ControlCornerRadius}"
SnapsToDevicePixels="True"
Cursor="Hand">
<Grid>
<ToggleButton x:Name="MainToggleButton"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Background="Transparent"
BorderThickness="0"
Focusable="False"
Cursor="Hand"
IsChecked="{Binding IsDropDownOpen, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border Background="Transparent" BorderThickness="0" Cursor="Hand"/>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
<DockPanel IsHitTestVisible="False">
<ContentPresenter x:Name="ContentSite"
Margin="12,0,8,0"
VerticalAlignment="Center"
HorizontalAlignment="Left"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"/>
<ToggleButton DockPanel.Dock="Right"
Width="28"
Focusable="False"
HorizontalAlignment="Right"
Template="{DynamicResource ComboBoxToggleButton}"
IsChecked="{Binding IsDropDownOpen, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}"/>
</DockPanel>
</Grid>
</Border>
<Popup x:Name="PART_Popup"
Placement="Bottom"
AllowsTransparency="True"
Focusable="True"
IsHitTestVisible="True"
IsOpen="{TemplateBinding IsDropDownOpen}"
PopupAnimation="Fade">
<Grid MaxHeight="320" Margin="0,4,0,0" IsHitTestVisible="True">
<Border Background="{StaticResource CardBrush}"
BorderBrush="{StaticResource SubtleBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Width="{Binding ActualWidth, RelativeSource={RelativeSource TemplatedParent}}"
Padding="2"
IsHitTestVisible="True">
<Border.Effect>
<DropShadowEffect Color="#40000000"
BlurRadius="16"
ShadowDepth="4"
Opacity="0.4"
Direction="270"/>
</Border.Effect>
<ScrollViewer Margin="0"
SnapsToDevicePixels="True"
VerticalScrollBarVisibility="Auto"
IsHitTestVisible="True"
CanContentScroll="False">
<ItemsPresenter/>
</ScrollViewer>
</Border>
</Grid>
</Popup>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Root" Property="BorderBrush" Value="{StaticResource AccentBrushMuted}"/>
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="Root" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
<Setter TargetName="Root" Property="BorderThickness" Value="2"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Root" Property="Opacity" Value="0.6"/>
</Trigger>
<Trigger Property="IsDropDownOpen" Value="True">
<Setter TargetName="Root" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
<Setter Property="ItemContainerStyle">
<Setter.Value>
<Style TargetType="ComboBoxItem">
<Setter Property="Padding" Value="14,10"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="Margin" Value="2,1"/>
<Setter Property="IsHitTestVisible" Value="True"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="Root"
Padding="{TemplateBinding Padding}"
Background="{TemplateBinding Background}"
CornerRadius="8"
SnapsToDevicePixels="True"
IsHitTestVisible="True"
Cursor="Hand">
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Root" Property="Background" Value="{StaticResource SurfaceAltBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Root" Property="Background" Value="{StaticResource AccentBrush}"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Root" Property="Opacity" Value="0.5"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Setter.Value>
</Setter>
</Style>
<!-- Toggle button used inside ComboBox -->
<ControlTemplate x:Key="ComboBoxToggleButton" TargetType="ToggleButton">
<Border x:Name="Border"
Background="Transparent"
Padding="8,4,16,4"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="0"
CornerRadius="{StaticResource ControlCornerRadius}">
<Path x:Name="Arrow"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Data="M 0 0 L 5 5 L 10 0 Z"
Fill="{StaticResource TextBrushSecondary}"
Stretch="Uniform"
Width="10"
Height="5"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Arrow" Property="Fill" Value="{StaticResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Arrow" Property="Fill" Value="{StaticResource AccentBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
<!-- Navigation tab control -->
<Style x:Key="TopNavTabControlStyle" TargetType="TabControl">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="HorizontalAlignment" Value="Left"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="UseLayoutRounding" Value="True"/>
<Setter Property="Margin" Value="0"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="ClipToBounds" Value="False"/>
<Setter Property="Width" Value="Auto"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TabControl">
<Grid ClipToBounds="False" HorizontalAlignment="Left">
<TabPanel x:Name="HeaderPanel"
Panel.ZIndex="1"
IsItemsHost="True"
Margin="8,4,0,4"
HorizontalAlignment="Left"
ClipToBounds="False"
Background="Transparent"
UseLayoutRounding="False"/>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="TopNavTabItemStyle" TargetType="TabItem">
<Setter Property="Height" Value="36"/>
<Setter Property="MinWidth" Value="130"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Padding" Value="18,8"/>
<Setter Property="Foreground" Value="{StaticResource TextBrushSecondary}"/>
<Setter Property="Margin" Value="0,0,24,0"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="UseLayoutRounding" Value="True"/>
<Setter Property="Panel.ZIndex" Value="0"/>
<Setter Property="ClipToBounds" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TabItem">
<Grid ClipToBounds="False">
<Border x:Name="Root"
Background="Transparent"
BorderBrush="{StaticResource SubtleBorderBrush}"
BorderThickness="1"
CornerRadius="0"
Padding="{TemplateBinding Padding}"
ClipToBounds="False">
<ContentPresenter ContentSource="Header"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Root" Property="BorderBrush" Value="{StaticResource AccentBrushMuted}"/>
<Setter TargetName="Root" Property="Background" Value="{StaticResource SurfaceAltBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Root" Property="Background" Value="{StaticResource AccentBrush}"/>
<Setter TargetName="Root" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="Panel.ZIndex" Value="10"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Root" Property="Opacity" Value="0.6"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ListBox -->
<Style TargetType="ListBox">
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Auto"/>
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Auto"/>
</Style>
<!-- Navigation ListBox style -->
<Style x:Key="TopNavListBoxStyle" TargetType="ListBox">
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="Transparent"/>
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled"/>
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Disabled"/>
</Style>
<!-- Navigation ListBoxItem style -->
<Style x:Key="TopNavListBoxItemStyle" TargetType="ListBoxItem">
<Setter Property="Height" Value="36"/>
<Setter Property="MinWidth" Value="130"/>
<Setter Property="Margin" Value="0,0,12,0"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="VerticalContentAlignment" Value="Stretch"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="BorderBrush" Value="{StaticResource SubtleBorderBrush}"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="ClipToBounds" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Root"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="18"
Padding="0"
ClipToBounds="False">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Root" Property="BorderBrush" Value="{StaticResource AccentBrushMuted}"/>
<Setter TargetName="Root" Property="Background" Value="{StaticResource SurfaceAltBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Root" Property="Background" Value="{StaticResource AccentBrush}"/>
<Setter TargetName="Root" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
<Setter Property="Foreground" Value="White"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Root" Property="Opacity" Value="0.6"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="SelectorListBoxItemStyle" TargetType="ListBoxItem">
<Setter Property="Height" Value="44"/>
<Setter Property="Padding" Value="12,6"/>
<Setter Property="Margin" Value="0,4,0,0"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Background" Value="{StaticResource SurfaceAltBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="BorderBrush" Value="{StaticResource SubtleBorderBrush}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Root"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{StaticResource ControlCornerRadius}"
Padding="{TemplateBinding Padding}">
<ContentPresenter VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Root" Property="BorderBrush" Value="{StaticResource AccentBrushMuted}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Root" Property="Background" Value="{StaticResource AccentBrush}"/>
<Setter Property="Foreground" Value="White"/>
<Setter TargetName="Root" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Labels -->
<Style x:Key="SectionHeaderTextStyle" TargetType="TextBlock">
<Setter Property="FontSize" Value="16"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{StaticResource TextBrushPrimary}"/>
<Setter Property="Margin" Value="0,0,0,12"/>
</Style>
<Style x:Key="CaptionTextStyle" TargetType="TextBlock">
<Setter Property="FontSize" Value="12"/>
<Setter Property="Foreground" Value="{StaticResource TextBrushSecondary}"/>
</Style>
</ResourceDictionary>

24
Utilities/RelayCommand.cs Normal file
View File

@ -0,0 +1,24 @@
using System;
using System.Windows.Input;
namespace AmebaPro3_ControlPanel.Utilities;
public class RelayCommand : ICommand
{
private readonly Action<object?> _execute;
private readonly Func<object?, bool>? _canExecute;
public RelayCommand(Action<object?> execute, Func<object?, bool>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
public void Execute(object? parameter) => _execute(parameter);
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}

View File

@ -0,0 +1,189 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Windows.Input;
using AmebaPro3_ControlPanel.Utilities;
using Microsoft.Win32;
namespace AmebaPro3_ControlPanel.ViewModels;
public class ConsolePanelViewModel : ViewModelBase
{
private string _title;
private bool _isConnected;
private string _currentCommand = string.Empty;
public ConsolePanelViewModel(string title)
{
_title = title;
SendCommand = new RelayCommand(_ => SendCurrentCommand(true));
ClearLog = new RelayCommand(_ => LogLines.Clear());
SaveLog = new RelayCommand(_ => SaveLogToFile());
LoadCommandFileCommand = new RelayCommand(_ => LoadCommandsFromFile());
}
public string Title
{
get => _title;
set => SetProperty(ref _title, value);
}
public bool IsConnected
{
get => _isConnected;
set => SetProperty(ref _isConnected, value);
}
public ObservableCollection<string> LogLines { get; } = new();
public ObservableCollection<string> History { get; } = new();
public string CurrentCommand
{
get => _currentCommand;
set => SetProperty(ref _currentCommand, value);
}
public ICommand SendCommand { get; }
public ICommand ClearLog { get; }
public ICommand SaveLog { get; }
public ICommand LoadCommandFileCommand { get; }
protected virtual void SendCurrentCommand(bool addToHistory)
{
if (string.IsNullOrWhiteSpace(CurrentCommand))
{
return;
}
var command = CurrentCommand.Trim();
LogLines.Add($"> {command}");
if (addToHistory)
{
AddHistoryEntry(command);
}
CurrentCommand = string.Empty;
}
public void SendFromHistory(string command)
{
if (string.IsNullOrWhiteSpace(command))
{
return;
}
CurrentCommand = command;
SendCurrentCommand(true);
}
protected virtual void SaveLogToFile()
{
var dialog = new SaveFileDialog
{
Filter = "Text Files (*.txt)|*.txt|Log Files (*.log)|*.log|All Files (*.*)|*.*",
FileName = $"{Title}_Log.txt"
};
if (dialog.ShowDialog() == true)
{
try
{
File.WriteAllLines(dialog.FileName, LogLines);
LogLines.Add($"Log saved to {dialog.FileName}");
}
catch (IOException ex)
{
LogLines.Add($"Failed to save log: {ex.Message}");
}
}
}
protected virtual void LoadCommandsFromFile()
{
var dialog = new OpenFileDialog
{
Filter = "Text Files (*.txt)|*.txt|All Files (*.*)|*.*",
Multiselect = false
};
if (dialog.ShowDialog() == true)
{
try
{
var commands = File.ReadAllLines(dialog.FileName)
.Where(line => !string.IsNullOrWhiteSpace(line))
.Select(line => line.Trim())
.ToList();
foreach (var command in commands)
{
AddHistoryEntry(command);
}
if (commands.Count > 0)
{
LogLines.Add($"Loaded {commands.Count} command(s) from file.");
}
}
catch (IOException ex)
{
LogLines.Add($"Failed to load commands: {ex.Message}");
}
}
}
public void SeedSampleLog(IEnumerable<string> lines)
{
foreach (var line in lines)
{
LogLines.Add(line);
}
}
public void SeedSampleHistory(IEnumerable<string> commands)
{
foreach (var command in commands)
{
AddHistoryEntry(command);
}
}
public void DeleteHistoryItems(IEnumerable<string> itemsToDelete)
{
foreach (var item in itemsToDelete)
{
History.Remove(item);
}
}
public void DeleteLogLines(IEnumerable<string> linesToDelete)
{
foreach (var line in linesToDelete)
{
LogLines.Remove(line);
}
}
protected void AddHistoryEntry(string command)
{
if (string.IsNullOrWhiteSpace(command))
{
return;
}
var trimmed = command.Trim();
for (var i = History.Count - 1; i >= 0; i--)
{
if (History[i] == trimmed)
{
History.RemoveAt(i);
}
}
History.Add(trimmed);
}
}

View File

@ -0,0 +1,37 @@
using System.Windows.Input;
using AmebaPro3_ControlPanel.Utilities;
namespace AmebaPro3_ControlPanel.ViewModels;
public class JLinkApSessionViewModel : ConsolePanelViewModel
{
public JLinkApSessionViewModel(string title) : base(title)
{
HaltCommand = new RelayCommand(_ => AppendDebuggerMessage("Halt requested (UI placeholder)."));
ContinueCommand = new RelayCommand(_ => AppendDebuggerMessage("Continue requested (UI placeholder)."));
StepInCommand = new RelayCommand(_ => AppendDebuggerMessage("Step In (UI placeholder)."));
StepOverCommand = new RelayCommand(_ => AppendDebuggerMessage("Step Over (UI placeholder)."));
StepOutCommand = new RelayCommand(_ => AppendDebuggerMessage("Step Out (UI placeholder)."));
}
public ICommand HaltCommand { get; }
public ICommand ContinueCommand { get; }
public ICommand StepInCommand { get; }
public ICommand StepOverCommand { get; }
public ICommand StepOutCommand { get; }
public void SetSessionConnected(bool connected)
{
IsConnected = connected;
AppendDebuggerMessage(connected ? "Session ready (dummy)." : "Session disconnected.");
}
private void AppendDebuggerMessage(string message)
{
LogLines.Add(message);
}
}

View File

@ -0,0 +1,71 @@
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows.Input;
using AmebaPro3_ControlPanel.Utilities;
namespace AmebaPro3_ControlPanel.ViewModels;
public class JLinkPageViewModel : PageViewModelBase
{
private string? _selectedSerialNumber;
private JLinkApSessionViewModel? _selectedApSession;
public JLinkPageViewModel()
{
Title = "Debugger";
AvailableSerialNumbers = new ObservableCollection<string> { "123456789", "987654321", "555555555" };
SelectedSerialNumber = AvailableSerialNumbers.FirstOrDefault();
ApSessions = new ObservableCollection<JLinkApSessionViewModel>
{
new("Cortex M33 - Processor NP"),
new("Cortex M33 - Processor MP"),
new("Cortex M23 - Processor FP"),
new("Cortex CA32 - Processor AP")
};
SelectedApSession = ApSessions.FirstOrDefault();
RefreshSerialsCommand = new RelayCommand(_ => RefreshSerials());
ConnectJLinkCommand = new RelayCommand(_ => ConnectJLink());
}
public ObservableCollection<string> AvailableSerialNumbers { get; }
public string? SelectedSerialNumber
{
get => _selectedSerialNumber;
set => SetProperty(ref _selectedSerialNumber, value);
}
public ObservableCollection<JLinkApSessionViewModel> ApSessions { get; }
public JLinkApSessionViewModel? SelectedApSession
{
get => _selectedApSession;
set => SetProperty(ref _selectedApSession, value);
}
public ICommand RefreshSerialsCommand { get; }
public ICommand ConnectJLinkCommand { get; }
private void RefreshSerials()
{
AvailableSerialNumbers.Clear();
AvailableSerialNumbers.Add("123456789");
AvailableSerialNumbers.Add("987654321");
AvailableSerialNumbers.Add("555555555");
SelectedSerialNumber ??= AvailableSerialNumbers.FirstOrDefault();
}
private void ConnectJLink()
{
foreach (var session in ApSessions)
{
session.SetSessionConnected(true);
session.LogLines.Add($"Attached to {session.Title} via J-Link {(SelectedSerialNumber ?? "N/A")} (UI placeholder).");
}
}
}

View File

@ -0,0 +1,171 @@
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Windows.Input;
using AmebaPro3_ControlPanel.Services.Uart;
using AmebaPro3_ControlPanel.Utilities;
using Microsoft.Win32;
namespace AmebaPro3_ControlPanel.ViewModels;
public class MainPageViewModel : PageViewModelBase
{
private string _bootloaderPath = string.Empty;
private string _applicationPath = string.Empty;
private bool _isArduinoConnected;
private string? _selectedArduinoPort;
private int _arduinoBaudRate;
private string? _selectedArduinoTest;
public MainPageViewModel()
{
Title = "Panel";
AmebaConsole = new UartConsoleViewModel("AmebaPro3 UART", 1_500_000);
var amebaPorts = PortDiscovery.GetPorts();
AmebaConsole.SetPorts(amebaPorts);
if (amebaPorts.Length == 0)
{
AmebaConsole.LogLines.Add("[UART] No COM ports detected.");
}
else
{
AmebaConsole.LogLines.Add("[UART] Ready. Select a COM port and connect.");
}
ArduinoTests = new ObservableCollection<string>
{
"Test 1",
"Test 2",
"Test 3",
"Test 4",
"Test 5",
"Test 6",
"Test 7"
};
ArduinoPorts = new ObservableCollection<string> { "COM7", "COM8", "COM9" };
SelectedArduinoPort = ArduinoPorts.FirstOrDefault();
_arduinoBaudRate = 115200;
SelectedArduinoTest = ArduinoTests.FirstOrDefault();
BrowseBootloaderCommand = new RelayCommand(_ => PickFile(path => BootloaderPath = path));
BrowseAppCommand = new RelayCommand(_ => PickFile(path => ApplicationPath = path));
FlashImageCommand = new RelayCommand(_ => FlashImage(), _ => !string.IsNullOrWhiteSpace(BootloaderPath) && !string.IsNullOrWhiteSpace(ApplicationPath));
ArduinoConnectCommand = new RelayCommand(_ => ToggleArduinoConnection(), _ => !string.IsNullOrWhiteSpace(SelectedArduinoPort));
ArduinoResetCommand = new RelayCommand(_ => LogArduinoAction("Reset"));
ArduinoDownloadCommand = new RelayCommand(_ => LogArduinoAction("Download Mode"));
ArduinoNormalCommand = new RelayCommand(_ => LogArduinoAction("Normal Mode"));
ArduinoTestModeCommand = new RelayCommand(_ => LogArduinoAction($"Test Mode ({SelectedArduinoTest ?? "Not selected"})"));
}
public UartConsoleViewModel AmebaConsole { get; }
public ObservableCollection<string> ArduinoPorts { get; }
public ObservableCollection<string> ArduinoTests { get; }
public string BootloaderPath
{
get => _bootloaderPath;
set
{
if (SetProperty(ref _bootloaderPath, value))
{
(FlashImageCommand as RelayCommand)?.RaiseCanExecuteChanged();
}
}
}
public string ApplicationPath
{
get => _applicationPath;
set
{
if (SetProperty(ref _applicationPath, value))
{
(FlashImageCommand as RelayCommand)?.RaiseCanExecuteChanged();
}
}
}
public bool IsArduinoConnected
{
get => _isArduinoConnected;
private set => SetProperty(ref _isArduinoConnected, value);
}
public string? SelectedArduinoPort
{
get => _selectedArduinoPort;
set
{
if (SetProperty(ref _selectedArduinoPort, value))
{
(ArduinoConnectCommand as RelayCommand)?.RaiseCanExecuteChanged();
}
}
}
public int ArduinoBaudRate
{
get => _arduinoBaudRate;
set => SetProperty(ref _arduinoBaudRate, value);
}
public string? SelectedArduinoTest
{
get => _selectedArduinoTest;
set => SetProperty(ref _selectedArduinoTest, value);
}
public ICommand BrowseBootloaderCommand { get; }
public ICommand BrowseAppCommand { get; }
public ICommand FlashImageCommand { get; }
public ICommand ArduinoConnectCommand { get; }
public ICommand ArduinoResetCommand { get; }
public ICommand ArduinoDownloadCommand { get; }
public ICommand ArduinoNormalCommand { get; }
public ICommand ArduinoTestModeCommand { get; }
private void PickFile(Action<string> setPath)
{
var dialog = new OpenFileDialog
{
Filter = "Binary Files (*.bin)|*.bin|All Files (*.*)|*.*",
Multiselect = false
};
if (dialog.ShowDialog() == true)
{
setPath(dialog.FileName);
}
}
private void FlashImage()
{
AmebaConsole.LogLines.Add($"[FLASH] Bootloader: {Path.GetFileName(BootloaderPath)}, App: {Path.GetFileName(ApplicationPath)}");
AmebaConsole.LogLines.Add("[FLASH] (UI-only) Flash operation queued.");
}
private void ToggleArduinoConnection()
{
IsArduinoConnected = !IsArduinoConnected;
var state = IsArduinoConnected ? "connected" : "disconnected";
AmebaConsole.LogLines.Add($"[ARDUINO] {state} on {SelectedArduinoPort} @ {ArduinoBaudRate} baud (UI placeholder).");
}
private void LogArduinoAction(string action)
{
AmebaConsole.LogLines.Add($"[ARDUINO] {action} triggered.");
}
}

View File

@ -0,0 +1,63 @@
using System.Collections.ObjectModel;
using System.Linq;
namespace AmebaPro3_ControlPanel.ViewModels;
public class MainWindowViewModel : ViewModelBase
{
private PageViewModelBase? _currentPage;
private string _selectedDevice = "AmebaPro3 (virtual)";
private string _connectionStatus = "Disconnected";
private string _lastRxTime = "N/A";
private long _rxBytes;
private long _txBytes;
public MainWindowViewModel()
{
Pages = new ObservableCollection<PageViewModelBase>
{
new MainPageViewModel(),
new JLinkPageViewModel()
};
CurrentPage = Pages.FirstOrDefault();
}
public ObservableCollection<PageViewModelBase> Pages { get; }
public PageViewModelBase? CurrentPage
{
get => _currentPage;
set => SetProperty(ref _currentPage, value);
}
public string SelectedDevice
{
get => _selectedDevice;
set => SetProperty(ref _selectedDevice, value);
}
public string ConnectionStatus
{
get => _connectionStatus;
set => SetProperty(ref _connectionStatus, value);
}
public string LastRxTime
{
get => _lastRxTime;
set => SetProperty(ref _lastRxTime, value);
}
public long RxBytes
{
get => _rxBytes;
set => SetProperty(ref _rxBytes, value);
}
public long TxBytes
{
get => _txBytes;
set => SetProperty(ref _txBytes, value);
}
}

View File

@ -0,0 +1,12 @@
namespace AmebaPro3_ControlPanel.ViewModels;
public abstract class PageViewModelBase : ViewModelBase
{
private string _title = string.Empty;
public string Title
{
get => _title;
protected set => SetProperty(ref _title, value);
}
}

View File

@ -0,0 +1,167 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading;
using System.Windows.Input;
using AmebaPro3_ControlPanel.Services.Uart;
using AmebaPro3_ControlPanel.Utilities;
namespace AmebaPro3_ControlPanel.ViewModels;
public class UartConsoleViewModel : ConsolePanelViewModel
{
private string? _selectedPort;
private int _baudRate;
private readonly PythonUartSession _uartSession;
private bool _isConnecting;
public UartConsoleViewModel(string title, int defaultBaudRate) : base(title)
{
_baudRate = defaultBaudRate;
_uartSession = new PythonUartSession(GetUartBridgePath(), SynchronizationContext.Current);
_uartSession.LineReceived += line => LogLines.Add(line);
_uartSession.ErrorReceived += line => LogLines.Add($"[UART ERROR] {line}");
_uartSession.Exited += OnSessionExited;
ConnectCommand = new RelayCommand(_ => ConnectAsync(), _ => !IsConnected && !_isConnecting && !string.IsNullOrWhiteSpace(SelectedPort));
DisconnectCommand = new RelayCommand(_ => Disconnect(), _ => IsConnected);
OpenTeraTermCommand = new RelayCommand(_ => OpenTeraTerm(), _ => !string.IsNullOrWhiteSpace(SelectedPort));
}
public ObservableCollection<string> AvailablePorts { get; } = new();
public string? SelectedPort
{
get => _selectedPort;
set
{
if (SetProperty(ref _selectedPort, value))
{
(ConnectCommand as RelayCommand)?.RaiseCanExecuteChanged();
(OpenTeraTermCommand as RelayCommand)?.RaiseCanExecuteChanged();
}
}
}
public int BaudRate
{
get => _baudRate;
set => SetProperty(ref _baudRate, value);
}
public ICommand ConnectCommand { get; }
public ICommand DisconnectCommand { get; }
public ICommand OpenTeraTermCommand { get; }
public void SetPorts(IEnumerable<string> ports)
{
AvailablePorts.Clear();
foreach (var port in ports)
{
AvailablePorts.Add(port);
}
if (string.IsNullOrWhiteSpace(SelectedPort))
{
SelectedPort = AvailablePorts.FirstOrDefault();
}
}
protected override void SendCurrentCommand(bool addToHistory)
{
if (string.IsNullOrWhiteSpace(CurrentCommand))
{
return;
}
var command = CurrentCommand.Trim();
base.SendCurrentCommand(addToHistory);
if (IsConnected)
{
_ = _uartSession.SendAsync(command);
}
else
{
LogLines.Add("[UART] Not connected.");
}
}
private async void ConnectAsync()
{
if (_isConnecting || string.IsNullOrWhiteSpace(SelectedPort))
{
return;
}
_isConnecting = true;
(ConnectCommand as RelayCommand)?.RaiseCanExecuteChanged();
LogLines.Add($"[UART] Connecting to {SelectedPort} @ {BaudRate}...");
var started = await _uartSession.StartAsync(SelectedPort, BaudRate);
if (started)
{
IsConnected = true;
LogLines.Add($"[UART] Connected to {SelectedPort} @ {BaudRate}.");
}
else
{
LogLines.Add("[UART] Failed to start session. Ensure Python + pyserial are installed.");
}
_isConnecting = false;
(DisconnectCommand as RelayCommand)?.RaiseCanExecuteChanged();
(ConnectCommand as RelayCommand)?.RaiseCanExecuteChanged();
}
private void Disconnect()
{
_ = DisconnectAsync();
}
private async System.Threading.Tasks.Task DisconnectAsync()
{
IsConnected = false;
_isConnecting = false;
(DisconnectCommand as RelayCommand)?.RaiseCanExecuteChanged();
(ConnectCommand as RelayCommand)?.RaiseCanExecuteChanged();
await _uartSession.StopAsync();
LogLines.Add("[UART] Disconnected.");
}
private void OnSessionExited()
{
if (!IsConnected)
{
return;
}
IsConnected = false;
_isConnecting = false;
LogLines.Add("[UART] Session closed.");
(DisconnectCommand as RelayCommand)?.RaiseCanExecuteChanged();
(ConnectCommand as RelayCommand)?.RaiseCanExecuteChanged();
}
private void OpenTeraTerm()
{
if (TeraTermLauncher.TryLaunch(SelectedPort, BaudRate, out var message))
{
LogLines.Add($"[UART] {message}");
}
else
{
LogLines.Add($"[UART] {message}");
}
}
private static string GetUartBridgePath()
{
return Path.Combine(AppContext.BaseDirectory, "Scripts", "uart_bridge.py");
}
}

View File

@ -0,0 +1,27 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace AmebaPro3_ControlPanel.ViewModels;
public abstract class ViewModelBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected void RaisePropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
protected bool SetProperty<T>(ref T storage, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(storage, value))
{
return false;
}
storage = value;
RaisePropertyChanged(propertyName);
return true;
}
}

View File

@ -0,0 +1,216 @@
<UserControl x:Class="AmebaPro3_ControlPanel.Views.Controls.ConsolePanel"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignHeight="450"
d:DesignWidth="800">
<Border Style="{StaticResource CardStyle}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <!-- Header with custom controls -->
<RowDefinition Height="*"/> <!-- Log -->
<RowDefinition Height="Auto"/> <!-- GridSplitter -->
<RowDefinition Height="*" MinHeight="140"/> <!-- History -->
<RowDefinition Height="Auto"/> <!-- Command buttons slot -->
<RowDefinition Height="Auto"/> <!-- Actions row -->
<RowDefinition Height="Auto"/> <!-- Input -->
</Grid.RowDefinitions>
<!-- Header -->
<Grid Grid.Row="0" Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Ellipse Width="10"
Height="10"
VerticalAlignment="Center"
Margin="0,0,10,0">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Fill" Value="{StaticResource DangerBrush}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsConnected}" Value="True">
<Setter Property="Fill" Value="{StaticResource SuccessBrush}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<StackPanel>
<TextBlock Text="{Binding Title}"
FontSize="16"
FontWeight="SemiBold"/>
<TextBlock FontSize="12">
<TextBlock.Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource CaptionTextStyle}">
<Setter Property="Text" Value="Disconnected"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsConnected}" Value="True">
<Setter Property="Text" Value="Connected"/>
<Setter Property="Foreground" Value="{StaticResource SuccessBrush}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</StackPanel>
<ContentPresenter Grid.Column="1"
VerticalAlignment="Center"
Content="{Binding RelativeSource={RelativeSource AncestorType=UserControl}, Path=HeaderContent}">
<ContentPresenter.Style>
<Style TargetType="ContentPresenter">
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Content, RelativeSource={RelativeSource Self}}" Value="{x:Null}">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</ContentPresenter.Style>
</ContentPresenter>
</Grid>
<!-- Log viewer -->
<Border Grid.Row="1"
Background="{StaticResource SurfaceAltBrush}"
BorderBrush="{StaticResource SubtleBorderBrush}"
BorderThickness="1"
CornerRadius="10">
<ListBox ItemsSource="{Binding LogLines}"
x:Name="LogListBox"
SelectionMode="Extended"
PreviewMouseWheel="LogListBox_OnPreviewMouseWheel"
PreviewMouseDown="LogListBox_OnPreviewMouseDown"
PreviewKeyDown="LogListBox_OnPreviewKeyDown"
KeyDown="LogListBox_OnKeyDown"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto"
ScrollViewer.CanContentScroll="True">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}"
FontFamily="Cascadia Code, Consolas, Segoe UI Mono"
FontSize="12"
TextWrapping="NoWrap"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<!-- GridSplitter for resizing history -->
<GridSplitter Grid.Row="2"
Height="4"
HorizontalAlignment="Stretch"
VerticalAlignment="Top"
Background="{StaticResource SubtleBorderBrush}"
ResizeDirection="Rows"
ResizeBehavior="PreviousAndNext"
Margin="0,10,0,0"
ShowsPreview="True"/>
<!-- Command history -->
<Border Grid.Row="3"
Margin="0,14,0,0"
Background="{StaticResource SurfaceAltBrush}"
BorderBrush="{StaticResource SubtleBorderBrush}"
BorderThickness="1"
CornerRadius="10">
<ListBox x:Name="HistoryListBox"
ItemsSource="{Binding History}"
SelectionMode="Extended"
SelectionChanged="HistoryListBox_OnSelectionChanged"
MouseDoubleClick="HistoryListBox_OnMouseDoubleClick"
PreviewMouseWheel="HistoryListBox_OnPreviewMouseWheel"
PreviewMouseDown="HistoryListBox_OnPreviewMouseDown"
PreviewKeyDown="HistoryListBox_OnPreviewKeyDown"
KeyDown="HistoryListBox_OnKeyDown"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
ScrollViewer.VerticalScrollBarVisibility="Auto"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.CanContentScroll="True">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" FontSize="12"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<!-- Optional command buttons slot -->
<ContentPresenter Grid.Row="4"
Margin="0,10,0,0"
Content="{Binding RelativeSource={RelativeSource AncestorType=UserControl}, Path=CommandButtonsContent}">
<ContentPresenter.Style>
<Style TargetType="ContentPresenter">
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Content, RelativeSource={RelativeSource Self}}" Value="{x:Null}">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</ContentPresenter.Style>
</ContentPresenter>
<!-- Actions row -->
<StackPanel Grid.Row="5"
Orientation="Horizontal"
HorizontalAlignment="Right"
Margin="0,10,0,0">
<Button Content="Clear"
Command="{Binding ClearLog}"
Style="{StaticResource GhostButtonStyle}"
Width="120"
Margin="0,0,8,0"/>
<Button Content="Save"
Command="{Binding SaveLog}"
Style="{StaticResource GhostButtonStyle}"
Width="120"
Margin="0,0,8,0"/>
<Button Content="Load Commands"
Command="{Binding LoadCommandFileCommand}"
Style="{StaticResource GhostButtonStyle}"
Width="140"/>
</StackPanel>
<!-- Input row -->
<Grid Grid.Row="6" Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="CommandInput"
Grid.Column="0"
Text="{Binding CurrentCommand, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
KeyDown="CommandInput_OnKeyDown"/>
<Button Grid.Column="2"
Content="Send"
Command="{Binding SendCommand}"
Style="{StaticResource PrimaryButtonStyle}"
MinWidth="96"/>
</Grid>
</Grid>
</Border>
</UserControl>

View File

@ -0,0 +1,482 @@
using System.Collections.Specialized;
using System.Linq;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
using AmebaPro3_ControlPanel.ViewModels;
namespace AmebaPro3_ControlPanel.Views.Controls;
public partial class ConsolePanel : UserControl
{
public static readonly DependencyProperty HeaderContentProperty =
DependencyProperty.Register(nameof(HeaderContent), typeof(UIElement), typeof(ConsolePanel), new PropertyMetadata(null));
public static readonly DependencyProperty CommandButtonsContentProperty =
DependencyProperty.Register(nameof(CommandButtonsContent), typeof(UIElement), typeof(ConsolePanel), new PropertyMetadata(null));
public UIElement? HeaderContent
{
get => (UIElement?)GetValue(HeaderContentProperty);
set => SetValue(HeaderContentProperty, value);
}
public UIElement? CommandButtonsContent
{
get => (UIElement?)GetValue(CommandButtonsContentProperty);
set => SetValue(CommandButtonsContentProperty, value);
}
private bool _isAutoScrolling = true;
private ScrollViewer? _logScrollViewer;
private bool _isProgrammaticScroll = false;
private bool _userScrollIntent = false;
private bool _isHistoryAutoScrolling = true;
private ScrollViewer? _historyScrollViewer;
private bool _isHistoryProgrammaticScroll = false;
private bool _historyUserScrollIntent = false;
public ConsolePanel()
{
InitializeComponent();
Loaded += ConsolePanel_Loaded;
}
private void ConsolePanel_Loaded(object sender, RoutedEventArgs e)
{
if (DataContext is ConsolePanelViewModel vm)
{
vm.LogLines.CollectionChanged += LogLines_CollectionChanged;
vm.History.CollectionChanged += History_CollectionChanged;
// Scroll to bottom if there are already items
if (vm.LogLines.Count > 0)
{
Dispatcher.BeginInvoke(new System.Action(() =>
{
if (_logScrollViewer != null)
{
_isProgrammaticScroll = true;
_logScrollViewer.ScrollToEnd();
_isProgrammaticScroll = false;
}
else if (LogListBox.Items.Count > 0)
{
LogListBox.ScrollIntoView(LogListBox.Items[LogListBox.Items.Count - 1]);
}
}), System.Windows.Threading.DispatcherPriority.Loaded);
}
if (vm.History.Count > 0)
{
Dispatcher.BeginInvoke(new System.Action(() =>
{
if (_historyScrollViewer != null)
{
_isHistoryProgrammaticScroll = true;
_historyScrollViewer.ScrollToEnd();
_isHistoryProgrammaticScroll = false;
}
}), System.Windows.Threading.DispatcherPriority.Loaded);
}
}
// Find the ScrollViewer in the LogListBox
_logScrollViewer = FindScrollViewer(LogListBox);
if (_logScrollViewer != null)
{
_logScrollViewer.ScrollChanged += LogScrollViewer_ScrollChanged;
// Initialize auto-scroll state
_isAutoScrolling = IsAtBottom(_logScrollViewer);
}
_historyScrollViewer = FindScrollViewer(HistoryListBox);
if (_historyScrollViewer != null)
{
_historyScrollViewer.ScrollChanged += HistoryScrollViewer_ScrollChanged;
_isHistoryAutoScrolling = IsAtBottom(_historyScrollViewer);
}
}
private void LogLines_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.Action == NotifyCollectionChangedAction.Add)
{
// Ensure ScrollViewer is found (might not be available immediately)
if (_logScrollViewer == null)
{
_logScrollViewer = FindScrollViewer(LogListBox);
if (_logScrollViewer != null)
{
_logScrollViewer.ScrollChanged += LogScrollViewer_ScrollChanged;
}
}
// Check if we should auto-scroll (verify current position before scrolling)
bool shouldAutoScroll = _isAutoScrolling && !_userScrollIntent;
if (_logScrollViewer != null && !_userScrollIntent)
{
shouldAutoScroll = IsAtBottom(_logScrollViewer);
}
if (shouldAutoScroll)
{
// Scroll to the bottom after UI updates
Dispatcher.BeginInvoke(new System.Action(() =>
{
if (_logScrollViewer != null)
{
_isProgrammaticScroll = true;
_logScrollViewer.ScrollToEnd();
_isProgrammaticScroll = false;
}
else
{
// Fallback: try to find ScrollViewer again and scroll
_logScrollViewer = FindScrollViewer(LogListBox);
if (_logScrollViewer != null)
{
_logScrollViewer.ScrollChanged += LogScrollViewer_ScrollChanged;
_isProgrammaticScroll = true;
_logScrollViewer.ScrollToEnd();
_isProgrammaticScroll = false;
}
else if (LogListBox.Items.Count > 0)
{
// Last resort: use ScrollIntoView
LogListBox.ScrollIntoView(LogListBox.Items[LogListBox.Items.Count - 1]);
}
}
}), System.Windows.Threading.DispatcherPriority.Loaded);
}
}
}
private void LogScrollViewer_ScrollChanged(object sender, ScrollChangedEventArgs e)
{
if (_logScrollViewer == null) return;
// Ignore scroll changes caused by programmatic scrolling
if (_isProgrammaticScroll)
{
return;
}
if (e.VerticalChange != 0)
{
_userScrollIntent = false;
}
if (!_userScrollIntent)
{
_isAutoScrolling = IsAtBottom(_logScrollViewer);
}
}
private void History_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.Action == NotifyCollectionChangedAction.Add)
{
if (_historyScrollViewer == null)
{
_historyScrollViewer = FindScrollViewer(HistoryListBox);
if (_historyScrollViewer != null)
{
_historyScrollViewer.ScrollChanged += HistoryScrollViewer_ScrollChanged;
}
}
bool shouldAutoScroll = _isHistoryAutoScrolling && !_historyUserScrollIntent;
if (_historyScrollViewer != null && !_historyUserScrollIntent)
{
shouldAutoScroll = IsAtBottom(_historyScrollViewer);
}
if (shouldAutoScroll)
{
Dispatcher.BeginInvoke(new System.Action(() =>
{
if (_historyScrollViewer != null)
{
_isHistoryProgrammaticScroll = true;
_historyScrollViewer.ScrollToEnd();
_isHistoryProgrammaticScroll = false;
}
}), System.Windows.Threading.DispatcherPriority.Loaded);
}
}
}
private void HistoryScrollViewer_ScrollChanged(object sender, ScrollChangedEventArgs e)
{
if (_historyScrollViewer == null) return;
if (_isHistoryProgrammaticScroll)
{
return;
}
if (e.VerticalChange != 0)
{
_historyUserScrollIntent = false;
}
if (!_historyUserScrollIntent)
{
_isHistoryAutoScrolling = IsAtBottom(_historyScrollViewer);
}
}
private static bool IsAtBottom(ScrollViewer scrollViewer)
{
return scrollViewer.ExtentHeight <= scrollViewer.ViewportHeight
|| (scrollViewer.VerticalOffset + scrollViewer.ViewportHeight >= scrollViewer.ExtentHeight - 5);
}
private static ScrollViewer? FindScrollViewer(DependencyObject parent)
{
for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
if (child is ScrollViewer scrollViewer)
{
return scrollViewer;
}
var result = FindScrollViewer(child);
if (result != null)
{
return result;
}
}
return null;
}
private void LogListBox_OnPreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
MarkUserScrollIntent();
}
private void LogListBox_OnPreviewMouseDown(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton == MouseButton.Left || e.ChangedButton == MouseButton.Middle || e.ChangedButton == MouseButton.Right)
{
MarkUserScrollIntent();
}
}
private void LogListBox_OnPreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key is Key.Up or Key.Down or Key.PageUp or Key.PageDown or Key.Home or Key.End)
{
MarkUserScrollIntent();
}
}
private void MarkUserScrollIntent()
{
_userScrollIntent = true;
if (_logScrollViewer == null)
{
_logScrollViewer = FindScrollViewer(LogListBox);
if (_logScrollViewer != null)
{
_logScrollViewer.ScrollChanged += LogScrollViewer_ScrollChanged;
}
}
Dispatcher.BeginInvoke(new System.Action(() =>
{
if (_logScrollViewer == null)
{
return;
}
if (_userScrollIntent)
{
_userScrollIntent = false;
_isAutoScrolling = IsAtBottom(_logScrollViewer);
}
}), System.Windows.Threading.DispatcherPriority.Background);
}
private void HistoryListBox_OnPreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
MarkHistoryUserScrollIntent();
}
private void HistoryListBox_OnPreviewMouseDown(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton == MouseButton.Left || e.ChangedButton == MouseButton.Middle || e.ChangedButton == MouseButton.Right)
{
MarkHistoryUserScrollIntent();
}
}
private void HistoryListBox_OnPreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key is Key.Up or Key.Down or Key.PageUp or Key.PageDown or Key.Home or Key.End)
{
MarkHistoryUserScrollIntent();
}
}
private void MarkHistoryUserScrollIntent()
{
_historyUserScrollIntent = true;
if (_historyScrollViewer == null)
{
_historyScrollViewer = FindScrollViewer(HistoryListBox);
if (_historyScrollViewer != null)
{
_historyScrollViewer.ScrollChanged += HistoryScrollViewer_ScrollChanged;
}
}
Dispatcher.BeginInvoke(new System.Action(() =>
{
if (_historyScrollViewer == null)
{
return;
}
if (_historyUserScrollIntent)
{
_historyUserScrollIntent = false;
_isHistoryAutoScrolling = IsAtBottom(_historyScrollViewer);
}
}), System.Windows.Threading.DispatcherPriority.Background);
}
private void HistoryListBox_OnSelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (DataContext is ConsolePanelViewModel vm)
{
// Only update CurrentCommand if single item is selected
if (HistoryListBox.SelectedItems.Count == 1 && HistoryListBox.SelectedItem is string selected)
{
vm.CurrentCommand = selected;
}
else if (HistoryListBox.SelectedItems.Count == 0)
{
vm.CurrentCommand = string.Empty;
}
// For multi-select, don't update CurrentCommand
}
}
private void HistoryListBox_OnMouseDoubleClick(object sender, MouseButtonEventArgs e)
{
if (DataContext is ConsolePanelViewModel vm && HistoryListBox.SelectedItem is string selected)
{
vm.SendFromHistory(selected);
}
}
private void CommandInput_OnKeyDown(object sender, KeyEventArgs e)
{
if (DataContext is not ConsolePanelViewModel vm)
{
return;
}
if (e.Key == Key.Enter)
{
if (vm.SendCommand.CanExecute(null))
{
vm.SendCommand.Execute(null);
HistoryListBox.SelectedIndex = -1;
}
e.Handled = true;
}
else if (e.Key == Key.Up)
{
if (HistoryListBox.Items.Count == 0)
{
return;
}
if (HistoryListBox.SelectedIndex < 0)
{
HistoryListBox.SelectedIndex = HistoryListBox.Items.Count - 1;
}
else if (HistoryListBox.SelectedIndex > 0)
{
HistoryListBox.SelectedIndex -= 1;
}
e.Handled = true;
}
else if (e.Key == Key.Down)
{
if (HistoryListBox.Items.Count == 0)
{
return;
}
if (HistoryListBox.SelectedIndex >= 0 && HistoryListBox.SelectedIndex < HistoryListBox.Items.Count - 1)
{
HistoryListBox.SelectedIndex += 1;
}
else
{
HistoryListBox.SelectedIndex = -1;
vm.CurrentCommand = string.Empty;
}
e.Handled = true;
}
}
private void HistoryListBox_OnKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Delete && DataContext is ConsolePanelViewModel vm)
{
var selectedItems = HistoryListBox.SelectedItems.Cast<string>().ToList();
if (selectedItems.Count > 0)
{
vm.DeleteHistoryItems(selectedItems);
// Clear selection after deletion
HistoryListBox.SelectedIndex = -1;
e.Handled = true;
}
}
}
private void LogListBox_OnKeyDown(object sender, KeyEventArgs e)
{
if (DataContext is not ConsolePanelViewModel vm)
{
return;
}
if (e.Key == Key.Delete)
{
var selectedItems = LogListBox.SelectedItems.Cast<string>().ToList();
if (selectedItems.Count > 0)
{
vm.DeleteLogLines(selectedItems);
// Clear selection after deletion
LogListBox.SelectedIndex = -1;
e.Handled = true;
}
}
else if (e.Key == Key.C && (Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control)
{
// Copy selected items to clipboard
var selectedItems = LogListBox.SelectedItems.Cast<string>().ToList();
if (selectedItems.Count > 0)
{
var clipboardText = string.Join("\r\n", selectedItems);
Clipboard.SetText(clipboardText);
e.Handled = true;
}
}
}
}

137
Views/MainWindow.xaml Normal file
View File

@ -0,0 +1,137 @@
<Window x:Class="AmebaPro3_ControlPanel.Views.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
Title="AmebaPro3 Control Panel"
Height="850"
Width="1400"
WindowStartupLocation="CenterScreen"
ClipToBounds="False">
<Grid x:Name="MainRootGrid" ClipToBounds="False">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Top bar -->
<Border x:Name="TopBarBorder" Grid.Row="0"
Background="{StaticResource SurfaceAltBrush}"
BorderBrush="{StaticResource SubtleBorderBrush}"
BorderThickness="0,0,0,1"
Padding="20,12,20,12"
ClipToBounds="False">
<Grid x:Name="TopBarGrid" ClipToBounds="False">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel x:Name="TopBarStackPanel" Orientation="Vertical"
VerticalAlignment="Center"
Margin="0,0,0,0"
ClipToBounds="False">
<TextBlock Text="AmebaPro3 Control Panel"
FontSize="20"
FontWeight="Bold"/>
<TextBlock Text="Powered by yiekheng, v2.0.0"
Style="{StaticResource CaptionTextStyle}"/>
</StackPanel>
<ListBox x:Name="TopNavListBox" Grid.Column="1"
VerticalAlignment="Center"
Margin="24,0,0,0"
ItemsSource="{Binding Pages}"
SelectedItem="{Binding CurrentPage}"
Style="{StaticResource TopNavListBoxStyle}"
ItemContainerStyle="{StaticResource TopNavListBoxItemStyle}">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Title}"
Padding="18,8"
FontWeight="SemiBold"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Border>
<!-- Page content -->
<Border Grid.Row="1"
Margin="20,16"
Padding="0"
Background="Transparent">
<ContentControl x:Name="MainContentHost"
Content="{Binding CurrentPage}"/>
</Border>
<!-- Status bar -->
<Border Grid.Row="2"
Background="{StaticResource SurfaceAltBrush}"
BorderBrush="{StaticResource SubtleBorderBrush}"
BorderThickness="1,1,1,0"
Padding="20,8">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="{Binding SelectedDevice}"
FontWeight="SemiBold"/>
<TextBlock Text=" • "
Margin="6,0"
Foreground="{StaticResource TextBrushMuted}"/>
<TextBlock Text="{Binding ConnectionStatus}"
Foreground="{StaticResource TextBrushSecondary}"/>
</StackPanel>
<StackPanel Grid.Column="1"
Orientation="Horizontal"
VerticalAlignment="Center"
Margin="16,0">
<TextBlock Text="Last RX:"
Style="{StaticResource CaptionTextStyle}"/>
<TextBlock Text="{Binding LastRxTime}"
Margin="6,0,0,0"/>
</StackPanel>
<StackPanel Grid.Column="2"
Orientation="Horizontal"
VerticalAlignment="Center"
Margin="16,0">
<TextBlock Text="RX:"
Style="{StaticResource CaptionTextStyle}"/>
<TextBlock Text="{Binding RxBytes}"
Margin="6,0,0,0"/>
<TextBlock Text="bytes"
Margin="4,0,0,0"
Style="{StaticResource CaptionTextStyle}"/>
</StackPanel>
<StackPanel Grid.Column="3"
Orientation="Horizontal"
VerticalAlignment="Center"
Margin="16,0">
<TextBlock Text="TX:"
Style="{StaticResource CaptionTextStyle}"/>
<TextBlock Text="{Binding TxBytes}"
Margin="6,0,0,0"/>
<TextBlock Text="bytes"
Margin="4,0,0,0"
Style="{StaticResource CaptionTextStyle}"/>
</StackPanel>
</Grid>
</Border>
</Grid>
</Window>

13
Views/MainWindow.xaml.cs Normal file
View File

@ -0,0 +1,13 @@
using System.Windows;
using AmebaPro3_ControlPanel.ViewModels;
namespace AmebaPro3_ControlPanel.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
DataContext = new MainWindowViewModel();
}
}

View File

@ -0,0 +1,70 @@
<UserControl x:Class="AmebaPro3_ControlPanel.Views.Pages.JLinkPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignHeight="720"
d:DesignWidth="1280">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="280"/>
</Grid.ColumnDefinitions>
<!-- Left workspace -->
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0"
Orientation="Horizontal"
Margin="0,0,0,12">
<ComboBox Width="220"
ItemsSource="{Binding AvailableSerialNumbers}"
SelectedItem="{Binding SelectedSerialNumber}"
Margin="0,0,8,0"/>
<Button Content="Refresh"
Command="{Binding RefreshSerialsCommand}"
Style="{StaticResource GhostButtonStyle}"
Width="120"
Margin="0,0,8,0"/>
<Button Content="Connect"
Command="{Binding ConnectJLinkCommand}"
Style="{StaticResource PrimaryButtonStyle}"
Width="140"/>
</StackPanel>
<Border Grid.Row="1"
Background="Transparent"
BorderBrush="{StaticResource SubtleBorderBrush}"
BorderThickness="1"
CornerRadius="10">
<ContentControl Content="{Binding SelectedApSession}"/>
</Border>
</Grid>
<!-- AP selector -->
<Border Grid.Column="1"
Margin="12,0,0,0"
Style="{StaticResource CardStyle}">
<StackPanel>
<TextBlock Text="AP Targets"
Style="{StaticResource SectionHeaderTextStyle}"/>
<ListBox x:Name="ApSelectorListBox"
ItemsSource="{Binding ApSessions}"
SelectedItem="{Binding SelectedApSession}"
ItemContainerStyle="{StaticResource SelectorListBoxItemStyle}">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Title}"
FontWeight="SemiBold"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</StackPanel>
</Border>
</Grid>
</UserControl>

View File

@ -0,0 +1,11 @@
using System.Windows.Controls;
namespace AmebaPro3_ControlPanel.Views.Pages;
public partial class JLinkPage : UserControl
{
public JLinkPage()
{
InitializeComponent();
}
}

184
Views/Pages/MainPage.xaml Normal file
View File

@ -0,0 +1,184 @@
<UserControl x:Class="AmebaPro3_ControlPanel.Views.Pages.MainPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:AmebaPro3_ControlPanel.Views.Controls"
mc:Ignorable="d"
d:DesignHeight="720"
d:DesignWidth="1280">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="480"/>
</Grid.ColumnDefinitions>
<!-- AmebaPro3 UART console -->
<controls:ConsolePanel DataContext="{Binding AmebaConsole}">
<controls:ConsolePanel.HeaderContent>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<ComboBox Width="160"
ItemsSource="{Binding AvailablePorts}"
SelectedItem="{Binding SelectedPort}"
Margin="0,0,8,0"/>
<TextBox Width="150"
Text="{Binding BaudRate}"
Margin="0,0,8,0"/>
<Button Content="Connect"
Command="{Binding ConnectCommand}"
Width="140"
Margin="0,0,8,0">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource BaseButtonStyle}">
<Setter Property="Content" Value="Connect"/>
<Setter Property="Command" Value="{Binding ConnectCommand}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsConnected}" Value="True">
<Setter Property="Content" Value="Disconnect"/>
<Setter Property="Command" Value="{Binding DisconnectCommand}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<Button Content="TeraTerm"
Command="{Binding OpenTeraTermCommand}"
Width="120"
Style="{StaticResource BaseButtonStyle}"/>
</StackPanel>
</controls:ConsolePanel.HeaderContent>
</controls:ConsolePanel>
<!-- Right stack -->
<Grid Grid.Column="1" Margin="12,0,0,0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="8"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Flash Image Card -->
<Border Style="{StaticResource CardStyle}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Text="Flash Image"
Style="{StaticResource SectionHeaderTextStyle}"/>
<Grid Grid.Row="1" Margin="0,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="BootloaderPathTextBox"
Grid.Column="0"
IsReadOnly="True"
Text="{Binding BootloaderPath}"
Margin="0,0,8,0"/>
<Button x:Name="BrowseBootloaderButton"
Grid.Column="1"
Content="Browse"
Width="110"
Command="{Binding BrowseBootloaderCommand}"
Style="{StaticResource BaseButtonStyle}"/>
</Grid>
<Grid Grid.Row="2" Margin="0,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="AppPathTextBox"
Grid.Column="0"
IsReadOnly="True"
Text="{Binding ApplicationPath}"
Margin="0,0,8,0"/>
<Button x:Name="BrowseAppButton"
Grid.Column="1"
Content="Browse"
Width="110"
Command="{Binding BrowseAppCommand}"
Style="{StaticResource BaseButtonStyle}"/>
</Grid>
<Button x:Name="FlashImageButton"
Grid.Row="3"
Content="Flash Image"
Command="{Binding FlashImageCommand}"
Style="{StaticResource PrimaryButtonStyle}"
Margin="0,12,0,0"/>
</Grid>
</Border>
<!-- Arduino Controller Card -->
<Border Grid.Row="2" Style="{StaticResource CardStyle}">
<StackPanel>
<TextBlock Text="Arduino Controller"
Style="{StaticResource SectionHeaderTextStyle}"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<ComboBox x:Name="ArduinoComComboBox"
Width="160"
ItemsSource="{Binding ArduinoPorts}"
SelectedItem="{Binding SelectedArduinoPort}"
Margin="0,0,8,0"/>
<TextBox x:Name="ArduinoBaudTextBox"
Width="140"
Text="{Binding ArduinoBaudRate}"
Margin="0,0,8,0"/>
<Button x:Name="ArduinoConnectButton"
Width="130"
Content="Connect"
Command="{Binding ArduinoConnectCommand}">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource BaseButtonStyle}">
<Setter Property="Content" Value="Connect"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsArduinoConnected}" Value="True">
<Setter Property="Content" Value="Disconnect"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
</StackPanel>
<UniformGrid Columns="4" Margin="0,0,0,8">
<Button x:Name="ArduinoResetButton"
Content="Reset"
Command="{Binding ArduinoResetCommand}"
Style="{StaticResource BaseButtonStyle}"
Margin="0,0,8,0"/>
<Button x:Name="ArduinoDownloadButton"
Content="Download Mode"
Command="{Binding ArduinoDownloadCommand}"
Style="{StaticResource BaseButtonStyle}"
Margin="0,0,8,0"/>
<Button x:Name="ArduinoNormalButton"
Content="Normal Mode"
Command="{Binding ArduinoNormalCommand}"
Style="{StaticResource BaseButtonStyle}"
Margin="0,0,8,0"/>
<Button x:Name="ArduinoTestModeButton"
Content="Test Mode"
Command="{Binding ArduinoTestModeCommand}"
Style="{StaticResource BaseButtonStyle}"/>
</UniformGrid>
<StackPanel Orientation="Vertical">
<TextBlock Text="Test mode"
Style="{StaticResource CaptionTextStyle}"/>
<ComboBox x:Name="ArduinoTestSelectComboBox"
ItemsSource="{Binding ArduinoTests}"
SelectedItem="{Binding SelectedArduinoTest}"
Margin="0,4,0,0"/>
</StackPanel>
</StackPanel>
</Border>
</Grid>
</Grid>
</UserControl>

View File

@ -0,0 +1,11 @@
using System.Windows.Controls;
namespace AmebaPro3_ControlPanel.Views.Pages;
public partial class MainPage : UserControl
{
public MainPage()
{
InitializeComponent();
}
}