feat: add image handling actions and improve IRC message formatting

This commit is contained in:
HueByte
2026-07-17 16:52:15 +02:00
parent b9d099dd73
commit 953e081123
13 changed files with 249 additions and 209 deletions
+22
View File
@@ -7,6 +7,16 @@ using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI.Chat;
/// <summary>An action a click on an attachment line can trigger.</summary>
public enum AttachmentAction
{
OpenImage,
SaveImage,
}
/// <summary>Inclusive column range on a chat line that triggers an attachment action when clicked.</summary>
public readonly record struct AttachmentActionSpan(int StartCol, int EndCol, AttachmentAction Action);
/// <summary>
/// A single line in the chat, composed of colored segments.
/// </summary>
@@ -20,6 +30,14 @@ public partial class ChatLine
public string? AttachmentFileName { get; set; }
public AttachmentKind? AttachmentKind { get; set; }
public string? SenderUsername { get; set; }
/// <summary>
/// Clickable sub-line targets (e.g. the "[open]" and "[save original]" brackets under an
/// image). Columns are relative to the unwrapped line, so only the first wrapped line
/// keeps them. Null means the whole line uses the kind's default action.
/// </summary>
public List<AttachmentActionSpan>? ActionSpans { get; set; }
/// <summary>Number of spaces to prepend on continuation lines when this line is word-wrapped.</summary>
public int ContinuationIndent { get; set; }
@@ -160,6 +178,10 @@ public partial class ChatLine
wrapped.IsMention = IsMention;
}
// Span columns only line up with the first wrapped line; later lines fall
// back to the kind's default action.
results[0].ActionSpans = ActionSpans;
return results;
}
@@ -443,9 +443,7 @@ public sealed class ChatMessageManager
lines.Add(new ChatLine(segments));
}
}
lines.Add(AttachmentActionLine(
$"[↓ save original] {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]",
ChatColors.FileAttr, attachment));
lines.Add(ImageActionLine(attachment));
break;
case Core.Models.AttachmentKind.Audio:
@@ -489,6 +487,41 @@ public sealed class ChatMessageManager
return lines;
}
/// <summary>
/// Builds the action line below an image preview: "[open] [↓ save original] name [size]".
/// Each bracket is an <see cref="AttachmentActionSpan"/> so a mouse click can target it;
/// keyboard activation (Enter) uses the default action, open.
/// </summary>
private static ChatLine ImageActionLine(AttachmentDto attachment)
{
var segments = RailPrefix();
var col = segments.Sum(s => s.Text.GetColumns());
var spans = new List<AttachmentActionSpan>();
void AddAction(string text, AttachmentAction action)
{
var width = text.GetColumns();
spans.Add(new AttachmentActionSpan(col, col + width - 1, action));
segments.Add(new(text, ChatColors.FileAttr));
col += width;
}
AddAction("[open]", AttachmentAction.OpenImage);
segments.Add(new(" ", null));
col += 1;
AddAction("[↓ save original]", AttachmentAction.SaveImage);
segments.Add(new($" {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]", ChatColors.FileAttr));
return new ChatLine(segments)
{
AttachmentUrl = attachment.Url,
AttachmentFileName = attachment.FileName,
AttachmentKind = attachment.Kind,
ActionSpans = spans,
ContinuationPrefixSegments = RailPrefix(),
};
}
/// <summary>
/// Builds a clickable attachment line carrying the metadata the message list uses to
/// route activation (play audio, download file, save original image).
+36 -2
View File
@@ -176,6 +176,12 @@ public sealed partial class MainWindow : Runnable
/// </summary>
public event Action<string, string>? OnImageSaveRequested;
/// <summary>
/// Fired when the user activates an image's "[open]" action to view it without saving.
/// Parameters: attachmentUrl, fileName.
/// </summary>
public event Action<string, string>? OnImageOpenRequested;
/// <summary>
/// Fired when the user presses Delete on the selected message. Parameter is the message id.
/// </summary>
@@ -532,7 +538,9 @@ public sealed partial class MainWindow : Runnable
if (line.AttachmentKind == AttachmentKind.Image)
{
OnImageSaveRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
// Keyboard/default activation opens the image for viewing;
// saving is the mouse span or the context menu.
OnImageOpenRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
e.Handled = true;
return;
}
@@ -600,7 +608,8 @@ public sealed partial class MainWindow : Runnable
private void OnMessageListMouseEvent(object? sender, Mouse e)
{
if (!e.Flags.HasFlag(MouseFlags.RightButtonClicked))
var leftClick = e.Flags.HasFlag(MouseFlags.LeftButtonClicked);
if (!leftClick && !e.Flags.HasFlag(MouseFlags.RightButtonClicked))
return;
if (_messageList.Source is not ChatListSource source || source.Count == 0 || e.Position is not { } pos)
@@ -610,6 +619,30 @@ public sealed partial class MainWindow : Runnable
if (index < 0 || index >= source.Count)
return;
// Left-click only activates the "[open]" / "[save original]" brackets on an
// attachment action line; anywhere else it falls through to normal selection.
if (leftClick)
{
var clicked = source.GetLine(index);
if (clicked?.ActionSpans is { } spans
&& clicked.AttachmentUrl is { } url && clicked.AttachmentFileName is { } name)
{
foreach (var span in spans)
{
if (pos.X < span.StartCol || pos.X > span.EndCol)
continue;
if (span.Action == AttachmentAction.OpenImage)
OnImageOpenRequested?.Invoke(url, name);
else
OnImageSaveRequested?.Invoke(url, name);
e.Handled = true;
return;
}
}
return;
}
// Select the right-clicked row (so the menu acts on it and it highlights), then show the menu.
_messageList.SelectedItem = index;
_messageList.SetFocus();
@@ -636,6 +669,7 @@ public sealed partial class MainWindow : Runnable
switch (kind)
{
case AttachmentKind.Image:
items.Add(new MenuItem("Open image", "", () => OnImageOpenRequested?.Invoke(url, name), Key.Empty));
items.Add(new MenuItem("Save original image", "", () => OnImageSaveRequested?.Invoke(url, name), Key.Empty));
break;
case AttachmentKind.Audio: