Files
Titano/Titano/UI/FrameTable.cs
T
Alby96andClaude Opus 5 cc1d040ac0 Titano: motore e interfaccia per time-lapse, senza dipendenze di terze parti
Applicazione desktop completa per creazione e ottimizzazione di time-lapse.
Il vincolo che ne definisce l'architettura è l'assenza totale di componenti
di terze parti: il progetto non ha alcun PackageReference e non invoca processi
esterni. Oltre alla libreria standard di .NET si usano solo API native di
Windows (WIC, Media Foundation, GDI+, DWM) richiamate via P/Invoke scritto a mano.

Sono in-house tutte le parti che di norma si delegherebbero a una libreria:
il parser binario EXIF/XMP, la misura di luminanza e la curva di deflicker,
il calcolo del campo vettoriale di movimento con il motion blur sintetico,
il multiplexer MP4 e ogni controllo dell'interfaccia.

Scelte algoritmiche che meritano una nota:
- il deflicker usa una regressione lineare locale pesata con seconda passata
  robusta, così le rampe reali di luce (alba, tramonto) sopravvivono mentre
  lo sfarfallio del diaframma viene rimosso; una media mobile semplice le
  appiattirebbe entrambe;
- la sfocatura mancante si compone in quadratura con quella già incisa nello
  scatto, perché sommarla linearmente renderebbe l'immagine troppo morbida;
- la luminanza si misura come media logaritmica troncata, invariante alla
  scala e insensibile a cieli bruciati e ombre chiuse.

L'elaborazione non produce file temporanei e mantiene un'occupazione di memoria
stazionaria: buffer poolati e canale a capacità limitata rendono i fotogrammi
vivi indipendenti dalla lunghezza della sequenza.

Verificato con "Titano.exe --selftest": 23 controlli su una sequenza sintetica
dalle proprietà note, incluse la struttura del contenitore prodotto e la sua
ri-decodifica con il lettore di sistema. Tutti superati.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:40:26 +02:00

289 lines
11 KiB
C#

using Titano.Core;
using Titano.Metadata;
namespace Titano.UI;
/// <summary>
/// Tabella dei fotogrammi a rendering virtuale: disegna soltanto le righe visibili, quindi
/// regge sequenze da decine di migliaia di scatti senza creare un controllo per riga.
/// Barra di scorrimento, intestazioni e selezione sono disegnate a mano nel tema scuro.
/// </summary>
internal sealed class FrameTable : Control
{
private sealed record Column(string Title, int Width, bool RightAligned, Func<FrameRecord, string> Value);
private const int RowHeight = 24;
private const int HeaderHeight = 30;
private const int ScrollWidth = 12;
private readonly Column[] _columns;
private TimelapseSequence? _sequence;
private int _scroll;
private int _hoverRow = -1;
private int _selectedIndex = -1;
private bool _draggingScroll;
private int _dragOffset;
public event EventHandler? SelectionChanged;
public int SelectedIndex
{
get => _selectedIndex;
set
{
if (_selectedIndex == value) return;
_selectedIndex = value;
EnsureVisible(value);
Invalidate();
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
}
public FrameTable()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Theme.Surface;
TabStop = true;
_columns =
[
new Column("#", 52, true, r => (r.Index + 1).ToString()),
new Column("File", 178, false, r => r.FileName),
new Column("Ora di scatto", 104, false, r => r.Metadata.CaptureTime?.ToString("HH:mm:ss.ff") ?? "—"),
new Column("Δt", 68, true, r => r.CadenceText),
new Column("Posa", 62, true, r => r.Metadata.ExposureText),
new Column("Apertura", 68, true, r => r.Metadata.ApertureText),
new Column("ISO", 52, true, r => r.Metadata.IsoText),
new Column("Otturatore", 74, true, r => r.ShutterAngle > 0 ? $"{r.ShutterAngle:0.#}°" : "—"),
new Column("Luminanza", 80, true, r => r.LuminanceAnalyzed ? $"{Math.Log2(Math.Max(r.MeasuredLuminance, 1e-9)):0.00}" : "—"),
new Column("Target", 72, true, r => r.LuminanceAnalyzed ? $"{Math.Log2(Math.Max(r.TargetLuminance, 1e-9)):0.00}" : "—"),
new Column("Guadagno", 76, true, r => r.LuminanceAnalyzed ? $"{r.GainStops:+0.00;-0.00;0.00}" : "—"),
new Column("Blur", 62, true, r => r.BlurLength > 0.01 ? $"{r.BlurLength:0.0} px" : "—"),
new Column("Movimento", 82, true, r => r.MotionMagnitude > 0.01 ? $"{r.MotionMagnitude:0.0} px" : "—"),
];
}
public void SetSequence(TimelapseSequence? sequence)
{
_sequence = sequence;
_scroll = 0;
_hoverRow = -1;
_selectedIndex = sequence is { Count: > 0 } ? 0 : -1;
Invalidate();
}
public void Refresh(TimelapseSequence? sequence)
{
_sequence = sequence;
Invalidate();
}
private int VisibleRows => Math.Max(1, (Height - HeaderHeight) / RowHeight);
private int RowCount => _sequence?.Count ?? 0;
private int MaxScroll => Math.Max(0, RowCount - VisibleRows);
private void EnsureVisible(int index)
{
if (index < 0) return;
if (index < _scroll) _scroll = index;
else if (index >= _scroll + VisibleRows) _scroll = index - VisibleRows + 1;
_scroll = Math.Clamp(_scroll, 0, MaxScroll);
}
// ------------------------------------------------------------------ interazione
protected override bool IsInputKey(Keys keyData) => keyData is Keys.Up or Keys.Down or Keys.PageUp or Keys.PageDown;
protected override void OnKeyDown(KeyEventArgs e)
{
if (RowCount == 0) return;
switch (e.KeyCode)
{
case Keys.Up: SelectedIndex = Math.Max(0, _selectedIndex - 1); break;
case Keys.Down: SelectedIndex = Math.Min(RowCount - 1, _selectedIndex + 1); break;
case Keys.PageUp: SelectedIndex = Math.Max(0, _selectedIndex - VisibleRows); break;
case Keys.PageDown: SelectedIndex = Math.Min(RowCount - 1, _selectedIndex + VisibleRows); break;
case Keys.Home: SelectedIndex = 0; break;
case Keys.End: SelectedIndex = RowCount - 1; break;
default: base.OnKeyDown(e); return;
}
e.Handled = true;
}
protected override void OnMouseWheel(MouseEventArgs e)
{
_scroll = Math.Clamp(_scroll - Math.Sign(e.Delta) * 3, 0, MaxScroll);
Invalidate();
}
protected override void OnMouseDown(MouseEventArgs e)
{
Focus();
if (e.X >= Width - ScrollWidth && RowCount > VisibleRows)
{
var thumb = ThumbBounds();
if (thumb.Contains(e.Location)) { _draggingScroll = true; _dragOffset = e.Y - thumb.Top; }
else ScrollToThumb(e.Y - thumb.Height / 2);
return;
}
int row = (e.Y - HeaderHeight) / RowHeight;
int index = _scroll + row;
if (e.Y >= HeaderHeight && index >= 0 && index < RowCount) SelectedIndex = index;
base.OnMouseDown(e);
}
protected override void OnMouseMove(MouseEventArgs e)
{
if (_draggingScroll) { ScrollToThumb(e.Y - _dragOffset); return; }
int row = e.Y >= HeaderHeight ? (e.Y - HeaderHeight) / RowHeight : -1;
if (row != _hoverRow) { _hoverRow = row; Invalidate(); }
base.OnMouseMove(e);
}
protected override void OnMouseUp(MouseEventArgs e) { _draggingScroll = false; base.OnMouseUp(e); }
protected override void OnMouseLeave(EventArgs e) { _hoverRow = -1; Invalidate(); base.OnMouseLeave(e); }
private Rectangle ThumbBounds()
{
int trackHeight = Height - HeaderHeight;
if (RowCount <= VisibleRows) return new Rectangle(Width - ScrollWidth, HeaderHeight, ScrollWidth, trackHeight);
int thumbHeight = Math.Max(28, trackHeight * VisibleRows / RowCount);
int available = trackHeight - thumbHeight;
int offset = MaxScroll == 0 ? 0 : available * _scroll / MaxScroll;
return new Rectangle(Width - ScrollWidth + 2, HeaderHeight + offset, ScrollWidth - 4, thumbHeight);
}
private void ScrollToThumb(int top)
{
int trackHeight = Height - HeaderHeight;
int thumbHeight = Math.Max(28, trackHeight * VisibleRows / Math.Max(1, RowCount));
int available = Math.Max(1, trackHeight - thumbHeight);
double fraction = Math.Clamp((top - HeaderHeight) / (double)available, 0, 1);
_scroll = (int)Math.Round(fraction * MaxScroll);
Invalidate();
}
// ------------------------------------------------------------------ disegno
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
Theme.HighQuality(g);
g.Clear(Theme.Surface);
int contentWidth = Width - (RowCount > VisibleRows ? ScrollWidth : 0);
DrawHeader(g, contentWidth);
if (_sequence is not { Count: > 0 })
{
TextRenderer.DrawText(g, "Trascina qui le immagini della sequenza, oppure usa «Aggiungi cartella»",
Theme.Body, new Rectangle(0, HeaderHeight, Width, Height - HeaderHeight),
Theme.TextFaint,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
return;
}
int last = Math.Min(RowCount, _scroll + VisibleRows + 1);
for (int index = _scroll; index < last; index++)
{
int y = HeaderHeight + (index - _scroll) * RowHeight;
if (y > Height) break;
DrawRow(g, _sequence.Frames[index], index, y, contentWidth);
}
DrawScrollBar(g);
}
private void DrawHeader(Graphics g, int contentWidth)
{
using (var brush = new SolidBrush(Theme.Background))
g.FillRectangle(brush, 0, 0, Width, HeaderHeight);
using (var pen = new Pen(Theme.Border))
g.DrawLine(pen, 0, HeaderHeight - 1, Width, HeaderHeight - 1);
int x = 8;
foreach (var column in _columns)
{
if (x > contentWidth) break;
var bounds = new Rectangle(x, 0, Math.Min(column.Width - 8, contentWidth - x), HeaderHeight);
TextRenderer.DrawText(g, column.Title, Theme.SmallBold, bounds, Theme.TextMuted,
(column.RightAligned ? TextFormatFlags.Right : TextFormatFlags.Left) |
TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
x += column.Width;
}
}
private void DrawRow(Graphics g, FrameRecord record, int index, int y, int contentWidth)
{
bool selected = index == _selectedIndex;
bool hovered = _hoverRow == index - _scroll;
if (selected)
{
using var brush = new SolidBrush(Color.FromArgb(58, Theme.Accent));
g.FillRectangle(brush, 0, y, contentWidth, RowHeight);
using var edge = new SolidBrush(Theme.Accent);
g.FillRectangle(edge, 0, y, 2, RowHeight);
}
else if (hovered)
{
using var brush = new SolidBrush(Theme.SurfaceAlt);
g.FillRectangle(brush, 0, y, contentWidth, RowHeight);
}
else if ((index & 1) == 1)
{
using var brush = new SolidBrush(Color.FromArgb(0x1F, 0x22, 0x29));
g.FillRectangle(brush, 0, y, contentWidth, RowHeight);
}
int x = 8;
for (int c = 0; c < _columns.Length; c++)
{
var column = _columns[c];
if (x > contentWidth) break;
Color color = c switch
{
0 => Theme.TextFaint,
1 => selected ? Theme.Text : Theme.Text,
10 => GainColor(record),
_ => Theme.TextMuted,
};
// Un intervallo anomalo va segnalato dove si legge: sulla colonna Δt.
if (c == 3 && record.IsCadenceAnomaly) color = Theme.Warning;
if (c == 2 && record.Metadata.CaptureSource == TimestampSource.FileSystem) color = Theme.Warning;
var bounds = new Rectangle(x, y, Math.Min(column.Width - 8, Math.Max(0, contentWidth - x)), RowHeight);
TextRenderer.DrawText(g, column.Value(record), c == 1 ? Theme.Body : Theme.Small, bounds, color,
(column.RightAligned ? TextFormatFlags.Right : TextFormatFlags.Left) |
TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
x += column.Width;
}
}
private static Color GainColor(FrameRecord record)
{
if (!record.LuminanceAnalyzed) return Theme.TextFaint;
double stops = Math.Abs(record.GainStops);
if (stops < 0.02) return Theme.TextMuted;
return record.GainStops > 0 ? Theme.Success : Theme.Danger;
}
private void DrawScrollBar(Graphics g)
{
if (RowCount <= VisibleRows) return;
using (var track = new SolidBrush(Theme.Background))
g.FillRectangle(track, Width - ScrollWidth, HeaderHeight, ScrollWidth, Height - HeaderHeight);
var thumb = ThumbBounds();
Theme.FillRounded(g, thumb, 3f, _draggingScroll ? Theme.Accent : Theme.BorderStrong);
}
}