Scheda Archivio, ottimizzazione per sezione e spiegazioni a comparsa
Aggiunge l'importazione da scheda di memoria e riordina l'interfaccia attorno a quello che ciascuna sezione serve a fare. L'importazione riconosce i supporti collegati, cerca nelle sottocartelle e prima di toccare il disco mostra il piano: quali cartelle nasceranno e che nome avranno i primi file. Un'importazione sbagliata su mille file non si annulla, e guardare prima costa un istante. Fa poi tre cose che a mano si sbagliano sempre: separa le sessioni sulla pausa fra due scatti, invece di lasciare piu' riprese mescolate in una cartella sola; costruisce i nomi da modelli con segnaposto, perche' un archivio si consulta anni dopo e il nome e' l'unica cosa leggibile senza aprire nulla; e rilegge quello che ha scritto confrontandone l'impronta, perche' una scheda che si scollega a meta' copia produce file della dimensione giusta e del contenuto troncato, e il danno si scopre mesi dopo. Sulla conversione in DNG va detto con precisione cosa si puo' e non si puo' fare. Un DNG vero contiene i valori del sensore prima dell'interpolazione cromatica; ottenerlo da un formato proprietario richiede la libreria del produttore, che il vincolo sulle dipendenze esclude. Quello che si ottiene in-house e' un DNG lineare - la specifica lo prevede, i pixel sono gia' interpolati, il file e' valido e apribile ovunque ma non restituisce la liberta' del grezzo. La copia resta quindi la scelta predefinita, e l'interfaccia lo dice invece di lasciarlo intuire. Il contenitore TIFF/DNG e' scritto a mano come il multiplexer MP4, e la verifica lo rilegge con il parser di questo stesso programma: due implementazioni indipendenti dello stesso formato, e al primo confronto e' saltato fuori un errore di dodici byte per voce nel calcolo degli scostamenti - la directory Exif finiva oltre il puntatore che la indicava. Le spiegazioni passano dalle note stampate ai suggerimenti a comparsa. Una nota sotto un cursore occupa spazio a chi la conosce gia', quindi deve restare corta; un suggerimento che appare solo quando serve non ha quel vincolo e puo' dire l'unica cosa che conta - perche' quel parametro esiste e cosa succede a spostarlo nel verso sbagliato. Ogni sezione guadagna un comando Ottimizza che rileva le impostazioni migliori per quella sola parte e riferisce cosa ha cambiato e perche'. Si distingue dal pilota automatico, che lavora di continuo sui parametri deducibili senza ambiguita': l'ottimizzazione si chiede a mano perche' accende e spegne interi moduli, e sostituire quelle scelte in silenzio sarebbe peggio che lasciarle sbagliate. Dove il dato manca non tira a indovinare: lo dice. L'uscita guadagna rapporto e ritaglio. Cambiare rapporto non deforma piu' l'immagine: il ritaglio viene preso con il nuovo rapporto dentro il fotogramma, il che ha anche corretto un difetto latente dello stadio geometrico, che con rapporti diversi fra sorgente e uscita stirava invece di tagliare. La zona da tenere si sceglie trascinandola. I comandi seguono la sezione: importazione e analisi compaiono solo dove servono. Un pulsante che non ha senso dove ci si trova non va disabilitato ma tolto, perche' disabilitato resta un ingombro che chiede perche' non funziona. Le preferenze dell'applicazione non mostrano piu' anteprima, riepilogo della sequenza ne' riga di stato che ne parli: non riguardano la sequenza caricata, e tenerle accanto confondeva due piani diversi. Via anche le descrizioni inutili: la finestra si chiama Titano e basta. Due difetti trovati durante la verifica a video. La barra di navigazione nasceva sulla prima voce, che ora e' Archivio, quindi il selettore usciva subito perche' l'indice coincideva e la sezione non veniva mai mostrata: barra su una voce, contenuto su un'altra. E il posizionamento dei pulsanti leggeva Visible, che in WinForms resta falso finche' la finestra non e' stata mostrata perche' riporta la visibilita' dell'intera catena: alla costruzione nessun pulsante veniva collocato e restavano tutti impilati sull'angolo. Verifica: da 54 a 60 controlli. I nuovi coprono i modelli di percorso, la sostituzione dei caratteri illegali, il rifiuto dei segnaposto inventati, il riconoscimento delle sessioni e la rilettura del DNG scritto - sia dal parser interno sia dal decodificatore di sistema. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
using Titano.Video;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Scelta dell'area di uscita dentro il fotogramma sorgente: si trascina il rettangolo per
|
||||
/// spostarlo e la maniglia d'angolo per stringerlo.
|
||||
///
|
||||
/// Il rettangolo ha sempre il rapporto scelto per l'uscita, non quello della sorgente. È la
|
||||
/// differenza fra tagliare e deformare: passando da 4:3 a 16:9 quello che si perde sono due
|
||||
/// fasce, e deciderle guardando l'immagine è l'unico modo sensato di farlo.
|
||||
/// </summary>
|
||||
internal sealed class FramingEditor : Control
|
||||
{
|
||||
private const float HandleSize = 10f;
|
||||
|
||||
private readonly ExportSettings _export;
|
||||
private bool _dragging;
|
||||
private bool _draggingZoom;
|
||||
private PointF _grabOffset;
|
||||
|
||||
public event EventHandler? Changed;
|
||||
|
||||
/// <summary>Rapporto larghezza/altezza del fotogramma sorgente.</summary>
|
||||
public double SourceAspect { get; set; } = 4.0 / 3.0;
|
||||
|
||||
/// <summary>Rapporto richiesto per l'uscita.</summary>
|
||||
public double OutputAspect { get; set; } = 4.0 / 3.0;
|
||||
|
||||
public FramingEditor(ExportSettings export)
|
||||
{
|
||||
_export = export;
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
BackColor = Theme.Surface;
|
||||
Height = 172;
|
||||
}
|
||||
|
||||
/// <summary>Riquadro che rappresenta il fotogramma sorgente, con le sue proporzioni.</summary>
|
||||
private RectangleF Stage
|
||||
{
|
||||
get
|
||||
{
|
||||
var available = new RectangleF(10, 22, Math.Max(20, Width - 20), Math.Max(20, Height - 44));
|
||||
float aspect = (float)Math.Max(0.1, SourceAspect);
|
||||
float width = available.Width;
|
||||
float height = width / aspect;
|
||||
|
||||
if (height > available.Height)
|
||||
{
|
||||
height = available.Height;
|
||||
width = height * aspect;
|
||||
}
|
||||
|
||||
return new RectangleF(available.Left + (available.Width - width) / 2,
|
||||
available.Top + (available.Height - height) / 2, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Rettangolo di ritaglio, con il rapporto dell'uscita e vincolato dentro la sorgente.</summary>
|
||||
private RectangleF CropRect
|
||||
{
|
||||
get
|
||||
{
|
||||
var stage = Stage;
|
||||
double zoom = Math.Max(1.0, _export.CropZoom);
|
||||
|
||||
// Il più grande rettangolo con il rapporto dell'uscita che sta nella sorgente,
|
||||
// poi ristretto dallo zoom: la stessa regola che applica lo stadio geometrico.
|
||||
double baseWidth = Math.Min(stage.Width, stage.Height * OutputAspect);
|
||||
double baseHeight = baseWidth / Math.Max(1e-6, OutputAspect);
|
||||
|
||||
float width = (float)(baseWidth / zoom);
|
||||
float height = (float)(baseHeight / zoom);
|
||||
|
||||
float halfX = width / 2f / stage.Width;
|
||||
float halfY = height / 2f / stage.Height;
|
||||
float centreX = (float)Math.Clamp(_export.CropCentreX, halfX, 1 - halfX);
|
||||
float centreY = (float)Math.Clamp(_export.CropCentreY, halfY, 1 - halfY);
|
||||
|
||||
return new RectangleF(stage.Left + centreX * stage.Width - width / 2,
|
||||
stage.Top + centreY * stage.Height - height / 2, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
private RectangleF Handle
|
||||
{
|
||||
get
|
||||
{
|
||||
var crop = CropRect;
|
||||
return new RectangleF(crop.Right - HandleSize, crop.Bottom - HandleSize,
|
||||
HandleSize * 2, HandleSize * 2);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ interazione
|
||||
|
||||
protected override void OnMouseDown(MouseEventArgs e)
|
||||
{
|
||||
if (e.Button != MouseButtons.Left) return;
|
||||
Focus();
|
||||
|
||||
if (Handle.Contains(e.Location)) { _draggingZoom = true; return; }
|
||||
|
||||
var crop = CropRect;
|
||||
if (!crop.Contains(e.Location)) return;
|
||||
|
||||
_dragging = true;
|
||||
_grabOffset = new PointF(e.X - (crop.Left + crop.Width / 2), e.Y - (crop.Top + crop.Height / 2));
|
||||
}
|
||||
|
||||
protected override void OnMouseMove(MouseEventArgs e)
|
||||
{
|
||||
var stage = Stage;
|
||||
|
||||
if (_draggingZoom)
|
||||
{
|
||||
float halfWidth = Math.Max(6f, e.X - (stage.Left + (float)_export.CropCentreX * stage.Width));
|
||||
double baseWidth = Math.Min(stage.Width, stage.Height * OutputAspect);
|
||||
_export.CropZoom = Math.Clamp(baseWidth / (2.0 * halfWidth), 1.0, 6.0);
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_dragging)
|
||||
{
|
||||
_export.CropCentreX = Math.Clamp((e.X - _grabOffset.X - stage.Left) / stage.Width, 0, 1);
|
||||
_export.CropCentreY = Math.Clamp((e.Y - _grabOffset.Y - stage.Top) / stage.Height, 0, 1);
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
Cursor = Handle.Contains(e.Location) ? Cursors.SizeNWSE
|
||||
: CropRect.Contains(e.Location) ? Cursors.SizeAll
|
||||
: Cursors.Default;
|
||||
base.OnMouseMove(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseUp(MouseEventArgs e)
|
||||
{
|
||||
_dragging = false;
|
||||
_draggingZoom = false;
|
||||
base.OnMouseUp(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseDoubleClick(MouseEventArgs e)
|
||||
{
|
||||
// Doppio clic: si torna all'inquadratura piena, che è la richiesta più frequente
|
||||
// dopo aver provato un ritaglio e non esserne convinti.
|
||||
_export.CropCentreX = 0.5;
|
||||
_export.CropCentreY = 0.5;
|
||||
_export.CropZoom = 1.0;
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
base.OnMouseDoubleClick(e);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ disegno
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Parent?.BackColor ?? Theme.Surface);
|
||||
|
||||
var stage = Stage;
|
||||
Theme.FillRounded(g, stage, 3f, Theme.SurfaceAlt);
|
||||
using (var border = new Pen(Theme.Border)) g.DrawRectangle(border, Rectangle.Round(stage));
|
||||
|
||||
var crop = CropRect;
|
||||
|
||||
// Le fasce escluse si scuriscono: si vede subito quanto si sta buttando via.
|
||||
using (var shade = new SolidBrush(Color.FromArgb(150, Theme.Background)))
|
||||
{
|
||||
g.FillRectangle(shade, stage.Left, stage.Top, stage.Width, crop.Top - stage.Top);
|
||||
g.FillRectangle(shade, stage.Left, crop.Bottom, stage.Width, stage.Bottom - crop.Bottom);
|
||||
g.FillRectangle(shade, stage.Left, crop.Top, crop.Left - stage.Left, crop.Height);
|
||||
g.FillRectangle(shade, crop.Right, crop.Top, stage.Right - crop.Right, crop.Height);
|
||||
}
|
||||
|
||||
using (var pen = new Pen(Theme.Accent, 2f)) g.DrawRectangle(pen, crop.Left, crop.Top, crop.Width, crop.Height);
|
||||
using (var handle = new SolidBrush(Theme.Accent))
|
||||
{
|
||||
g.FillRectangle(handle, crop.Right - HandleSize / 2, crop.Bottom - HandleSize / 2, HandleSize, HandleSize);
|
||||
}
|
||||
|
||||
// Terzi dentro il ritaglio: servono a comporre, ed è per comporre che si sta qui.
|
||||
using (var thirds = new Pen(Color.FromArgb(70, Color.White)))
|
||||
{
|
||||
for (int i = 1; i < 3; i++)
|
||||
{
|
||||
float x = crop.Left + crop.Width * i / 3f;
|
||||
float y = crop.Top + crop.Height * i / 3f;
|
||||
g.DrawLine(thirds, x, crop.Top, x, crop.Bottom);
|
||||
g.DrawLine(thirds, crop.Left, y, crop.Right, y);
|
||||
}
|
||||
}
|
||||
|
||||
TextRenderer.DrawText(g, "AREA DI USCITA", Theme.SmallBold, new Rectangle(2, 2, 200, 16),
|
||||
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
||||
|
||||
double coverage = stage.Width * stage.Height > 0
|
||||
? crop.Width * crop.Height / (stage.Width * stage.Height)
|
||||
: 1;
|
||||
TextRenderer.DrawText(g, $"{_export.CropZoom:0.00}× · {coverage * 100:0}% dell'area sorgente",
|
||||
Theme.Small, new Rectangle(Width - 260, 2, 252, 16), Theme.TextMuted,
|
||||
TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
|
||||
|
||||
TextRenderer.DrawText(g, "trascina per spostare · angolo per stringere · doppio clic azzera",
|
||||
Theme.Small, new Rectangle(10, Height - 18, Width - 20, 16), Theme.TextFaint,
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
using Titano.Pipeline;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Pagina dell'archivio: da dove si importa, cosa verrà scritto e dove.
|
||||
///
|
||||
/// L'anteprima del piano non è un vezzo. Un'importazione sbagliata su mille file non si
|
||||
/// annulla: o si è scritto nel posto giusto con il nome giusto, o si passa la serata a
|
||||
/// rimettere in ordine. Vedere prima le cartelle che verranno create e i primi nomi che ne
|
||||
/// escono costa un istante e toglie l'unico rischio serio dell'operazione.
|
||||
/// </summary>
|
||||
internal sealed class ImportPanel : Panel
|
||||
{
|
||||
private readonly ImportSettings _settings;
|
||||
private readonly VolumeList _volumes = new() { Dock = DockStyle.Top, Height = 148 };
|
||||
private readonly PlanView _plan = new() { Dock = DockStyle.Fill };
|
||||
private readonly ProgressStrip _progress = new() { Dock = DockStyle.Bottom, Height = 78 };
|
||||
|
||||
private List<ImportCandidate> _candidates = [];
|
||||
private ImportPlan? _current;
|
||||
|
||||
/// <summary>L'utente ha scelto un supporto: il chiamante aggiorna la sorgente e rilegge.</summary>
|
||||
public event EventHandler<string>? VolumeChosen;
|
||||
|
||||
public ImportPanel(ImportSettings settings)
|
||||
{
|
||||
_settings = settings;
|
||||
BackColor = Theme.Background;
|
||||
Padding = new Padding(14, 12, 14, 12);
|
||||
|
||||
_volumes.VolumeChosen += (_, path) => VolumeChosen?.Invoke(this, path);
|
||||
|
||||
Controls.Add(_plan);
|
||||
Controls.Add(_progress);
|
||||
Controls.Add(_volumes);
|
||||
}
|
||||
|
||||
public ImportPlan? CurrentPlan => _current;
|
||||
public int CandidateCount => _candidates.Count;
|
||||
|
||||
/// <summary>Rilegge l'elenco dei supporti collegati.</summary>
|
||||
public void Refresh()
|
||||
{
|
||||
_volumes.SetVolumes(MediaImporter.Volumes(), _settings.SourcePath);
|
||||
_plan.Update(_settings, _candidates, _current);
|
||||
}
|
||||
|
||||
public void SetScan(List<ImportCandidate> candidates, ImportPlan? plan)
|
||||
{
|
||||
_candidates = candidates;
|
||||
_current = plan;
|
||||
_plan.Update(_settings, candidates, plan);
|
||||
}
|
||||
|
||||
public void RebuildPlan()
|
||||
{
|
||||
_current = _candidates.Count > 0 ? MediaImporter.Plan(_candidates, _settings) : null;
|
||||
_plan.Update(_settings, _candidates, _current);
|
||||
}
|
||||
|
||||
public void ReportProgress(PipelineProgress progress) => _progress.Report(progress);
|
||||
public void ShowResult(ImportResult result) => _progress.Show(result);
|
||||
public void ShowMessage(string message) => _progress.ShowMessage(message);
|
||||
|
||||
// ================================================================== supporti
|
||||
|
||||
private sealed class VolumeList : Control
|
||||
{
|
||||
private List<ImportVolume> _items = [];
|
||||
private string _selected = string.Empty;
|
||||
private int _hovered = -1;
|
||||
|
||||
public event EventHandler<string>? VolumeChosen;
|
||||
|
||||
public VolumeList()
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
BackColor = Theme.Background;
|
||||
Cursor = Cursors.Hand;
|
||||
}
|
||||
|
||||
public void SetVolumes(List<ImportVolume> volumes, string selected)
|
||||
{
|
||||
_items = volumes;
|
||||
_selected = selected;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private const int RowHeight = 30;
|
||||
private Rectangle Body => new(0, 26, Width, Math.Max(20, Height - 32));
|
||||
|
||||
private int IndexAt(int y)
|
||||
{
|
||||
int index = (y - Body.Top) / RowHeight;
|
||||
return index >= 0 && index < _items.Count ? index : -1;
|
||||
}
|
||||
|
||||
protected override void OnMouseMove(MouseEventArgs e)
|
||||
{
|
||||
int index = IndexAt(e.Y);
|
||||
if (index != _hovered) { _hovered = index; Invalidate(); }
|
||||
base.OnMouseMove(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseLeave(EventArgs e)
|
||||
{
|
||||
_hovered = -1;
|
||||
Invalidate();
|
||||
base.OnMouseLeave(e);
|
||||
}
|
||||
|
||||
protected override void OnMouseDown(MouseEventArgs e)
|
||||
{
|
||||
int index = IndexAt(e.Y);
|
||||
if (index < 0) return;
|
||||
_selected = _items[index].Path;
|
||||
Invalidate();
|
||||
VolumeChosen?.Invoke(this, _items[index].Path);
|
||||
base.OnMouseDown(e);
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Theme.Background);
|
||||
|
||||
TextRenderer.DrawText(g, "SUPPORTI COLLEGATI", Theme.SmallBold, new Rectangle(2, 4, 300, 16),
|
||||
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
||||
|
||||
if (_items.Count == 0)
|
||||
{
|
||||
TextRenderer.DrawText(g, "Nessun supporto rilevato. Indica una cartella dalle impostazioni.",
|
||||
Theme.Small, Body, Theme.TextFaint,
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
||||
return;
|
||||
}
|
||||
|
||||
var body = Body;
|
||||
for (int i = 0; i < _items.Count; i++)
|
||||
{
|
||||
var volume = _items[i];
|
||||
var row = new Rectangle(0, body.Top + i * RowHeight, Width, RowHeight - 2);
|
||||
if (row.Bottom > body.Bottom) break;
|
||||
|
||||
bool chosen = string.Equals(volume.Path, _selected, StringComparison.OrdinalIgnoreCase);
|
||||
if (chosen) Theme.FillRounded(g, row, 4f, Theme.SurfaceAlt);
|
||||
else if (i == _hovered) Theme.FillRounded(g, row, 4f, Theme.Surface);
|
||||
|
||||
// Il pallino distingue a colpo d'occhio una scheda da un disco interno: sono
|
||||
// due cose diverse e si sbaglia facilmente a scaricare dalla seconda.
|
||||
using (var dot = new SolidBrush(volume.Removable ? Theme.Accent : Theme.TextFaint))
|
||||
g.FillEllipse(dot, 8, row.Top + RowHeight / 2f - 4, 7, 7);
|
||||
|
||||
TextRenderer.DrawText(g, volume.Description, chosen ? Theme.BodyBold : Theme.Body,
|
||||
new Rectangle(24, row.Top, Width - 200, row.Height),
|
||||
chosen ? Theme.Text : Theme.TextMuted,
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
|
||||
|
||||
string free = $"{volume.FreeBytes / (1024.0 * 1024 * 1024):0.#} GB liberi";
|
||||
TextRenderer.DrawText(g, free, Theme.Small, new Rectangle(Width - 190, row.Top, 182, row.Height),
|
||||
Theme.TextFaint, TextFormatFlags.Right | TextFormatFlags.VerticalCenter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================== piano
|
||||
|
||||
private sealed class PlanView : Control
|
||||
{
|
||||
private readonly List<string> _lines = [];
|
||||
private string _headline = "Nessuna scansione eseguita";
|
||||
private string _detail = "Scegli un supporto e premi «Leggi supporto» per vedere cosa contiene.";
|
||||
private bool _warning;
|
||||
|
||||
public PlanView()
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
BackColor = Theme.Background;
|
||||
}
|
||||
|
||||
public void Update(ImportSettings settings, List<ImportCandidate> candidates, ImportPlan? plan)
|
||||
{
|
||||
_lines.Clear();
|
||||
_warning = false;
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
_headline = "Nessuna scansione eseguita";
|
||||
_detail = "Scegli un supporto e premi «Leggi supporto» per vedere cosa contiene.";
|
||||
Invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (plan is null)
|
||||
{
|
||||
_headline = $"{candidates.Count} file trovati";
|
||||
_detail = "Imposta la destinazione per costruire il piano.";
|
||||
Invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
string size = plan.TotalBytes >= 1L << 30
|
||||
? $"{plan.TotalBytes / (1024.0 * 1024 * 1024):0.0} GB"
|
||||
: $"{plan.TotalBytes / (1024.0 * 1024):0} MB";
|
||||
|
||||
_headline = $"{plan.Pending} da importare · {plan.Skipped} già presenti · {size}";
|
||||
_detail = plan.SessionCount > 1
|
||||
? $"{plan.SessionCount} sessioni riconosciute con pause oltre {settings.SessionGapMinutes:0} minuti"
|
||||
: "Una sola sessione riconosciuta";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(settings.DestinationRoot))
|
||||
{
|
||||
_warning = true;
|
||||
_detail = "Manca la cartella di destinazione.";
|
||||
}
|
||||
|
||||
foreach (string folder in plan.Folders.Take(6))
|
||||
{
|
||||
_lines.Add("cartella " + folder);
|
||||
}
|
||||
|
||||
foreach (var step in plan.Steps.Where(s => !s.Skipped).Take(6))
|
||||
{
|
||||
_lines.Add("file " + Path.GetFileName(step.DestinationPath));
|
||||
}
|
||||
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Theme.Background);
|
||||
|
||||
var card = new RectangleF(0.5f, 4.5f, Width - 1, Math.Max(40, Height - 12));
|
||||
Theme.FillAndStroke(g, card, 6f, Theme.Surface, _warning ? Theme.Warning : Theme.Border);
|
||||
|
||||
TextRenderer.DrawText(g, "COSA VERRÀ IMPORTATO", Theme.SmallBold, new Rectangle(16, 14, 300, 16),
|
||||
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
||||
|
||||
TextRenderer.DrawText(g, _headline, Theme.BodyBold, new Rectangle(16, 34, Width - 32, 22),
|
||||
_warning ? Theme.Warning : Theme.Text,
|
||||
TextFormatFlags.Left | TextFormatFlags.EndEllipsis);
|
||||
|
||||
TextRenderer.DrawText(g, _detail, Theme.Small, new Rectangle(16, 58, Width - 32, 18),
|
||||
Theme.TextMuted, TextFormatFlags.Left | TextFormatFlags.EndEllipsis);
|
||||
|
||||
int y = 86;
|
||||
foreach (string line in _lines)
|
||||
{
|
||||
if (y > Height - 30) break;
|
||||
TextRenderer.DrawText(g, line, Theme.Small, new Rectangle(16, y, Width - 32, 18),
|
||||
Theme.TextFaint,
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.PathEllipsis);
|
||||
y += 18;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================== avanzamento
|
||||
|
||||
private sealed class ProgressStrip : Control
|
||||
{
|
||||
private PipelineProgress _progress;
|
||||
private string _message = string.Empty;
|
||||
private bool _failed;
|
||||
|
||||
public ProgressStrip()
|
||||
{
|
||||
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
|
||||
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
|
||||
BackColor = Theme.Background;
|
||||
}
|
||||
|
||||
public void Report(PipelineProgress progress)
|
||||
{
|
||||
_progress = progress;
|
||||
_message = string.Empty;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public void ShowMessage(string message)
|
||||
{
|
||||
_message = message;
|
||||
_failed = false;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public void Show(ImportResult result)
|
||||
{
|
||||
_failed = result.Failed > 0;
|
||||
_message = $"Importati {result.Imported} file in {result.Elapsed.TotalSeconds:0.0} s" +
|
||||
(result.Skipped > 0 ? $", {result.Skipped} già presenti" : string.Empty) +
|
||||
(result.Failed > 0 ? $", {result.Failed} non riusciti" : string.Empty) +
|
||||
(result.Errors.Count > 0 ? " — " + result.Errors[0] : string.Empty);
|
||||
_progress = default;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.Clear(Theme.Background);
|
||||
|
||||
var card = new RectangleF(0.5f, 6.5f, Width - 1, Height - 10);
|
||||
Theme.FillAndStroke(g, card, 6f, Theme.Surface, _failed ? Theme.Danger : Theme.Border);
|
||||
|
||||
if (_message.Length > 0)
|
||||
{
|
||||
TextRenderer.DrawText(g, _message, Theme.Body, new Rectangle(16, 6, Width - 32, Height - 12),
|
||||
_failed ? Theme.Danger : Theme.Success,
|
||||
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_progress.Total <= 0)
|
||||
{
|
||||
TextRenderer.DrawText(g, "In attesa", Theme.Small, new Rectangle(16, 6, Width - 32, Height - 12),
|
||||
Theme.TextFaint, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
|
||||
return;
|
||||
}
|
||||
|
||||
TextRenderer.DrawText(g, $"{_progress.Message} {_progress.Completed}/{_progress.Total}",
|
||||
Theme.Body, new Rectangle(16, 14, Width - 32, 20), Theme.Text,
|
||||
TextFormatFlags.Left | TextFormatFlags.EndEllipsis);
|
||||
|
||||
var bar = new RectangleF(16, 46, Width - 32, 8);
|
||||
Theme.FillRounded(g, bar, 4f, Theme.SurfaceAlt);
|
||||
if (_progress.Fraction > 0.0005)
|
||||
{
|
||||
Theme.FillRounded(g, new RectangleF(bar.X, bar.Y, (float)(bar.Width * _progress.Fraction), bar.Height),
|
||||
4f, Theme.Accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+214
-36
@@ -30,10 +30,14 @@ internal sealed class MainForm : Form
|
||||
private readonly MotionPathChart _motionPath = new() { Dock = DockStyle.Fill };
|
||||
private readonly PlanChart _planChart = new() { Dock = DockStyle.Fill };
|
||||
private readonly ExportPanel _export;
|
||||
private readonly ImportPanel _import;
|
||||
private readonly AboutPanel _about = new() { Dock = DockStyle.Fill };
|
||||
private readonly ImportSettings _importSettings = new();
|
||||
|
||||
private readonly Dictionary<WorkspaceSection, Control> _sections = [];
|
||||
private Panel _sectionHost = null!;
|
||||
private Panel _previewHost = null!;
|
||||
private Splitter _previewSplitter = null!;
|
||||
|
||||
private readonly DarkProgressBar _progress = new() { Dock = DockStyle.Fill };
|
||||
private readonly Label _status;
|
||||
@@ -45,6 +49,8 @@ internal sealed class MainForm : Form
|
||||
private readonly DarkButton _analyzeButton = new() { Text = "Analizza sequenza", Width = 158 };
|
||||
private readonly DarkButton _exportButton = new() { Text = "Esporta video", Width = 136, Primary = true };
|
||||
private readonly DarkButton _cancelButton = new() { Text = "Annulla", Width = 92, Danger = true, Visible = false };
|
||||
private readonly DarkButton _importButton = new() { Text = "Leggi supporto", Width = 138, Primary = true };
|
||||
private readonly DarkButton _optimizeButton = new() { Text = "Ottimizza", Width = 178 };
|
||||
|
||||
private readonly DarkToggle _compareToggle = new() { Glyph = ToggleGlyph.Compare, Hint = "Confronto prima/dopo" };
|
||||
private readonly DarkToggle _zoomToggle = new() { Glyph = ToggleGlyph.Zoom, Hint = "Scala uno a uno" };
|
||||
@@ -56,7 +62,7 @@ internal sealed class MainForm : Form
|
||||
|
||||
public MainForm()
|
||||
{
|
||||
Text = "Titano — time-lapse";
|
||||
Text = "Titano";
|
||||
MinimumSize = new Size(1280, 760);
|
||||
Size = new Size(1660, 980);
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
@@ -68,9 +74,15 @@ internal sealed class MainForm : Form
|
||||
|
||||
_app.ApplyTo(_project);
|
||||
|
||||
// Una destinazione plausibile evita che la prima importazione fallisca solo perché
|
||||
// nessuno ha ancora scelto dove mettere le cose.
|
||||
_importSettings.DestinationRoot = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures), "Titano");
|
||||
|
||||
_preview = new PreviewPanel(_project) { Dock = DockStyle.Fill };
|
||||
_settings = new SettingsPanel(_project, _app) { Dock = DockStyle.Fill };
|
||||
_settings = new SettingsPanel(_project, _app, _importSettings) { Dock = DockStyle.Fill };
|
||||
_export = new ExportPanel(_project) { Dock = DockStyle.Fill };
|
||||
_import = new ImportPanel(_importSettings) { Dock = DockStyle.Fill };
|
||||
|
||||
_status = new Label
|
||||
{
|
||||
@@ -91,9 +103,14 @@ internal sealed class MainForm : Form
|
||||
|
||||
LoadApplicationIcon();
|
||||
BuildLayout();
|
||||
|
||||
// La barra nasce sulla prima voce, che è l'archivio: portarla su Sequenza prima di
|
||||
// collegare gli eventi evita che il selettore esca subito perché l'indice coincide,
|
||||
// lasciando la barra su una sezione e il contenuto su un'altra.
|
||||
_rail.Selected = WorkspaceSection.Sequence;
|
||||
WireEvents();
|
||||
_export.Configure(_app);
|
||||
ShowSection(WorkspaceSection.Sequence);
|
||||
ShowSection(_rail.Selected);
|
||||
UpdateCommandState();
|
||||
}
|
||||
|
||||
@@ -156,6 +173,7 @@ internal sealed class MainForm : Form
|
||||
_sections[WorkspaceSection.Exposure] = exposurePage;
|
||||
_sections[WorkspaceSection.Motion] = Card(_motionPath, "Percorso della stabilizzazione", DockStyle.Fill);
|
||||
_sections[WorkspaceSection.Timing] = Card(_planChart, "Piano temporale", DockStyle.Fill);
|
||||
_sections[WorkspaceSection.Archive] = _import;
|
||||
_sections[WorkspaceSection.Export] = _export;
|
||||
_sections[WorkspaceSection.Preferences] = _about;
|
||||
|
||||
@@ -167,13 +185,13 @@ internal sealed class MainForm : Form
|
||||
_sectionHost.Controls.Add(section);
|
||||
}
|
||||
|
||||
var previewHost = Card(_preview, "Anteprima", DockStyle.Top, 330, BuildPreviewTools());
|
||||
var splitter = new Splitter { Dock = DockStyle.Top, Height = 6, BackColor = Theme.Background };
|
||||
_previewHost = Card(_preview, "Anteprima", DockStyle.Top, 330, BuildPreviewTools());
|
||||
_previewSplitter = new Splitter { Dock = DockStyle.Top, Height = 6, BackColor = Theme.Background };
|
||||
|
||||
var center = new Panel { Dock = DockStyle.Fill, BackColor = Theme.Background };
|
||||
center.Controls.Add(_sectionHost);
|
||||
center.Controls.Add(splitter);
|
||||
center.Controls.Add(previewHost);
|
||||
center.Controls.Add(_previewSplitter);
|
||||
center.Controls.Add(_previewHost);
|
||||
|
||||
Controls.Add(center);
|
||||
Controls.Add(_settingsHost);
|
||||
@@ -247,33 +265,8 @@ internal sealed class MainForm : Form
|
||||
TextAlign = ContentAlignment.MiddleLeft,
|
||||
};
|
||||
|
||||
var subtitle = new Label
|
||||
{
|
||||
Text = "elaborazione time-lapse",
|
||||
Font = Theme.Small,
|
||||
ForeColor = Theme.TextFaint,
|
||||
AutoSize = false,
|
||||
Bounds = new Rectangle(104, 20, 152, 18),
|
||||
TextAlign = ContentAlignment.MiddleLeft,
|
||||
};
|
||||
|
||||
int x = 272;
|
||||
foreach (var button in new[] { _addFilesButton, _addFolderButton, _clearButton })
|
||||
{
|
||||
button.Bounds = new Rectangle(x, 13, button.Width, 32);
|
||||
bar.Controls.Add(button);
|
||||
x += button.Width + 8;
|
||||
}
|
||||
|
||||
_analyzeButton.Bounds = new Rectangle(x + 16, 13, _analyzeButton.Width, 32);
|
||||
bar.Controls.Add(_analyzeButton);
|
||||
x += _analyzeButton.Width + 24;
|
||||
|
||||
_exportButton.Bounds = new Rectangle(x, 13, _exportButton.Width, 32);
|
||||
bar.Controls.Add(_exportButton);
|
||||
|
||||
foreach (var button in ToolbarButtons) bar.Controls.Add(button);
|
||||
bar.Controls.Add(title);
|
||||
bar.Controls.Add(subtitle);
|
||||
|
||||
bar.Paint += (_, e) =>
|
||||
{
|
||||
@@ -283,6 +276,32 @@ internal sealed class MainForm : Form
|
||||
return bar;
|
||||
}
|
||||
|
||||
/// <summary>I pulsanti della barra, nell'ordine in cui compaiono quando sono visibili.</summary>
|
||||
private IEnumerable<DarkButton> ToolbarButtons =>
|
||||
[
|
||||
_importButton, _addFilesButton, _addFolderButton, _clearButton, _analyzeButton,
|
||||
_optimizeButton, _exportButton,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Ridispone i pulsanti mostrati uno dopo l'altro. Con una barra contestuale le posizioni
|
||||
/// fisse lascerebbero buchi dove i pulsanti nascosti stavano prima.
|
||||
///
|
||||
/// L'elenco arriva dal chiamante e non si legge da <c>Visible</c>: finché la finestra non
|
||||
/// è stata mostrata quella proprietà restituisce falso anche per i controlli appena
|
||||
/// accesi, perché riporta la visibilità effettiva dell'intera catena. Leggendola qui,
|
||||
/// alla costruzione nessun pulsante verrebbe posizionato.
|
||||
/// </summary>
|
||||
private static void LayoutToolbar(IReadOnlyList<DarkButton> shown)
|
||||
{
|
||||
int x = 132;
|
||||
foreach (var button in shown)
|
||||
{
|
||||
button.Bounds = new Rectangle(x, 13, button.Width, 32);
|
||||
x += button.Width + 8;
|
||||
}
|
||||
}
|
||||
|
||||
private Panel BuildStatusBar()
|
||||
{
|
||||
var bar = new Panel { Dock = DockStyle.Bottom, Height = 52, BackColor = Theme.Background };
|
||||
@@ -333,6 +352,15 @@ internal sealed class MainForm : Form
|
||||
|
||||
_rail.SelectionChanged += (_, _) => ShowSection(_rail.Selected);
|
||||
|
||||
_importButton.Click += async (_, _) => await ImportAsync();
|
||||
_optimizeButton.Click += (_, _) => OptimizeCurrentSection();
|
||||
_import.VolumeChosen += (_, path) =>
|
||||
{
|
||||
_importSettings.SourcePath = path;
|
||||
_settings.ShowImportSource();
|
||||
_import.Refresh();
|
||||
};
|
||||
|
||||
_chart.SelectionChanged += (_, _) => Select(_chart.SelectedIndex);
|
||||
_table.SelectionChanged += (_, _) => Select(_table.SelectedIndex);
|
||||
_timeline.SelectionChanged += (_, _) => Select(_timeline.SelectedIndex);
|
||||
@@ -382,6 +410,7 @@ internal sealed class MainForm : Form
|
||||
_app.Save();
|
||||
_export.Configure(_app);
|
||||
};
|
||||
_settings.ImportChanged += (_, _) => _import.RebuildPlan();
|
||||
}
|
||||
|
||||
private void ShowSection(WorkspaceSection section)
|
||||
@@ -390,8 +419,55 @@ internal sealed class MainForm : Form
|
||||
_settings.ShowSection(section);
|
||||
_export.SetActive(section == WorkspaceSection.Export);
|
||||
|
||||
// Le preferenze non riguardano la sequenza caricata: mostrarle accanto all'anteprima
|
||||
// e al riepilogo degli scatti confonderebbe due piani diversi, e ruberebbe alla
|
||||
// pagina lo spazio che le serve per essere letta senza scorrere.
|
||||
bool sequenceContext = section is not (WorkspaceSection.Preferences or WorkspaceSection.Archive);
|
||||
_previewHost.Visible = sequenceContext;
|
||||
_previewSplitter.Visible = sequenceContext;
|
||||
_summary.Visible = sequenceContext;
|
||||
|
||||
UpdateToolbarFor(section);
|
||||
|
||||
if (section == WorkspaceSection.Export) _export.Refresh(_project);
|
||||
if (section == WorkspaceSection.Preferences) _about.Update(_project, _app);
|
||||
if (section == WorkspaceSection.Archive) _import.Refresh();
|
||||
|
||||
if (section == WorkspaceSection.Preferences)
|
||||
{
|
||||
_about.Update(_project, _app);
|
||||
// Anche la riga di stato parla della sequenza: qui non c'entra nulla.
|
||||
SetStatus("Preferenze dell'applicazione. Valgono per ogni sequenza e restano salvate.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// I comandi seguono la sezione. Un pulsante che non ha senso dove ci si trova non va
|
||||
/// disabilitato ma tolto: disabilitato resta un ingombro che chiede perché non funziona.
|
||||
/// </summary>
|
||||
private void UpdateToolbarFor(WorkspaceSection section)
|
||||
{
|
||||
bool onSequence = section == WorkspaceSection.Sequence;
|
||||
bool onArchive = section == WorkspaceSection.Archive;
|
||||
|
||||
var shown = new List<DarkButton>();
|
||||
if (onArchive) shown.Add(_importButton);
|
||||
if (onSequence) { shown.Add(_addFilesButton); shown.Add(_addFolderButton); shown.Add(_clearButton); }
|
||||
if (onSequence) shown.Add(_analyzeButton);
|
||||
if (section is not (WorkspaceSection.Preferences or WorkspaceSection.Archive)) shown.Add(_optimizeButton);
|
||||
if (section == WorkspaceSection.Export) shown.Add(_exportButton);
|
||||
|
||||
foreach (var button in ToolbarButtons) button.Visible = shown.Contains(button);
|
||||
|
||||
_optimizeButton.Text = section switch
|
||||
{
|
||||
WorkspaceSection.Sequence => "Ottimizza lettura",
|
||||
WorkspaceSection.Exposure => "Ottimizza esposizione",
|
||||
WorkspaceSection.Motion => "Ottimizza movimento",
|
||||
WorkspaceSection.Timing => "Ottimizza tempo",
|
||||
_ => "Ottimizza esportazione",
|
||||
};
|
||||
|
||||
LayoutToolbar(shown);
|
||||
}
|
||||
|
||||
/// <summary>Allinea tutte le viste sullo stesso fotogramma, qualunque l'abbia scelto.</summary>
|
||||
@@ -405,6 +481,108 @@ internal sealed class MainForm : Form
|
||||
ShowPreview(index);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rilegge il supporto, costruisce il piano e — se la destinazione c'è — lo esegue.
|
||||
/// Sono due passi distinti apposta: il primo non tocca il disco e si può guardare.
|
||||
/// </summary>
|
||||
private async Task ImportAsync()
|
||||
{
|
||||
if (_busy) return;
|
||||
|
||||
if (!Directory.Exists(_importSettings.SourcePath))
|
||||
{
|
||||
_import.ShowMessage("Scegli un supporto o una cartella di origine.");
|
||||
return;
|
||||
}
|
||||
|
||||
bool ready = _import.CandidateCount > 0 && _import.CurrentPlan is { Pending: > 0 };
|
||||
|
||||
BeginOperation(ready ? "Importazione…" : "Lettura del supporto…");
|
||||
try
|
||||
{
|
||||
var progress = new Progress<PipelineProgress>(p =>
|
||||
{
|
||||
ReportProgress(p);
|
||||
_import.ReportProgress(p);
|
||||
});
|
||||
|
||||
if (!ready)
|
||||
{
|
||||
var settings = _importSettings.Clone();
|
||||
var candidates = await Task.Run(
|
||||
() => MediaImporter.Scan(settings, progress, _operation!.Token), _operation!.Token);
|
||||
|
||||
var plan = candidates.Count > 0 && !string.IsNullOrWhiteSpace(settings.DestinationRoot)
|
||||
? MediaImporter.Plan(candidates, settings)
|
||||
: null;
|
||||
|
||||
_import.SetScan(candidates, plan);
|
||||
SetStatus(candidates.Count == 0
|
||||
? "Nessun file d'immagine riconosciuto nel percorso indicato."
|
||||
: $"{candidates.Count} file letti dal supporto. Controlla il piano e premi di nuovo per importare.");
|
||||
return;
|
||||
}
|
||||
|
||||
var current = _import.CurrentPlan!;
|
||||
var executing = _importSettings.Clone();
|
||||
var result = await Task.Run(
|
||||
() => MediaImporter.Execute(current, executing, progress, _operation!.Token), _operation!.Token);
|
||||
|
||||
_import.ShowResult(result);
|
||||
SetStatus($"Importati {result.Imported} file" +
|
||||
(result.Skipped > 0 ? $", {result.Skipped} già presenti" : string.Empty) +
|
||||
(result.Failed > 0 ? $", {result.Failed} non riusciti" : string.Empty) +
|
||||
$" in {result.Elapsed.TotalSeconds:0.0} s.");
|
||||
|
||||
// La cartella appena riempita è quasi sempre quella che si vuole aprire subito.
|
||||
if (result.Imported > 0 && result.FirstFolder is { } folder)
|
||||
{
|
||||
_app.LastFolder = folder;
|
||||
_app.Save();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_import.ShowMessage("Importazione interrotta.");
|
||||
SetStatus("Importazione interrotta.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_import.ShowMessage("Importazione non riuscita: " + ex.Message);
|
||||
SetStatus("Importazione non riuscita: " + ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndOperation();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Rileva le impostazioni ottimali per la sezione in vista e dice cosa ha cambiato.</summary>
|
||||
private void OptimizeCurrentSection()
|
||||
{
|
||||
if (_busy) return;
|
||||
|
||||
var area = _rail.Selected switch
|
||||
{
|
||||
WorkspaceSection.Sequence => WorkspaceArea.Sequence,
|
||||
WorkspaceSection.Exposure => WorkspaceArea.Exposure,
|
||||
WorkspaceSection.Motion => WorkspaceArea.Motion,
|
||||
WorkspaceSection.Timing => WorkspaceArea.Timing,
|
||||
_ => WorkspaceArea.Export,
|
||||
};
|
||||
|
||||
var changes = AutoOptimizer.Optimize(_project, area);
|
||||
|
||||
// I controlli leggono il progetto quando vengono costruiti: dopo un'ottimizzazione
|
||||
// che tocca interruttori e menu, la colonna va rifatta o mostrerebbe i valori vecchi.
|
||||
RebuildSettingsPanel();
|
||||
RecomputeCurve();
|
||||
RefreshDerived();
|
||||
ShowPreview(_table.SelectedIndex);
|
||||
|
||||
SetStatus(string.Join(" ", changes));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ comandi
|
||||
|
||||
private void AddFiles()
|
||||
@@ -851,7 +1029,7 @@ internal sealed class MainForm : Form
|
||||
// ------------------------------------------------------------------ modalità di cattura
|
||||
|
||||
internal void SelectSettingsTab(int index)
|
||||
=> _rail.Selected = (WorkspaceSection)Math.Clamp(index, 0, 5);
|
||||
=> _rail.Selected = (WorkspaceSection)Math.Clamp(index, 0, 6);
|
||||
|
||||
internal async Task PrepareForCaptureAsync(IReadOnlyList<string> paths)
|
||||
{
|
||||
@@ -894,7 +1072,7 @@ internal sealed class MainForm : Form
|
||||
_settingsHost.Controls.Remove(_settings);
|
||||
_settings.Dispose();
|
||||
|
||||
_settings = new SettingsPanel(_project, _app) { Dock = DockStyle.Fill };
|
||||
_settings = new SettingsPanel(_project, _app, _importSettings) { Dock = DockStyle.Fill };
|
||||
_settingsHost.Controls.Add(_settings);
|
||||
WireSettingsEvents();
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Titano.UI;
|
||||
/// <summary>Le sezioni in cui è divisa la finestra, nell'ordine in cui compaiono nella barra.</summary>
|
||||
internal enum WorkspaceSection
|
||||
{
|
||||
Archive,
|
||||
Sequence,
|
||||
Exposure,
|
||||
Motion,
|
||||
@@ -32,6 +33,7 @@ internal sealed class NavigationRail : Control
|
||||
|
||||
private readonly Item[] _items =
|
||||
[
|
||||
new(WorkspaceSection.Archive, "Archivio", "Importazione da schede e fotocamere, nomi e cartelle"),
|
||||
new(WorkspaceSection.Sequence, "Sequenza", "Fotogrammi, cadenza e lettura dei file"),
|
||||
new(WorkspaceSection.Exposure, "Esposizione", "Deflicker, regioni e transizioni giorno-notte"),
|
||||
new(WorkspaceSection.Motion, "Movimento", "Stabilizzazione, camera virtuale e sfocatura"),
|
||||
@@ -198,6 +200,20 @@ internal sealed class NavigationRail : Control
|
||||
|
||||
switch (section)
|
||||
{
|
||||
case WorkspaceSection.Archive:
|
||||
// Cartella con la linguetta: l'archivio, non un supporto specifico.
|
||||
g.DrawLines(pen,
|
||||
[
|
||||
new PointF(x + 1, y + h - 1.5f),
|
||||
new PointF(x + 1, y + 3.5f),
|
||||
new PointF(x + w * 0.42f, y + 3.5f),
|
||||
new PointF(x + w * 0.55f, y + 6.5f),
|
||||
new PointF(x + w - 1, y + 6.5f),
|
||||
new PointF(x + w - 1, y + h - 1.5f),
|
||||
]);
|
||||
g.DrawLine(pen, x + 1, y + h - 1.5f, x + w - 1, y + h - 1.5f);
|
||||
break;
|
||||
|
||||
case WorkspaceSection.Sequence:
|
||||
// Tre fotogrammi impilati e sfalsati.
|
||||
g.DrawRectangle(pen, x + 0.5f, y + 5.5f, w - 6, h - 8);
|
||||
|
||||
+629
-152
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
using System.Drawing.Drawing2D;
|
||||
|
||||
namespace Titano.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Suggerimenti a comparsa, disegnati a mano nel tema scuro.
|
||||
///
|
||||
/// Le spiegazioni stanno qui invece che sotto ai controlli per un motivo pratico: una nota
|
||||
/// stampata sotto un cursore deve essere corta, perché occupa spazio a chi la conosce già.
|
||||
/// Un suggerimento che compare solo quando serve non ha quel vincolo, e può permettersi di
|
||||
/// dire l'unica cosa che conta davvero — perché quel parametro esiste, e cosa succede se lo
|
||||
/// si sposta nella direzione sbagliata.
|
||||
///
|
||||
/// Il suggerimento di sistema è disegnato dal tema di Windows, che qui sarebbe chiaro su
|
||||
/// scuro: viene quindi disegnato per intero, con la stessa tavolozza del resto.
|
||||
/// </summary>
|
||||
internal static class Tips
|
||||
{
|
||||
private const int MaximumWidth = 380;
|
||||
|
||||
private static readonly ToolTip Instance = Create();
|
||||
|
||||
private static ToolTip Create()
|
||||
{
|
||||
var tip = new ToolTip
|
||||
{
|
||||
OwnerDraw = true,
|
||||
InitialDelay = 380,
|
||||
ReshowDelay = 120,
|
||||
AutoPopDelay = 32000, // le spiegazioni lunghe devono restare leggibili
|
||||
ShowAlways = true,
|
||||
UseAnimation = false,
|
||||
UseFading = false,
|
||||
};
|
||||
|
||||
tip.Popup += (_, e) =>
|
||||
{
|
||||
var size = TextRenderer.MeasureText(tip.GetToolTip(e.AssociatedControl) ?? string.Empty,
|
||||
Theme.Small, new Size(MaximumWidth, 0),
|
||||
TextFormatFlags.WordBreak);
|
||||
e.ToolTipSize = new Size(Math.Min(MaximumWidth, size.Width) + 22, size.Height + 18);
|
||||
};
|
||||
|
||||
tip.Draw += (_, e) =>
|
||||
{
|
||||
var g = e.Graphics;
|
||||
Theme.HighQuality(g);
|
||||
g.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
|
||||
var bounds = new RectangleF(0.5f, 0.5f, e.Bounds.Width - 1, e.Bounds.Height - 1);
|
||||
Theme.FillAndStroke(g, bounds, 5f, Theme.SurfaceAlt, Theme.BorderStrong);
|
||||
|
||||
TextRenderer.DrawText(g, e.ToolTipText, Theme.Small,
|
||||
Rectangle.Inflate(e.Bounds, -11, -9), Theme.Text,
|
||||
TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.WordBreak);
|
||||
};
|
||||
|
||||
return tip;
|
||||
}
|
||||
|
||||
/// <summary>Associa una spiegazione a un controllo e a tutti i suoi figli.</summary>
|
||||
public static void Set(Control control, string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return;
|
||||
|
||||
Instance.SetToolTip(control, text);
|
||||
|
||||
// Un LabeledCombo è un pannello con dentro etichetta e menu: senza propagare, il
|
||||
// suggerimento comparirebbe solo sui pochi pixel di bordo fra i due.
|
||||
foreach (Control child in control.Controls) Set(child, text);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user