Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
560 changes: 501 additions & 59 deletions src/OpenIPC.Viewer.App/Controls/TerminalView.cs

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions src/OpenIPC.Viewer.App/Services/Localizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,20 @@ private static LangCode DetectSystem()
["Settings.Ssh.StrictHostKey"] = "Strict host-key checking",
["Settings.Ssh.DefaultPort"] = "Default SSH port",
["Settings.Ssh.TerminalFontSize"] = "Terminal font size",
["Settings.Ssh.Theme"] = "Terminal theme",
["Settings.Ssh.Theme.default"] = "Classic",
["Settings.Ssh.Theme.gruvbox-dark"] = "Gruvbox Dark",
["Settings.Ssh.Theme.dracula"] = "Dracula",
["Settings.Ssh.Theme.solarized-dark"] = "Solarized Dark",
["Settings.Ssh.Theme.solarized-light"] = "Solarized Light",
["Settings.Ssh.Theme.phosphor"] = "Green phosphor",
["Settings.Ssh.CursorStyle"] = "Cursor",
["Settings.Ssh.Cursor.Outline"] = "Outline",
["Settings.Ssh.Cursor.Block"] = "Block",
["Settings.Ssh.Cursor.Bar"] = "Bar",
["Settings.Ssh.Cursor.Underline"] = "Underline",
["Settings.Ssh.CursorBlink"] = "Blinking cursor",
["Settings.Ssh.Scrollback"] = "History lines",
["Settings.Ssh.MajesticPath"] = "majestic.yaml path",
["Settings.Ssh.ResetHostKeys"] = "Forget all host keys",
["Settings.Ssh.HostKeysCleared"] = "All pinned host keys were forgotten.",
Expand Down Expand Up @@ -899,6 +913,20 @@ private static LangCode DetectSystem()
["Settings.Ssh.StrictHostKey"] = "Строгая проверка host-key",
["Settings.Ssh.DefaultPort"] = "SSH-порт по умолчанию",
["Settings.Ssh.TerminalFontSize"] = "Размер шрифта терминала",
["Settings.Ssh.Theme"] = "Тема терминала",
["Settings.Ssh.Theme.default"] = "Классическая",
["Settings.Ssh.Theme.gruvbox-dark"] = "Gruvbox Dark",
["Settings.Ssh.Theme.dracula"] = "Dracula",
["Settings.Ssh.Theme.solarized-dark"] = "Solarized Dark",
["Settings.Ssh.Theme.solarized-light"] = "Solarized Light",
["Settings.Ssh.Theme.phosphor"] = "Зелёный люминофор",
["Settings.Ssh.CursorStyle"] = "Курсор",
["Settings.Ssh.Cursor.Outline"] = "Контур",
["Settings.Ssh.Cursor.Block"] = "Блок",
["Settings.Ssh.Cursor.Bar"] = "Черта",
["Settings.Ssh.Cursor.Underline"] = "Подчёркивание",
["Settings.Ssh.CursorBlink"] = "Мигающий курсор",
["Settings.Ssh.Scrollback"] = "Строк в истории",
["Settings.Ssh.MajesticPath"] = "Путь к majestic.yaml",
["Settings.Ssh.ResetHostKeys"] = "Забыть все host-ключи",
["Settings.Ssh.HostKeysCleared"] = "Все запомненные host-ключи забыты.",
Expand Down
6 changes: 6 additions & 0 deletions src/OpenIPC.Viewer.App/Services/UserSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ public sealed record UserSettings(
// only ever appears when the user asks for it with the key-bar button — which is what you
// want while selecting text, because the keyboard's inset resizes the grid under you.
bool SshTerminalAutoKeyboard = true,
// Terminal look: a TerminalTheme.Id, a TerminalCursorStyle name (outline = the original
// hollow box), blink, and how many rows of history the terminal keeps.
string SshTerminalTheme = "default",
string SshTerminalCursorStyle = "outline",
bool SshTerminalCursorBlink = false,
int SshTerminalScrollback = 1000,
string MajesticConfigPath = "/etc/majestic.yaml",
// Local AI analytics (Phase 15.2). "auto" lets the detector pick the
// platform execution provider with a CPU fallback; "force-cpu" pins CPU.
Expand Down
70 changes: 69 additions & 1 deletion src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using OpenIPC.Viewer.Core.Onvif.Discovery;
using OpenIPC.Viewer.Core.Platform;
using OpenIPC.Viewer.Core.Ssh;
using OpenIPC.Viewer.Core.Ssh.Terminal;

namespace OpenIPC.Viewer.App.ViewModels;

Expand Down Expand Up @@ -69,10 +70,46 @@ public sealed partial class SettingsPageViewModel : ViewModelBase, IBackNavigabl
// SSH section (Phase 13).
[ObservableProperty] private bool _sshStrictHostKey = true;
[ObservableProperty] private int _sshDefaultPort = 22;
[ObservableProperty] private int _sshTerminalFontSize = 14;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(SshTerminalPreviewFontSize))]
private int _sshTerminalFontSize = 14;
[ObservableProperty] private string _majesticConfigPath = "/etc/majestic.yaml";
[ObservableProperty] private bool _hostKeysJustCleared;

// Terminal look. The preview is a fixed sample screen drawn by the real TerminalView, so
// what the user picks here is exactly what the terminal will show.
[ObservableProperty] private TerminalThemeOption? _sshTerminalTheme;
[ObservableProperty] private TerminalCursorStyleOption? _sshTerminalCursorStyle;
[ObservableProperty] private bool _sshTerminalCursorBlink;
[ObservableProperty] private int _sshTerminalScrollback = TerminalEmulator.DefaultScrollbackLimit;
public IReadOnlyList<TerminalThemeOption> TerminalThemeOptions { get; }
public IReadOnlyList<TerminalCursorStyleOption> TerminalCursorStyleOptions { get; }
public TerminalEmulator TerminalPreview { get; } = CreateTerminalPreview();
public double SshTerminalPreviewFontSize => SshTerminalFontSize;

// A prompt, a colored `ls`, both rows of the ANSI palette and the renditions — enough to
// judge a theme by. Kept within 44 columns so it fits a phone.
private static TerminalEmulator CreateTerminalPreview()
{
const string prompt = "\x1b[1;32mroot@openipc\x1b[0m:\x1b[1;34m~\x1b[0m# ";
var preview = new TerminalEmulator(44, 7);
preview.Feed(
prompt + "ls\r\n" +
"\x1b[1;34mbin\x1b[0m \x1b[1;36mlib\x1b[0m \x1b[1;32mmajestic\x1b[0m majestic.yaml\r\n" +
"\x1b[31mred \x1b[32mgreen \x1b[33myellow \x1b[34mblue \x1b[35mmagenta \x1b[36mcyan\x1b[0m\r\n" +
"\x1b[91mred \x1b[92mgreen \x1b[93myellow \x1b[94mblue \x1b[95mmagenta \x1b[96mcyan\x1b[0m\r\n" +
"\x1b[1mbold\x1b[0m \x1b[2mdim\x1b[0m \x1b[3mitalic\x1b[0m \x1b[4munderline\x1b[0m \x1b[7m inverse \x1b[0m\r\n");
for (var i = 0; i < 44; i++)
{
// A truecolor ramp, to show 24-bit output comes through.
var r = 255 - i * 255 / 43;
var b = i * 255 / 43;
preview.Feed($"\x1b[48;2;{r};64;{b}m ");
}
preview.Feed("\x1b[0m\r\n" + prompt);
return preview;
}

// Notifications (Phase 19.3).
[ObservableProperty] private bool _notificationsEnabled = true;
[ObservableProperty] private bool _notifyOnMotion = true;
Expand Down Expand Up @@ -226,6 +263,17 @@ public SettingsPageViewModel(
};
StartupLayoutOptions.Add(new StartupLayoutOption(Localizer.Instance["Settings.Appearance.StartupLayout.LastUsed"], 0));

TerminalThemeOptions = TerminalTheme.BuiltIn
.Select(t => new TerminalThemeOption(Localizer.Instance[$"Settings.Ssh.Theme.{t.Id}"], t))
.ToList();
Comment on lines +266 to +268

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. Theme labels stay in the old language 🐞 Bug ≡ Correctness

TerminalThemeOptions and TerminalCursorStyleOptions store localized display strings when the
settings view model is constructed and never rebuild them. Changing the application language while
that page remains open updates localizer-backed labels, but both new terminal pickers keep their
previous-language entries until the page is recreated.
Agent Prompt
## Issue description
The new terminal option lists retain their constructor-time language after an in-page language change.
## Fix Focus Areas
- src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs[266-275]
- src/OpenIPC.Viewer.App/ViewModels/SettingsPageViewModel.cs[345-346]
## Recommended Fix
Refresh the terminal theme and cursor option displays when the localizer changes, preserving the selected theme and style, or bind their labels directly to live localization.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

TerminalCursorStyleOptions = new[]
{
TerminalCursorStyle.Outline, TerminalCursorStyle.Block,
TerminalCursorStyle.Bar, TerminalCursorStyle.Underline,
}
.Select(c => new TerminalCursorStyleOption(Localizer.Instance[$"Settings.Ssh.Cursor.{c}"], c))
.ToList();

Load();
}

Expand Down Expand Up @@ -261,6 +309,14 @@ private void Load()
SshStrictHostKey = s.SshStrictHostKey;
SshDefaultPort = s.SshDefaultPort;
SshTerminalFontSize = s.SshTerminalFontSize;
var theme = TerminalTheme.ById(s.SshTerminalTheme);
SshTerminalTheme = TerminalThemeOptions.FirstOrDefault(o => o.Theme == theme) ?? TerminalThemeOptions[0];
SshTerminalCursorStyle =
TerminalCursorStyleOptions.FirstOrDefault(o =>
string.Equals(o.Style.ToString(), s.SshTerminalCursorStyle, StringComparison.OrdinalIgnoreCase))
?? TerminalCursorStyleOptions[0];
SshTerminalCursorBlink = s.SshTerminalCursorBlink;
SshTerminalScrollback = s.SshTerminalScrollback;
MajesticConfigPath = s.MajesticConfigPath;
NotificationsEnabled = s.NotificationsEnabled;
NotifyOnMotion = s.NotifyOnMotion;
Expand Down Expand Up @@ -296,6 +352,10 @@ private void Load()
partial void OnSshStrictHostKeyChanged(bool value) => Persist();
partial void OnSshDefaultPortChanged(int value) => Persist();
partial void OnSshTerminalFontSizeChanged(int value) => Persist();
partial void OnSshTerminalThemeChanged(TerminalThemeOption? value) => Persist();
partial void OnSshTerminalCursorStyleChanged(TerminalCursorStyleOption? value) => Persist();
partial void OnSshTerminalCursorBlinkChanged(bool value) => Persist();
partial void OnSshTerminalScrollbackChanged(int value) => Persist();
partial void OnMajesticConfigPathChanged(string value) => Persist();
partial void OnNotificationsEnabledChanged(bool value) => Persist();
partial void OnNotifyOnMotionChanged(bool value) => Persist();
Expand Down Expand Up @@ -331,6 +391,12 @@ private void Persist()
SshStrictHostKey = SshStrictHostKey,
SshDefaultPort = SshDefaultPort,
SshTerminalFontSize = SshTerminalFontSize,
SshTerminalTheme = SshTerminalTheme?.Theme.Id ?? TerminalTheme.DefaultId,
SshTerminalCursorStyle = (SshTerminalCursorStyle?.Style ?? TerminalCursorStyle.Outline)
.ToString().ToLowerInvariant(),
SshTerminalCursorBlink = SshTerminalCursorBlink,
SshTerminalScrollback = Math.Clamp(SshTerminalScrollback,
TerminalEmulator.MinScrollbackLimit, TerminalEmulator.MaxScrollbackLimit),
MajesticConfigPath = MajesticConfigPath,
NotificationsEnabled = NotificationsEnabled,
NotifyOnMotion = NotifyOnMotion,
Expand Down Expand Up @@ -579,3 +645,5 @@ public sealed record IdleTimeoutOption(string Display, int Minutes);
// Value is the persisted UserSettings.StartupPage; Id is the LayoutId (0 = last used).
public sealed record StartPageOption(string Display, string Value);
public sealed record StartupLayoutOption(string Display, int Id);
public sealed record TerminalThemeOption(string Display, TerminalTheme Theme);
public sealed record TerminalCursorStyleOption(string Display, TerminalCursorStyle Style);
17 changes: 16 additions & 1 deletion src/OpenIPC.Viewer.App/ViewModels/SshTerminalViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@ public sealed partial class SshTerminalViewModel : ViewModelBase, IAsyncDisposab
private int _columns = 80;
private int _rows = 24;

public TerminalEmulator Emulator { get; } = new(80, 24);
public TerminalEmulator Emulator { get; }
public string Title => $"SSH — {_camera.Name}";
public double FontSize { get; }
public TerminalTheme Theme { get; }
public TerminalCursorStyle CursorStyle { get; }
public bool CursorBlink { get; }

/// <summary>Whether a tap on the terminal is allowed to raise the soft keyboard.</summary>
public bool AutoKeyboard => _settings.Current.SshTerminalAutoKeyboard;
Expand All @@ -59,6 +62,18 @@ public SshTerminalViewModel(
_settings = settings;
FontSize = fontSize >= 8 ? fontSize : 14;
_logger = logger;

var s = settings.Current;
Emulator = new TerminalEmulator(80, 24, s.SshTerminalScrollback);
Theme = TerminalTheme.ById(s.SshTerminalTheme);
CursorStyle = Enum.TryParse<TerminalCursorStyle>(s.SshTerminalCursorStyle, ignoreCase: true, out var style)
? style
: TerminalCursorStyle.Outline;
CursorBlink = s.SshTerminalCursorBlink;

// Status queries (cursor position, device attributes) are answered over the same shell;
// a tool that asks and gets no answer can sit waiting for one.
Emulator.Reply += reply => _ = SendAsync(reply);
}

public async Task ConnectAsync()
Expand Down
45 changes: 23 additions & 22 deletions src/OpenIPC.Viewer.App/Views/Dialogs/SshTerminalContent.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Media;
using OpenIPC.Viewer.App.Controls;
using OpenIPC.Viewer.App.Services;
using OpenIPC.Viewer.App.ViewModels;
Expand Down Expand Up @@ -79,19 +80,24 @@ private void SetUpImeProxy()
this.FindControl<Button>("KeyShowKeyboard")!.Click += (_, _) => FocusInput();

_ctrlKey = this.FindControl<ToggleButton>("KeyCtrl");
WireKey("KeyEsc", "\x1b");
WireKey("KeyTab", "\t");
WireKey("KeyArrowUp", "\x1b[A");
WireKey("KeyArrowDown", "\x1b[B");
WireKey("KeyArrowRight", "\x1b[C");
WireKey("KeyArrowLeft", "\x1b[D");
WireKey("KeyEsc", () => "\x1b");
WireKey("KeyTab", () => "\t");
// Arrows follow the cursor-key mode at the moment of the tap: a full-screen tool may have
// switched to the ESC O spelling since the bar was wired.
WireKey("KeyArrowUp", () => CursorKey('A'));
WireKey("KeyArrowDown", () => CursorKey('B'));
WireKey("KeyArrowRight", () => CursorKey('C'));
WireKey("KeyArrowLeft", () => CursorKey('D'));
}

private void WireKey(string buttonName, string sequence)
private string CursorKey(char final) =>
(DataContext as SshTerminalViewModel)?.Emulator.CursorKey(final) ?? "\x1b[" + final;

private void WireKey(string buttonName, Func<string> sequence)
{
this.FindControl<Button>(buttonName)!.Click += (_, _) =>
{
SendInput(sequence);
SendInput(sequence());
// The key-bar buttons are not focusable, so the proxy still holds focus; this only
// matters for bringing a dismissed keyboard back, which is opt-in.
AutoFocusInput();
Expand All @@ -116,6 +122,12 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)

_term.Emulator = vm.Emulator;
_term.TerminalFontSize = vm.FontSize;
_term.ColorTheme = vm.Theme;
_term.CursorStyle = vm.CursorStyle;
_term.CursorBlink = vm.CursorBlink;
// The strip around the grid (whatever is left when the size isn't a whole number of
// cells) takes the theme's background rather than the original dark one.
Background = new SolidColorBrush(Color.FromUInt32(0xFF00_0000 | vm.Theme.Background));
_term.Input += (_, text) => SendInput(text);
_term.PasteRequested += (_, _) => _ = PasteAsync();
_term.CopyRequested += (_, _) => _ = CopyAsync(_term.GetSelectedText());
Expand Down Expand Up @@ -386,20 +398,9 @@ private void OnProxyKeyDown(object? sender, KeyEventArgs e)
return;
}

var seq = e.Key switch
{
Key.Enter => "\r",
Key.Back => "\x7f",
Key.Tab => "\t",
Key.Escape => "\x1b",
Key.Up => "\x1b[A",
Key.Down => "\x1b[B",
Key.Right => "\x1b[C",
Key.Left => "\x1b[D",
Key.Home => "\x1b[H",
Key.End => "\x1b[F",
_ => null,
};
// Same table as the terminal's own key handler, so a hardware keyboard on a phone gets the
// cursor-key mode and the F-keys too.
var seq = _term?.SequenceFor(e.Key, e.KeyModifiers);
if (seq is not null)
{
SendInput(seq);
Expand Down
48 changes: 48 additions & 0 deletions src/OpenIPC.Viewer.App/Views/Pages/SettingsPage.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
xmlns:vm="using:OpenIPC.Viewer.App.ViewModels"
xmlns:svc="using:OpenIPC.Viewer.App.Services"
xmlns:conv="using:OpenIPC.Viewer.App.Converters"
xmlns:controls="using:OpenIPC.Viewer.App.Controls"
x:Class="OpenIPC.Viewer.App.Views.Pages.SettingsPage"
x:DataType="vm:SettingsPageViewModel">

Expand Down Expand Up @@ -405,6 +406,53 @@
Minimum="8" Maximum="32" Increment="1" FormatString="0" MinWidth="120" />
</Grid>

<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="field-label"
Text="{Binding [Settings.Ssh.Theme], Source={x:Static svc:Localizer.Instance}}" />
<ComboBox Grid.Column="1"
ItemsSource="{Binding TerminalThemeOptions}"
SelectedItem="{Binding SshTerminalTheme, Mode=TwoWay}"
DisplayMemberBinding="{Binding Display}"
MinWidth="160" />
</Grid>

<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="field-label"
Text="{Binding [Settings.Ssh.CursorStyle], Source={x:Static svc:Localizer.Instance}}" />
<ComboBox Grid.Column="1"
ItemsSource="{Binding TerminalCursorStyleOptions}"
SelectedItem="{Binding SshTerminalCursorStyle, Mode=TwoWay}"
DisplayMemberBinding="{Binding Display}"
MinWidth="160" />
</Grid>

<CheckBox IsChecked="{Binding SshTerminalCursorBlink}"
Content="{Binding [Settings.Ssh.CursorBlink], Source={x:Static svc:Localizer.Instance}}"
Foreground="{StaticResource TextPrimaryBrush}" />

<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="field-label"
Text="{Binding [Settings.Ssh.Scrollback], Source={x:Static svc:Localizer.Instance}}" />
<NumericUpDown Grid.Column="1" Value="{Binding SshTerminalScrollback, Mode=TwoWay}"
Minimum="100" Maximum="50000"
Increment="500" FormatString="0" MinWidth="120" />
</Grid>

<!-- Live preview: the real terminal control over a fixed sample screen. -->
<Border HorizontalAlignment="Left"
CornerRadius="{StaticResource RadiusSm}"
BorderBrush="{StaticResource BorderWeakBrush}" BorderThickness="1"
ClipToBounds="True">
<controls:TerminalView Emulator="{Binding TerminalPreview}"
ColorTheme="{Binding SshTerminalTheme.Theme}"
CursorStyle="{Binding SshTerminalCursorStyle.Style}"
CursorBlink="{Binding SshTerminalCursorBlink}"
TerminalFontSize="{Binding SshTerminalPreviewFontSize}"
FitToEmulator="True"
Focusable="False"
IsHitTestVisible="False" />
</Border>

<StackPanel Spacing="6">
<TextBlock Classes="field-label"
Text="{Binding [Settings.Ssh.MajesticPath], Source={x:Static svc:Localizer.Instance}}" />
Expand Down
Loading
Loading