TUI: переработать shell и адаптацию layout

This commit is contained in:
2026-04-14 01:23:55 +03:00
parent 7224a423fa
commit d12e9873f0
9 changed files with 346 additions and 43 deletions

View File

@@ -18,6 +18,9 @@
<PackageReference Include="RazorConsole.Core" Version="0.5.0" /> <PackageReference Include="RazorConsole.Core" Version="0.5.0" />
<PackageReference Include="RestSharp" Version="112.0.0" /> <PackageReference Include="RestSharp" Version="112.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<EmbeddedResource Include="logo.svg">
<LogicalView>LazyBear.MCP</LogicalView>
</EmbeddedResource>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -9,37 +9,31 @@
@implements IDisposable @implements IDisposable
<Rows Expand="true"> <Rows Expand="true">
<Panel Title="@($"LazyBear MCP [{Localization.Label}]")" <Columns Expand="true">
TitleColor="@UiPalette.Accent" <Markup Content="@GetHeaderProjectSegment()"
BorderColor="@UiPalette.Frame" Foreground="@UiPalette.Text"
Background="@UiPalette.HeaderBrandBackground"
Decoration="@Spectre.Console.Decoration.Bold" />
@foreach (var segment in BuildHeaderSegments())
{
<Markup Content="@segment.Content"
Foreground="@segment.Foreground"
Background="@segment.Background"
Decoration="@segment.Decoration" />
}
</Columns>
<Panel BorderColor="@UiPalette.Frame"
Expand="true" Expand="true"
Height="@GetPanelHeight()" Height="@GetPanelHeight()"
Padding="@(new Spectre.Console.Padding(1, 0, 1, 0))"> Padding="@(new Spectre.Console.Padding(1, 0, 1, 0))">
<Rows Expand="true"> <Rows Expand="true">
<Markup Content="@Localization.Current.HintBar" Foreground="@UiPalette.TextMuted" />
<Markup Content=" " />
<Columns>
@foreach (var tab in _tabs)
{
var isActive = _activeTab == tab;
<Markup Content="@($" {GetTabLabel(tab)} ")"
Foreground="@(isActive ? UiPalette.SelectionForeground : UiPalette.Text)"
Background="@(isActive ? UiPalette.Accent : UiPalette.SurfaceMuted)"
Decoration="@(isActive ? Spectre.Console.Decoration.Bold : Spectre.Console.Decoration.None)" />
<Markup Content=" " />
}
</Columns>
<Markup Content=" " />
@if (_activeTab == Tab.Overview) @if (_activeTab == Tab.Overview)
{ {
<OverviewTab Rows="@GetOverviewRows()" <OverviewTab Rows="@GetOverviewRows()"
SelectedIndex="@_overviewSelection" SelectedIndex="@_overviewSelection"
SelectedIndexChanged="@OnOverviewSelectionChanged" SelectedIndexChanged="@OnOverviewSelectionChanged"
ViewportRows="@GetOverviewViewportRows()" ViewportRows="@GetOverviewViewportRows()"
Endpoint="@_mcpEndpoint"
Loc="@Localization.Current" /> Loc="@Localization.Current" />
} }
else if (_activeTab == Tab.Logs) else if (_activeTab == Tab.Logs)
@@ -62,9 +56,38 @@
} }
</Rows> </Rows>
</Panel> </Panel>
<Markup Content="@BuildStatusBar(UiMetrics.ConsoleWidth)"
Foreground="@UiPalette.Text"
Background="@UiPalette.SurfaceMuted" />
<ModalWindow IsOpened="@_isHelpOpen">
<Panel Title="@Localization.Current.HelpModalTitle"
TitleColor="@UiPalette.Accent"
BorderColor="@UiPalette.AccentSoft"
Width="@GetHelpModalWidth()"
Padding="@(new Spectre.Console.Padding(1, 0, 1, 0))">
<Rows>
<Markup Content="@Localization.Current.HintBar"
Foreground="@UiPalette.Text" />
<Markup Content=" " />
<Markup Content="@GetHelpLine(Tab.Overview, Localization.Current.OverviewHint)"
Foreground="@UiPalette.TextMuted" />
<Markup Content="@GetHelpLine(Tab.Logs, Localization.Current.LogsHint)"
Foreground="@UiPalette.TextMuted" />
<Markup Content="@GetHelpLine(Tab.Settings, Localization.Current.SettingsHint)"
Foreground="@UiPalette.TextMuted" />
<Markup Content=" " />
<Markup Content="@Localization.Current.HelpCloseHint"
Foreground="@UiPalette.AccentSoft"
Decoration="@Spectre.Console.Decoration.Bold" />
</Rows>
</Panel>
</ModalWindow>
</Rows> </Rows>
@code { @code {
private const char Nbsp = '\u00A0';
private enum Tab private enum Tab
{ {
Overview, Overview,
@@ -72,6 +95,12 @@
Settings Settings
} }
private readonly record struct HeaderSegment(
string Content,
Spectre.Console.Color Foreground,
Spectre.Console.Color Background,
Spectre.Console.Decoration Decoration);
private static readonly Tab[] _tabs = [Tab.Overview, Tab.Logs, Tab.Settings]; private static readonly Tab[] _tabs = [Tab.Overview, Tab.Logs, Tab.Settings];
private static readonly string[] _logFilters = ["All", "Info", "Warn", "Error"]; private static readonly string[] _logFilters = ["All", "Info", "Warn", "Error"];
private readonly HashSet<string> _expandedModules = new(StringComparer.Ordinal); private readonly HashSet<string> _expandedModules = new(StringComparer.Ordinal);
@@ -82,14 +111,16 @@
private int _logSelection; private int _logSelection;
private int _settingsSelection; private int _settingsSelection;
private bool _logsStickToBottom = true; private bool _logsStickToBottom = true;
private bool _isHelpOpen;
private static readonly string _mcpEndpoint = private static readonly string _mcpEndpoint =
Environment.GetEnvironmentVariable("ASPNETCORE_URLS") ?? "http://localhost:5000"; Environment.GetEnvironmentVariable("ASPNETCORE_URLS") ?? "http://localhost:5000";
private static int GetPanelHeight() => Math.Max(Console.WindowHeight - 2, 10); private static int GetPanelHeight() => Math.Max(UiMetrics.ConsoleHeight - 4, 10);
private static int GetOverviewViewportRows() => Math.Max(Console.WindowHeight - 11, 3); private static int GetOverviewViewportRows() => Math.Max(GetPanelHeight() - 6, 3);
private static int GetLogsViewportRows() => Math.Max(Console.WindowHeight - 16, 5); private static int GetLogsViewportRows() => Math.Max(GetPanelHeight() - 10, 5);
private static int GetSettingsViewportRows() => Math.Max(Console.WindowHeight - 13, 5); private static int GetSettingsViewportRows() => Math.Max(GetPanelHeight() - 7, 5);
private static int GetHelpModalWidth() => Math.Max(Math.Min(UiMetrics.ConsoleWidth - 6, 84), 36);
protected override void OnInitialized() protected override void OnInitialized()
{ {
@@ -115,6 +146,7 @@
ConsoleKey.PageUp => "PageUp", ConsoleKey.PageUp => "PageUp",
ConsoleKey.PageDown => "PageDown", ConsoleKey.PageDown => "PageDown",
ConsoleKey.Tab => "Tab", ConsoleKey.Tab => "Tab",
ConsoleKey.Escape => "Escape",
_ => key.KeyChar == '\0' ? string.Empty : key.KeyChar.ToString() _ => key.KeyChar == '\0' ? string.Empty : key.KeyChar.ToString()
}; };
@@ -145,6 +177,22 @@
private void HandleKeyDown(KeyboardEventArgs args) private void HandleKeyDown(KeyboardEventArgs args)
{ {
if (args.Key is "h" or "H")
{
_isHelpOpen = !_isHelpOpen;
return;
}
if (_isHelpOpen)
{
if (args.Key == "Escape")
{
_isHelpOpen = false;
}
return;
}
if (string.Equals(args.Key, "Tab", StringComparison.Ordinal)) if (string.Equals(args.Key, "Tab", StringComparison.Ordinal))
{ {
ChangeTab(args.ShiftKey ? -1 : 1); ChangeTab(args.ShiftKey ? -1 : 1);
@@ -559,6 +607,127 @@
_ => Localization.Current.TabSettings _ => Localization.Current.TabSettings
}; };
private HeaderSegment[] BuildHeaderSegments()
{
var width = Math.Max(UiMetrics.ConsoleWidth, 1);
var projectSegmentWidth = GetHeaderProjectSegment().Length;
var availableTabsWidth = Math.Max(width - projectSegmentWidth, 0);
if (availableTabsWidth <= 0)
{
return [];
}
var labels = _tabs.Select(GetTabLabel).ToArray();
var labelWidths = labels.Select(label => label.Length).ToArray();
var availableLabelWidth = availableTabsWidth - (_tabs.Length * 2);
if (availableLabelWidth < _tabs.Length)
{
return
[
new HeaderSegment(
FillBar(availableTabsWidth),
UiPalette.Text,
UiPalette.HeaderTabsBackground,
Spectre.Console.Decoration.None)
];
}
while (labelWidths.Sum() > availableLabelWidth)
{
var largestIndex = Array.IndexOf(labelWidths, labelWidths.Max());
if (largestIndex < 0 || labelWidths[largestIndex] <= 1)
{
break;
}
labelWidths[largestIndex]--;
}
var segments = new List<HeaderSegment>(_tabs.Length + 1);
var usedWidth = 0;
for (var i = 0; i < _tabs.Length; i++)
{
var tab = _tabs[i];
var label = FitInline(labels[i], labelWidths[i]);
var content = WrapBarSegment(label);
segments.Add(new HeaderSegment(
content,
tab == _activeTab ? UiPalette.SelectionForeground : UiPalette.Text,
tab == _activeTab ? UiPalette.SelectionBackground : UiPalette.HeaderTabsBackground,
tab == _activeTab ? Spectre.Console.Decoration.Bold : Spectre.Console.Decoration.None));
usedWidth += content.Length;
}
var fillerWidth = Math.Max(availableTabsWidth - usedWidth, 0);
if (fillerWidth > 0)
{
segments.Add(new HeaderSegment(
FillBar(fillerWidth),
UiPalette.Text,
UiPalette.HeaderTabsBackground,
Spectre.Console.Decoration.None));
}
return [.. segments];
}
private string GetHeaderProjectSegment() => WrapBarSegment(Localization.Current.ProjectTitle);
private string GetHelpLine(Tab tab, string hint) => $"{GetTabLabel(tab)}: {hint}";
private string GetStatusBarLeftText() => $"{Localization.Label} | {Localization.Current.HelpStatusHint}";
private string GetStatusBarRightText() => $"{Localization.Current.DashboardEndpointLabel}: {_mcpEndpoint}";
private string BuildStatusBar(int width)
{
var left = GetStatusBarLeftText();
var right = GetStatusBarRightText();
width = Math.Max(width, 1);
if (right.Length >= width)
{
return right[^width..];
}
var leftWidth = Math.Max(width - right.Length - 1, 0);
var fittedLeft = leftWidth == 0 ? string.Empty : PadRightVisible(FitInline(left, leftWidth), leftWidth);
return leftWidth == 0
? PadLeftVisible(right, width)
: $"{fittedLeft} {right}";
}
private static string FillBar(int width) =>
width <= 0 ? string.Empty : new string(Nbsp, width);
private static string WrapBarSegment(string text) => $"{Nbsp}{text}{Nbsp}";
private static string PadRightVisible(string text, int width)
{
if (width <= 0) return string.Empty;
if (text.Length >= width) return text[..width];
return text + FillBar(width - text.Length);
}
private static string PadLeftVisible(string text, int width)
{
if (width <= 0) return string.Empty;
if (text.Length >= width) return text[^width..];
return FillBar(width - text.Length) + text;
}
private static string FitInline(string text, int width)
{
if (width <= 0) return string.Empty;
if (text.Length <= width) return text;
if (width <= 3) return text[..width];
return text[..(width - 3)] + "...";
}
public void Dispose() public void Dispose()
{ {
Registry.StateChanged -= OnRegistryChanged; Registry.StateChanged -= OnRegistryChanged;

View File

@@ -2,7 +2,6 @@
<Rows> <Rows>
<Markup Content="@Loc.LogsTitle" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" /> <Markup Content="@Loc.LogsTitle" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
<Markup Content="@Loc.LogsHint" Foreground="@UiPalette.TextMuted" />
<Markup Content=" " /> <Markup Content=" " />
<Columns> <Columns>
@@ -87,7 +86,7 @@
? selected.Message ? selected.Message
: $"{selected.Message} | {selected.Exception}"; : $"{selected.Message} | {selected.Exception}";
return Fit(details, Math.Max(Console.WindowWidth - 12, 32)); return Fit(details, Math.Max(UiMetrics.ConsoleWidth - 12, 32));
} }
private string FormatEntry(int index) private string FormatEntry(int index)
@@ -104,7 +103,7 @@
}; };
var text = $"{entry.Timestamp:HH:mm:ss} {level,-3} {entry.ShortCategory,-18} {entry.Message}"; var text = $"{entry.Timestamp:HH:mm:ss} {level,-3} {entry.ShortCategory,-18} {entry.Message}";
return Fit(text, Math.Max(Console.WindowWidth - 12, 32)); return Fit(text, Math.Max(UiMetrics.ConsoleWidth - 12, 32));
} }
private static string Fit(string text, int width) private static string Fit(string text, int width)

View File

@@ -1,8 +1,5 @@
<Rows> <Rows>
<Markup Content="@Loc.OverviewTitle" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" /> <Markup Content="@Loc.OverviewTitle" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
<Markup Content="@Loc.OverviewHint" Foreground="@UiPalette.TextMuted" />
<Markup Content=" " />
<Markup Content="@($"{Loc.DashboardEndpointLabel}: {Endpoint}")" Foreground="@UiPalette.Accent" />
<Markup Content=" " /> <Markup Content=" " />
@if (Rows.Count == 0) @if (Rows.Count == 0)
@@ -32,7 +29,6 @@
[Parameter] public int SelectedIndex { get; set; } [Parameter] public int SelectedIndex { get; set; }
[Parameter] public EventCallback<int> SelectedIndexChanged { get; set; } [Parameter] public EventCallback<int> SelectedIndexChanged { get; set; }
[Parameter] public int ViewportRows { get; set; } = 3; [Parameter] public int ViewportRows { get; set; } = 3;
[Parameter] public string Endpoint { get; set; } = "";
[Parameter] public TuiResources Loc { get; set; } = TuiResources.En; [Parameter] public TuiResources Loc { get; set; } = TuiResources.En;
private int[] GetOptions() => Enumerable.Range(0, Rows.Count).ToArray(); private int[] GetOptions() => Enumerable.Range(0, Rows.Count).ToArray();
@@ -56,7 +52,7 @@
var row = Rows[index]; var row = Rows[index];
var status = row.IsModuleEnabled ? $"[{Loc.StateOn}] " : $"[{Loc.StateOff}]"; var status = row.IsModuleEnabled ? $"[{Loc.StateOn}] " : $"[{Loc.StateOff}]";
var text = $"{row.ModuleName,-12} {status} {row.ConfiguredTools,2}/{row.TotalTools,-2} {row.Description}"; var text = $"{row.ModuleName,-12} {status} {row.ConfiguredTools,2}/{row.TotalTools,-2} {row.Description}";
return Fit(text, Math.Max(Console.WindowWidth - 12, 32)); return Fit(text, Math.Max(UiMetrics.ConsoleWidth - 12, 32));
} }
private static string Fit(string text, int width) private static string Fit(string text, int width)

View File

@@ -1,6 +1,5 @@
<Rows> <Rows>
<Markup Content="@Loc.SettingsTitle" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" /> <Markup Content="@Loc.SettingsTitle" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
<Markup Content="@Loc.SettingsHint" Foreground="@UiPalette.TextMuted" />
<Markup Content=" " /> <Markup Content=" " />
@if (Entries.Count == 0) @if (Entries.Count == 0)
@@ -67,7 +66,7 @@
text = $"{indent}{checkbox} {entry.Label}{disabledSuffix}"; text = $"{indent}{checkbox} {entry.Label}{disabledSuffix}";
} }
return Fit(text, Math.Max(Console.WindowWidth - 12, 32)); return Fit(text, Math.Max(UiMetrics.ConsoleWidth - 12, 32));
} }
private static string Fit(string text, int width) private static string Fit(string text, int width)

View File

@@ -7,10 +7,15 @@ namespace LazyBear.MCP.TUI.Localization;
public sealed record TuiResources public sealed record TuiResources
{ {
// ── Подсказка и вкладки ────────────────────────────────────────────────── // ── Подсказка и вкладки ──────────────────────────────────────────────────
public string ProjectTitle { get; init; } = "";
public string HintBar { get; init; } = ""; public string HintBar { get; init; } = "";
public string TabOverview { get; init; } = ""; public string TabOverview { get; init; } = "";
public string TabLogs { get; init; } = ""; public string TabLogs { get; init; } = "";
public string TabSettings { get; init; } = ""; public string TabSettings { get; init; } = "";
public string HelpButtonLabel { get; init; } = "";
public string HelpStatusHint { get; init; } = "";
public string HelpModalTitle { get; init; } = "";
public string HelpCloseHint { get; init; } = "";
// ── Dashboard ──────────────────────────────────────────────────────────── // ── Dashboard ────────────────────────────────────────────────────────────
public string OverviewTitle { get; init; } = ""; public string OverviewTitle { get; init; } = "";
@@ -44,10 +49,15 @@ public sealed record TuiResources
public static readonly TuiResources En = new() public static readonly TuiResources En = new()
{ {
HintBar = "Tab: tabs | Arrows: navigate | Space: toggle | Enter: open | L: language | Q: quit", ProjectTitle = "LazyBear MCP",
HintBar = "Tab/Shift+Tab: switch tabs | H: help | L: language | Q: quit",
TabOverview = "Dashboard", TabOverview = "Dashboard",
TabLogs = "Logs", TabLogs = "Logs",
TabSettings = "Settings", TabSettings = "Settings",
HelpButtonLabel = "Help",
HelpStatusHint = "H: help",
HelpModalTitle = "Keyboard Shortcuts",
HelpCloseHint = "H / Esc: close",
OverviewTitle = "Dashboard", OverviewTitle = "Dashboard",
OverviewHint = "Up/Down: select module. Enter: open settings.", OverviewHint = "Up/Down: select module. Enter: open settings.",
@@ -77,10 +87,15 @@ public sealed record TuiResources
public static readonly TuiResources Ru = new() public static readonly TuiResources Ru = new()
{ {
HintBar = "Tab: вкладки | Стрелки: навигация | Space: вкл/выкл | Enter: открыть | L: язык | Q: выход", ProjectTitle = "LazyBear MCP",
HintBar = "Tab/Shift+Tab: вкладки | H: справка | L: язык | Q: выход",
TabOverview = "Dashboard", TabOverview = "Dashboard",
TabLogs = "Логи", TabLogs = "Логи",
TabSettings = "Настройки", TabSettings = "Настройки",
HelpButtonLabel = "Справка",
HelpStatusHint = "H: справка",
HelpModalTitle = "Горячие клавиши",
HelpCloseHint = "H / Esc: закрыть",
OverviewTitle = "Dashboard", OverviewTitle = "Dashboard",
OverviewHint = "Вверх/вниз: выбор модуля. Enter: открыть настройки.", OverviewHint = "Вверх/вниз: выбор модуля. Enter: открыть настройки.",

View File

@@ -0,0 +1,43 @@
using Spectre.Console;
namespace LazyBear.MCP.TUI;
internal static class UiMetrics
{
public static int ConsoleWidth => Math.Max(ReadConsoleSize(ReadConsoleWidth, () => AnsiConsole.Profile.Width, 80), 1);
public static int ConsoleHeight => Math.Max(ReadConsoleSize(ReadConsoleHeight, () => AnsiConsole.Profile.Height, 24), 1);
private static int ReadConsoleSize(Func<int> consoleReader, Func<int> profileReader, int fallback)
{
try
{
var consoleValue = consoleReader();
if (consoleValue > 0)
{
return consoleValue;
}
}
catch
{
// Игнорируем и пробуем fallback через профиль Spectre.
}
try
{
var profileValue = profileReader();
if (profileValue > 0)
{
return profileValue;
}
}
catch
{
// Игнорируем и используем значение по умолчанию.
}
return fallback;
}
private static int ReadConsoleWidth() => Console.WindowWidth;
private static int ReadConsoleHeight() => Console.WindowHeight;
}

View File

@@ -8,6 +8,8 @@ internal static class UiPalette
public static readonly Color Surface = new(12, 21, 34); public static readonly Color Surface = new(12, 21, 34);
public static readonly Color SurfaceAlt = new(18, 29, 44); public static readonly Color SurfaceAlt = new(18, 29, 44);
public static readonly Color SurfaceMuted = new(28, 40, 56); public static readonly Color SurfaceMuted = new(28, 40, 56);
public static readonly Color HeaderBrandBackground = new(9, 31, 47);
public static readonly Color HeaderTabsBackground = SurfaceMuted;
public static readonly Color Accent = Color.Cyan1; public static readonly Color Accent = Color.Cyan1;
public static readonly Color AccentSoft = Color.DeepSkyBlue1; public static readonly Color AccentSoft = Color.DeepSkyBlue1;
public static readonly Color Text = Color.Grey93; public static readonly Color Text = Color.Grey93;

77
LazyBear.MCP/logo.svg Normal file
View File

@@ -0,0 +1,77 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#050816"/>
<stop offset="100%" stop-color="#0E0A22"/>
</linearGradient>
<linearGradient id="cyan" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#F2FFFF"/>
<stop offset="50%" stop-color="#3EE8FF"/>
<stop offset="100%" stop-color="#0A8FE5"/>
</linearGradient>
<linearGradient id="visor" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#C7FFFF"/>
<stop offset="100%" stop-color="#9B6CFF"/>
</linearGradient>
<filter id="glow" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="8" result="b"/>
<feMerge>
<feMergeNode in="b"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- background -->
<rect width="1024" height="1024" rx="220" fill="url(#bg)"/>
<!-- BIGGER BEAR (scaled + centered) -->
<g transform="translate(512,560) scale(1.25) translate(-512,-560)">
<!-- ears -->
<circle cx="360" cy="300" r="90" fill="#0A101B" stroke="#4EE7FF" stroke-width="14"/>
<circle cx="664" cy="300" r="90" fill="#0A101B" stroke="#4EE7FF" stroke-width="14"/>
<!-- head -->
<path d="M260 380
C260 240 380 160 512 160
C644 160 764 240 764 380
C764 560 650 760 512 840
C374 760 260 560 260 380Z"
fill="#0A101B" stroke="#54E8FF" stroke-width="16"/>
<!-- face -->
<path d="M340 430
C360 330 430 290 512 290
C594 290 664 330 684 430
C694 500 660 610 590 670
C550 700 474 700 434 670
C364 610 330 500 340 430Z"
fill="url(#cyan)"/>
<!-- visor -->
<g filter="url(#glow)">
<path d="M360 370
C400 330 624 330 664 370
L664 450
C620 480 404 480 360 450Z"
fill="#0B1528" stroke="url(#visor)" stroke-width="12"/>
</g>
<!-- nose -->
<ellipse cx="512" cy="500" rx="40" ry="26" fill="#0E121A"/>
<!-- simplified mouth for small sizes -->
<path d="M440 580
C470 610 554 610 584 580"
fill="none" stroke="#FFFFFF" stroke-width="10" stroke-linecap="round"/>
<!-- teeth (simplified) -->
<path d="M470 585 L490 615 L510 585 L530 615 L550 585"
fill="none" stroke="#EFFFFF" stroke-width="8" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB