using System.Net.Sockets;
using System.Text;
using System.Threading.Channels;
using Sunfish.Auth;
using Sunfish.Config;
using Sunfish.Host;
using Sunfish.Log;
using Sunfish.Net;
namespace Sunfish.Cli.Tui;
/// <summary>
/// Interfaz interactiva de sunfish-cli: sirve y gestiona a la vez. Se redibuja la pantalla entera
/// en cada cambio (tecla, registro, estado). Las ventanas modales (preguntas, formularios) se
/// apilan sobre las pestanas.
/// </summary>
internal sealed partial class TuiApp
{
private enum Tab { Status, Shares, Users, Explore, Log }
private const int TabCount = 5;
private abstract class Overlay;
private sealed class InputOverlay(string prompt, string text, bool mask, Func<string, Task> accept) : Overlay
{
public string Prompt { get; } = prompt;
public StringBuilder Text { get; } = new(text);
public bool Mask { get; } = mask;
public Func<string, Task> Accept { get; } = accept;
}
private sealed class ConfirmOverlay(string question, Func<Task> yes) : Overlay
{
public string Question { get; } = question;
public Func<Task> Yes { get; } = yes;
}
private sealed class MessageOverlay(string text) : Overlay
{
public string Text { get; } = text;
}
/// <summary>Codigo QR de una direccion; las flechas pasan a la siguiente.</summary>
private sealed class QrOverlay(List<string> urls) : Overlay
{
public List<string> Urls { get; } = urls;
public int Index { get; set; }
}
private sealed class ShareForm(ShareConfig share, string? originalName) : Overlay
{
public ShareConfig Share { get; } = share;
public string? OriginalName { get; } = originalName;
public int Selected { get; set; }
}
private readonly SunfishHost _host;
private readonly Channel<ConsoleKeyInfo?> _events = Channel.CreateUnbounded<ConsoleKeyInfo?>();
private readonly List<Overlay> _overlays = new();
private Tab _tab;
private int _shareSel;
private int _userSel;
private bool _quit;
private string? _status;
private string[]? _icon;
public TuiApp(SunfishHost host) => _host = host;
private bool IsQuick => _host.Config.FilePath is null;
public async Task RunAsync()
{
Console.TreatControlCAsInput = true;
Terminal.Enter();
_host.Log.Written += _ => _events.Writer.TryWrite(null);
_host.Changed += () => _events.Writer.TryWrite(null);
var keys = new Thread(ReadKeys) { IsBackground = true, Name = "tui-keys" };
keys.Start();
_ = WatchResizeAsync();
try
{
while (!_quit)
{
Render();
var ev = await _events.Reader.ReadAsync();
await HandleAsync(ev);
while (!_quit && _events.Reader.TryRead(out ev))
await HandleAsync(ev);
}
}
finally
{
Terminal.Leave();
Console.TreatControlCAsInput = false;
}
}
private void ReadKeys()
{
while (!_quit)
{
try
{
_events.Writer.TryWrite(Console.ReadKey(intercept: true));
}
catch (InvalidOperationException)
{
return;
}
}
}
private async Task WatchResizeAsync()
{
int w = Terminal.Width, h = Terminal.Height;
while (!_quit)
{
await Task.Delay(400);
if (Terminal.Width != w || Terminal.Height != h)
{
w = Terminal.Width;
h = Terminal.Height;
_events.Writer.TryWrite(null);
}
}
}
// ------------------------------------------------------------------ teclas
private async Task HandleAsync(ConsoleKeyInfo? ev)
{
if (ev is not { } key)
return; // solo redibujar
_status = null;
if (_overlays.Count > 0)
{
await HandleOverlayAsync(_overlays[^1], key);
return;
}
if (key.Key == ConsoleKey.Tab)
{
_tab = (Tab)(((int)_tab + (key.Modifiers.HasFlag(ConsoleModifiers.Shift) ? TabCount - 1 : 1)) % TabCount);
return;
}
if (key.Key is >= ConsoleKey.F1 and <= ConsoleKey.F5)
{
_tab = (Tab)(key.Key - ConsoleKey.F1);
return;
}
if (_tab == Tab.Explore && await ExploreKeyAsync(key))
return;
if (key.Key is ConsoleKey.Escape or ConsoleKey.Q || (key.Key == ConsoleKey.C && key.Modifiers.HasFlag(ConsoleModifiers.Control)))
{
_overlays.Add(new ConfirmOverlay(TuiTexts.QuitQuestion, () =>
{
_quit = true;
return Task.CompletedTask;
}));
return;
}
switch (_tab)
{
case Tab.Status: await StatusKeyAsync(key); break;
case Tab.Shares: await SharesKeyAsync(key); break;
case Tab.Users: UsersKey(key); break;
}
}
private async Task StatusKeyAsync(ConsoleKeyInfo key)
{
switch (key.Key)
{
case ConsoleKey.S:
if (_host.Running)
await _host.StopAsync();
else
StartServer();
break;
case ConsoleKey.P:
AskPort(TuiTexts.PortPrompt, _host.Config.Server.HttpPort, (c, port) => c.Server.HttpPort = port);
break;
case ConsoleKey.T:
AskPort(TuiTexts.FtpPortPrompt, _host.Config.Server.FtpPort, (c, port) => c.Server.FtpPort = port);
break;
case ConsoleKey.F:
await ChangeAsync(c => c.Server.Ftp = !c.Server.Ftp);
break;
case ConsoleKey.C:
var urls = QrUrls();
_overlays.Add(urls.Count > 0 ? new QrOverlay(urls) : new MessageOverlay(TuiTexts.NoQr));
break;
case ConsoleKey.N:
_overlays.Add(new InputOverlay(TuiTexts.NamePrompt, _host.Config.Server.Name, false, async text =>
{
if (text.Trim().Length > 0)
await ChangeAsync(c => c.Server.Name = text.Trim());
}));
break;
}
}
private void AskPort(string prompt, int current, Action<SunfishConfig, int> set)
{
_overlays.Add(new InputOverlay(prompt, current.ToString(), false, async text =>
{
if (!int.TryParse(text, out int port) || port is < 1 or > 65535)
{
_overlays.Add(new MessageOverlay(TuiTexts.BadPort));
return;
}
await ChangeAsync(c => set(c, port));
}));
}
private void StartServer()
{
try
{
_host.Start();
}
catch (SocketException e)
{
_overlays.Add(new MessageOverlay(Strings.CannotListen(_host.Config.Server.HttpPort, e.Message)));
}
}
private async Task SharesKeyAsync(ConsoleKeyInfo key)
{
var shares = _host.Config.Shares;
switch (key.Key)
{
case ConsoleKey.UpArrow: _shareSel = Math.Max(0, _shareSel - 1); break;
case ConsoleKey.DownArrow: _shareSel = Math.Min(Math.Max(0, shares.Count - 1), _shareSel + 1); break;
case ConsoleKey.A or ConsoleKey.Insert:
_overlays.Add(new InputOverlay(TuiTexts.NewShareFolder, Environment.CurrentDirectory, false, text =>
{
string path = text.Trim().Trim('"');
if (!Directory.Exists(path))
{
_overlays.Add(new MessageOverlay(Strings.ErrorNotAFolder(path)));
return Task.CompletedTask;
}
path = Path.GetFullPath(path);
string folderName = Path.GetFileName(Path.TrimEndingDirectorySeparator(path));
var share = new ShareConfig
{
Name = ShareSettings.SuggestName(_host.Config, folderName),
Location = ShareSettings.SuggestLocation(_host.Config, folderName),
Path = path,
};
_overlays.Add(new ShareForm(share, null));
return Task.CompletedTask;
}));
break;
case ConsoleKey.Enter or ConsoleKey.E when shares.Count > 0:
_overlays.Add(new ShareForm(shares[_shareSel].Clone(), shares[_shareSel].Name));
break;
case ConsoleKey.Spacebar when shares.Count > 0:
string toggled = shares[_shareSel].Name;
await ChangeAsync(c => c.Shares.First(s => s.Name == toggled).Enabled ^= true);
break;
case ConsoleKey.D or ConsoleKey.Delete when shares.Count > 0:
string removed = shares[_shareSel].Name;
_overlays.Add(new ConfirmOverlay(Strings.AskDeleteShare(removed), async () =>
{
await ChangeAsync(c => c.Shares.RemoveAll(s => s.Name == removed));
_shareSel = Math.Max(0, _shareSel - 1);
}));
break;
}
}
private void UsersKey(ConsoleKeyInfo key)
{
var users = _host.Config.Users;
switch (key.Key)
{
case ConsoleKey.UpArrow: _userSel = Math.Max(0, _userSel - 1); break;
case ConsoleKey.DownArrow: _userSel = Math.Min(Math.Max(0, users.Count - 1), _userSel + 1); break;
case ConsoleKey.A or ConsoleKey.Insert:
_overlays.Add(new InputOverlay(TuiTexts.NewUserName, "", false, name =>
{
name = name.Trim();
if (!ShareSettings.IsValidName(name))
_overlays.Add(new MessageOverlay(Strings.ErrorBadName(name)));
else if (_host.Config.Users.Any(u => u.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
_overlays.Add(new MessageOverlay(Strings.ErrorNameInUse(name)));
else
AskPassword(name, isNew: true);
return Task.CompletedTask;
}));
break;
case ConsoleKey.P when users.Count > 0:
AskPassword(users[_userSel].Name, isNew: false);
break;
case ConsoleKey.D or ConsoleKey.Delete when users.Count > 0:
string removed = users[_userSel].Name;
_overlays.Add(new ConfirmOverlay(Strings.AskDeleteUser(removed), async () =>
{
await ChangeAsync(c =>
{
c.Users.RemoveAll(u => u.Name == removed);
foreach (var s in c.Shares)
s.Users.RemoveAll(u => u.Equals(removed, StringComparison.OrdinalIgnoreCase));
});
_userSel = Math.Max(0, _userSel - 1);
}));
break;
}
}
private void AskPassword(string name, bool isNew)
{
_overlays.Add(new InputOverlay(TuiTexts.NewPassword(name), "", true, first =>
{
if (first.Length == 0)
{
_overlays.Add(new MessageOverlay(Strings.ErrorPasswordEmpty));
return Task.CompletedTask;
}
_overlays.Add(new InputOverlay(TuiTexts.RepeatPassword, "", true, async second =>
{
if (second != first)
{
_overlays.Add(new MessageOverlay(TuiTexts.PasswordMismatch));
return;
}
string hash = Passwords.Hash(first);
await ChangeAsync(c =>
{
if (isNew)
c.Users.Add(new UserConfig { Name = name, PasswordHash = hash });
else
c.Users.First(u => u.Name == name).PasswordHash = hash;
});
}));
return Task.CompletedTask;
}));
}
private async Task HandleOverlayAsync(Overlay overlay, ConsoleKeyInfo key)
{
switch (overlay)
{
case MessageOverlay:
_overlays.Remove(overlay);
break;
case ConfirmOverlay confirm:
_overlays.Remove(overlay);
if (TuiTexts.IsYes(key.KeyChar))
await confirm.Yes();
break;
case InputOverlay input:
if (key.Key == ConsoleKey.Escape)
_overlays.Remove(overlay);
else if (key.Key == ConsoleKey.Enter)
{
_overlays.Remove(overlay);
await input.Accept(input.Text.ToString());
}
else if (key.Key == ConsoleKey.Backspace)
{
if (input.Text.Length > 0)
input.Text.Length--;
}
else if (!char.IsControl(key.KeyChar))
input.Text.Append(key.KeyChar);
break;
case ShareForm form:
await HandleFormAsync(form, key);
break;
case QrOverlay qr:
if (key.Key is ConsoleKey.RightArrow or ConsoleKey.DownArrow or ConsoleKey.Tab or ConsoleKey.Spacebar)
qr.Index = (qr.Index + 1) % qr.Urls.Count;
else if (key.Key is ConsoleKey.LeftArrow or ConsoleKey.UpArrow)
qr.Index = (qr.Index + qr.Urls.Count - 1) % qr.Urls.Count;
else
_overlays.Remove(overlay);
break;
}
}
/// <summary>Direcciones para el QR: cada share activo en cada interfaz (HTTP y, si esta, FTP).</summary>
private List<string> QrUrls()
{
var urls = new List<string>();
var addresses = LocalAddresses.Get();
foreach (var share in _host.Config.Shares.Where(s => s.Enabled))
foreach (var a in addresses)
{
if (_host.HttpRunning)
urls.Add(LocalAddresses.HttpUrl(a.Address, _host.HttpPort, share));
if (_host.FtpRunning && share.Ftp)
urls.Add(LocalAddresses.FtpUrl(a.Address, _host.FtpPort, share));
}
return urls;
}
/// <summary>
/// El QR ocupa el cuerpo: medios bloques negros sobre fondo blanco (con el fondo oscuro del
/// terminal los moviles no lo leen), centrado, con la direccion debajo.
/// </summary>
private static List<string> QrBody(QrOverlay qr, int width, int height)
{
string url = qr.Urls[qr.Index];
var code = Qr.QrImage.HalfBlocks(Qr.QrCode.Encode(url), 2).ToList();
var lines = new List<string>();
int top = Math.Max(0, (height - code.Count - 2) / 2);
for (int i = 0; i < top; i++)
lines.Add("");
if (code.Count + 2 > height || code[0].Length > width)
{
lines.Add(TuiTexts.QrTooSmall);
lines.Add(url);
return lines;
}
string pad = new string(' ', Math.Max(0, (width - code[0].Length) / 2));
foreach (var row in code)
lines.Add(pad + Terminal.Fg(0, 0, 0) + Terminal.Bg(255, 255, 255) + row + Terminal.Reset);
string caption = $"{url} ({qr.Index + 1}/{qr.Urls.Count})";
lines.Add(new string(' ', Math.Max(0, (width - caption.Length) / 2)) + Terminal.Bold + caption + Terminal.Reset);
return lines;
}
// ------------------------------------------------------------------ formulario de share
private static readonly string[] TextFields = ["Name", "Location", "Path", "Index", "Users"];
private static readonly string[] BoolFields = ["Enabled", "Listing", "Upload", "Overwrite", "Rename", "Delete", "Zip"];
private static int FormRows => TextFields.Length + BoolFields.Length + 2;
private async Task HandleFormAsync(ShareForm form, ConsoleKeyInfo key)
{
switch (key.Key)
{
case ConsoleKey.Escape:
_overlays.Remove(form);
return;
case ConsoleKey.UpArrow:
form.Selected = (form.Selected + FormRows - 1) % FormRows;
return;
case ConsoleKey.DownArrow or ConsoleKey.Tab:
form.Selected = (form.Selected + 1) % FormRows;
return;
case ConsoleKey.Enter or ConsoleKey.Spacebar:
break;
default:
return;
}
int i = form.Selected;
var s = form.Share;
if (i < TextFields.Length)
{
if (key.Key != ConsoleKey.Enter)
return;
string field = TextFields[i];
_overlays.Add(new InputOverlay(FieldLabel(field), FieldValue(s, field), false, text =>
{
text = text.Trim();
if (field == "Name")
{
if (!ShareSettings.IsValidName(text))
_overlays.Add(new MessageOverlay(Strings.ErrorBadName(text)));
else
s.Name = text;
}
else if (ShareSettings.TrySet(s, field, text) is { } error)
{
_overlays.Add(new MessageOverlay(error));
}
return Task.CompletedTask;
}));
return;
}
i -= TextFields.Length;
if (i < BoolFields.Length)
{
ShareSettings.TrySet(s, BoolFields[i], (!FieldBool(s, BoolFields[i])).ToString());
return;
}
i -= BoolFields.Length;
if (i == 1 || key.Key != ConsoleKey.Enter)
{
if (i == 1)
_overlays.Remove(form);
return;
}
// Guardar
if (ValidateShare(form) is { } problem)
{
_overlays.Add(new MessageOverlay(problem));
return;
}
_overlays.Remove(form);
await ChangeAsync(c =>
{
int index = form.OriginalName is null ? -1 : c.Shares.FindIndex(x => x.Name == form.OriginalName);
if (index >= 0)
c.Shares[index] = s;
else
c.Shares.Add(s);
});
_shareSel = Math.Max(0, _host.Config.Shares.FindIndex(x => x.Name == s.Name));
}
private string? ValidateShare(ShareForm form) =>
ShareSettings.Validate(_host.Config, form.Share, form.OriginalName);
private static string FieldLabel(string field) => field switch
{
"Name" => Strings.FieldName,
"Location" => Strings.FieldLocation,
"Path" => Strings.FieldPath,
"Index" => Strings.FieldIndex,
"Users" => Strings.FieldUsers,
"Enabled" => Strings.OptEnabled,
"Listing" => Strings.OptListing,
"Upload" => Strings.OptUpload,
"Overwrite" => Strings.OptOverwrite,
"Rename" => Strings.OptRename,
"Delete" => Strings.OptDelete,
"Zip" => Strings.OptZip,
_ => field,
};
private static string FieldValue(ShareConfig s, string field) => field switch
{
"Name" => s.Name,
"Location" => s.Location,
"Path" => s.Path,
"Index" => s.Index,
"Users" => string.Join(", ", s.Users),
_ => "",
};
private static bool FieldBool(ShareConfig s, string field) => field switch
{
"Enabled" => s.Enabled,
"Listing" => s.Listing,
"Upload" => s.Upload,
"Overwrite" => s.Overwrite,
"Rename" => s.Rename,
"Delete" => s.Delete,
"Zip" => s.Zip,
_ => false,
};
/// <summary>Cambia una copia de la configuracion, la guarda (si hay fichero) y la aplica.</summary>
private async Task ChangeAsync(Action<SunfishConfig> change)
{
var copy = _host.Config.Clone();
change(copy);
bool ok = IsQuick ? await _host.ApplyAsync(copy) : await _host.SaveAndApplyAsync(copy);
_status = IsQuick ? TuiTexts.NotSavedQuick : ok ? TuiTexts.Saved : null;
}
// ------------------------------------------------------------------ dibujo
// La TUI solo se usa en terminales modernos: marco de caja con esquinas redondeadas, pestanas
// en el borde superior y atajos en el inferior. (La web es la que se mantiene compatible con
// equipos antiguos.)
private static readonly string Frame = Terminal.Fg(90, 140, 190);
private static readonly string Accent = Terminal.Fg(120, 200, 255);
private static readonly string Muted = Terminal.Fg(130, 130, 140);
private void Render()
{
int width = Terminal.Width;
int height = Terminal.Height;
int inner = width - 4; // "│ " + contenido + " │"
int bodyHeight = height - 4; // borde superior, separador, estado, borde inferior
var body = _tab switch
{
Tab.Status => StatusBody(inner, bodyHeight),
Tab.Shares => SharesBody(inner),
Tab.Users => UsersBody(inner),
Tab.Explore => ExploreBody(inner, bodyHeight),
_ => LogBody(inner, bodyHeight),
};
if (_overlays.Count > 0 && _overlays[^1] is QrOverlay qr)
body = QrBody(qr, inner, bodyHeight);
var lines = body.Take(bodyHeight).ToList();
while (lines.Count < bodyHeight)
lines.Add("");
if (_overlays.Count > 0 && _overlays[^1] is not QrOverlay)
DrawOverlay(lines, _overlays[^1], inner, bodyHeight);
string hint = _overlays.Count > 0 ? OverlayHint(_overlays[^1]) : _tab switch
{
Tab.Status => TuiTexts.KeysStatus,
Tab.Shares => TuiTexts.KeysShares,
Tab.Users => TuiTexts.KeysUsers,
Tab.Explore => ExploreHint(),
_ => TuiTexts.KeysLog,
};
var sb = new StringBuilder("\e[H");
sb.Append(TopBorder(width)).Append("\e[K\r\n");
foreach (var line in lines)
sb.Append(Frame).Append("│ ").Append(Terminal.Reset).Append(Terminal.FitVisible(line, inner))
.Append(Terminal.Reset).Append(Frame).Append(" │").Append(Terminal.Reset).Append("\e[K\r\n");
sb.Append(Frame).Append('├').Append(new string('─', width - 2)).Append('┤').Append(Terminal.Reset).Append("\e[K\r\n");
sb.Append(Frame).Append("│ ").Append(Terminal.Reset).Append(Muted).Append(Terminal.Fit(_status ?? "", inner))
.Append(Terminal.Reset).Append(Frame).Append(" │").Append(Terminal.Reset).Append("\e[K\r\n");
sb.Append(BottomBorder(width, hint + " · " + TuiTexts.KeysCommon)).Append("\e[K");
sb.Append(Terminal.Reset);
Terminal.Draw(sb.ToString());
}
/// <summary>╭─ Sunfish 3.0 ─┤ F1 Estado ├─ F2 Shares ─ ... ─ ● En marcha :8080 ─╮</summary>
private string TopBorder(int width)
{
string[] names = [Strings.AppStatus, Strings.AppShares, Strings.AppUsers, TuiTexts.ExploreTab, Strings.AppLog];
var sb = new StringBuilder(Frame + "╭─ " + Terminal.Reset + Terminal.Bold + Accent + "Sunfish 3.0" + Terminal.Reset + Frame + " ─");
int used = 3 + 11 + 2;
for (int i = 0; i < names.Length; i++)
{
string label = $" F{i + 1} {names[i]} ";
if (i == (int)_tab)
sb.Append('┤').Append(Terminal.Reset).Append(Terminal.Reverse).Append(Terminal.Bold).Append(label)
.Append(Terminal.Reset).Append(Frame).Append('├');
else
sb.Append('─').Append(Terminal.Reset).Append(label).Append(Frame).Append('─');
used += label.Length + 2;
}
string ports = (_host.HttpRunning ? $" http:{_host.HttpPort}" : "") + (_host.FtpRunning ? $" ftp:{_host.FtpPort}" : "");
string state = _host.Running ? $" ● {Strings.AppRunning}{ports} " : $" ○ {Strings.AppStopped} ";
string color = _host.Running ? Terminal.Fg(90, 200, 110) : Terminal.Fg(230, 90, 80);
int gap = width - used - state.Length - 2;
if (gap >= 1)
sb.Append(new string('─', gap)).Append(Terminal.Reset).Append(color).Append(state).Append(Frame).Append("─╮");
else
sb.Append(new string('─', Math.Max(0, width - used - 1))).Append('╮');
sb.Append(Terminal.Reset);
return sb.ToString();
}
/// <summary>╰─ atajos ──────────────╯</summary>
private static string BottomBorder(int width, string hint)
{
string text = Terminal.Fit(" " + hint + " ", Math.Max(0, width - 5)).TrimEnd() + " ";
int rest = Math.Max(0, width - 3 - text.Length);
return Frame + "╰─" + Terminal.Reset + Muted + text + Terminal.Reset + Frame + new string('─', rest) + "╯" + Terminal.Reset;
}
/// <summary>":8080", ":2121 (off)" o ":2121 !" si debia estar en marcha y no lo esta (puerto ocupado).</summary>
private string Service(bool running, bool enabled, int port)
{
if (running)
return $"{Terminal.Fg(90, 200, 110)}:{port}{Terminal.Reset}";
if (!enabled)
return $"{Muted}:{port} (off){Terminal.Reset}";
return _host.Running ? $"{Terminal.Fg(230, 90, 80)}:{port} !{Terminal.Reset}" : $":{port}";
}
private List<string> StatusBody(int width, int height)
{
var info = new List<string>
{
Terminal.Bold + Accent + _host.Config.Server.Name + Terminal.Reset,
(_host.Running ? Terminal.Fg(90, 200, 110) + "● " + Strings.AppRunning : Terminal.Fg(230, 90, 80) + "○ " + Strings.AppStopped) +
Terminal.Reset + $" HTTP: {Service(_host.HttpRunning, _host.Config.Server.Http, _host.Config.Server.HttpPort)}" +
$" FTP: {Service(_host.FtpRunning, _host.Config.Server.Ftp, _host.Config.Server.FtpPort)}",
Muted + (IsQuick ? Strings.AppQuickShare(_host.Config.Shares.FirstOrDefault()?.Path ?? "") : TuiTexts.Config(_host.Config.FilePath!)) + Terminal.Reset,
"",
Terminal.Bold + Strings.AppAddresses + Terminal.Reset,
};
var shares = _host.Config.Shares.Where(s => s.Enabled).ToList();
if (shares.Count == 0)
info.Add(" " + Strings.AppNoShares);
var addresses = LocalAddresses.Get();
foreach (var share in shares)
{
info.Add($" {Accent}▸{Terminal.Reset} {share.Name}");
foreach (var address in addresses)
{
if (_host.HttpRunning || !_host.Running)
info.Add($" {LocalAddresses.HttpUrl(address.Address, _host.HttpPort, share)} {Muted}{address.Interface}{Terminal.Reset}");
if (_host.FtpRunning && share.Ftp)
info.Add($" {LocalAddresses.FtpUrl(address.Address, _host.FtpPort, share)} {Muted}{address.Interface}{Terminal.Reset}");
}
}
bool showIcon = width >= 96 && height >= 24;
if (!showIcon)
return info;
_icon ??= TuiIcon.Render();
var lines = new List<string>();
int count = Math.Max(TuiIcon.Rows + 1, info.Count);
for (int i = 0; i < count; i++)
{
string left = i >= 1 && i <= TuiIcon.Rows ? _icon[i - 1] + " " : new string(' ', TuiIcon.Columns + 2);
lines.Add(left + (i < info.Count ? info[i] : ""));
}
return lines;
}
private List<string> SharesBody(int width)
{
var lines = new List<string>();
var shares = _host.Config.Shares;
int nameW = 14, locW = 16, permW = 7, usersW = 12;
int pathW = Math.Max(10, width - nameW - locW - permW - usersW - 7);
string Row(string mark, string name, string loc, string path, string perms, string users) =>
mark + " " + Terminal.Fit(name, nameW) + " " + Terminal.Fit(loc, locW) + " " +
Terminal.Fit(path, pathW) + " " + Terminal.Fit(perms, permW) + " " + Terminal.Fit(users, usersW);
lines.Add(Terminal.Bold + Row(" ", Strings.FieldName, "Location", Strings.FieldPath, TuiTexts.ColPerms, TuiTexts.ColUsers) + Terminal.Reset);
lines.Add(Frame + new string('─', width) + Terminal.Reset);
if (shares.Count == 0)
lines.Add(Muted + Strings.AppNoShares + Terminal.Reset);
for (int i = 0; i < shares.Count; i++)
{
var s = shares[i];
string perms = (s.Listing ? "L" : "·") + (s.Upload ? "U" : "·") + (s.Overwrite ? "O" : "·") +
(s.Rename ? "R" : "·") + (s.Delete ? "D" : "·") + (s.Zip ? "Z" : "·");
string row = Row(s.Enabled ? "●" : "○", s.Name, s.Location, s.Path, perms, string.Join(",", s.Users));
lines.Add(i == _shareSel ? Terminal.Reverse + row + Terminal.Reset : (s.Enabled ? row : Muted + row + Terminal.Reset));
}
lines.Add("");
lines.Add(Muted + TuiTexts.Permissions + Terminal.Reset);
return lines;
}
private List<string> UsersBody(int width)
{
var lines = new List<string>();
var users = _host.Config.Users;
if (users.Count == 0)
lines.Add(Muted + TuiTexts.NoUsers + Terminal.Reset);
for (int i = 0; i < users.Count; i++)
{
var u = users[i];
var shares = _host.Config.Shares.Where(s => s.Users.Contains(u.Name, StringComparer.OrdinalIgnoreCase)).Select(s => s.Name);
string row = "◆ " + Terminal.Fit(u.Name, 20) + " " + Terminal.Fit(TuiTexts.UsedIn(string.Join(", ", shares)), Math.Max(10, width - 24));
lines.Add(i == _userSel ? Terminal.Reverse + row + Terminal.Reset : row);
}
return lines;
}
private List<string> LogBody(int width, int height)
{
var entries = _host.Log.Recent();
return entries.Skip(Math.Max(0, entries.Length - height)).Select(e =>
{
string color = e.Level switch
{
LogLevel.Error => Terminal.Fg(230, 90, 80),
LogLevel.Warning => Terminal.Fg(230, 190, 80),
LogLevel.Detail => Muted,
_ => "",
};
return Muted + $"{e.Time:HH:mm:ss} " + Terminal.Reset + color + Terminal.Fit(e.Message, Math.Max(10, width - 9)) + Terminal.Reset;
}).ToList();
}
private static string OverlayHint(Overlay overlay) => overlay switch
{
ShareForm => TuiTexts.KeysForm,
QrOverlay => TuiTexts.KeysQr,
InputOverlay => TuiTexts.KeysInput,
ConfirmOverlay => TuiTexts.YesNo,
_ => "",
};
/// <summary>Recuadro redondeado centrado sobre el cuerpo; tapa las lineas que ocupa.</summary>
private void DrawOverlay(List<string> lines, Overlay overlay, int width, int height)
{
int boxWidth = Math.Min(width - 2, 76);
string title = "";
var content = new List<(string Text, bool Highlight)>();
switch (overlay)
{
case MessageOverlay m:
content.Add((m.Text, false));
break;
case ConfirmOverlay c:
content.Add((c.Question + " " + TuiTexts.YesNo, false));
break;
case InputOverlay input:
title = input.Prompt;
string value = input.Mask ? new string('•', input.Text.Length) : input.Text.ToString();
int room = boxWidth - 6;
if (value.Length > room)
value = "…" + value[^(room - 1)..];
content.Add(("› " + value + "▏", true));
break;
case ShareForm form:
title = TuiTexts.EditShare(form.Share.Name);
int row = 0;
foreach (var field in TextFields)
content.Add(($"{Terminal.Fit(FieldLabel(field), 34)} {FieldValue(form.Share, field)}", form.Selected == row++));
content.Add((new string('─', boxWidth - 4), false));
foreach (var field in BoolFields)
content.Add(($"{(FieldBool(form.Share, field) ? "☑" : "☐")} {FieldLabel(field)}", form.Selected == row++));
content.Add((new string('─', boxWidth - 4), false));
content.Add((TuiTexts.Save, form.Selected == row++));
content.Add((TuiTexts.Cancel, form.Selected == row));
if (form.Share.Upload && form.Share.Listing)
content.Add(("⚠ " + Strings.WarnUploadListing, false));
break;
}
int boxHeight = content.Count + 2;
int top = Math.Max(0, (height - boxHeight) / 2);
int left = Math.Max(0, (width - boxWidth) / 2);
string pad = new(' ', left);
string titleText = title.Length > 0 ? Terminal.Fit(" " + title + " ", Math.Min(title.Length + 2, boxWidth - 4)) : "";
string topLine = Accent + "╭─" + Terminal.Reset + Terminal.Bold + titleText + Terminal.Reset + Accent +
new string('─', Math.Max(0, boxWidth - 3 - titleText.Length)) + "╮" + Terminal.Reset;
string bottomLine = Accent + "╰" + new string('─', boxWidth - 2) + "╯" + Terminal.Reset;
void Put(int index, string text)
{
if (index >= 0 && index < lines.Count)
lines[index] = pad + text;
}
Put(top, topLine);
for (int i = 0; i < content.Count; i++)
{
var (text, highlight) = content[i];
string innerText = Terminal.Fit(" " + text, boxWidth - 2);
Put(top + 1 + i, Accent + "│" + Terminal.Reset + (highlight ? Terminal.Reverse + innerText + Terminal.Reset : innerText) + Accent + "│" + Terminal.Reset);
}
Put(top + 1 + content.Count, bottomLine);
}
}