Rifa' l'impaginazione attorno a una barra verticale sul fianco sinistro che governa insieme il contenuto principale e la colonna delle impostazioni: una sezione, una vista, i suoi comandi. Prima c'erano due gerarchie di schede da tenere allineate a mano; adesso ce n'e' una sola, in verticale ci sta il nome per esteso, e la sezione scelta resta leggibile mentre si lavora - cosa che in una fila di schede in cima si perde appena l'occhio scende sul contenuto. Accanto a ogni voce compare il numero di avvisi che la riguardano. L'anteprima non e' piu' il contenuto di una scheda fra le altre: resta in alto sempre, perche' in un programma che tratta immagini l'immagine si guarda mentre si regola qualunque cosa. Guadagna le quattro cose che la rendono utile per giudicare e non solo per guardare. Zoom e trascinamento, perche' una stabilizzazione sotto il pixel non si vede su un'immagine rimpicciolita per stare in un riquadro, e da scala uno a uno in su i pixel si mostrano come sono invece di essere interpolati. Confronto a tendina fra originale e corretto, perche' l'unico modo di capire cosa una correzione stia facendo e' vedere accanto ciо' che c'era prima. Campo vettoriale sovrapposto, che il motore calcola comunque e che spiega perche' la sfocatura viene come viene, soprattutto quando e' sbagliata. Confine delle regioni, campionato attraverso la stessa mappatura del ritaglio cosi' resta al suo posto anche sotto una panoramica virtuale. Ogni sezione porta lo strumento di misura che le riguarda: striscia di provini e pannello degli avvisi sulla sequenza, istogramma e forma d'onda sull'esposizione, percorso ricostruito della stabilizzazione sul movimento, piano temporale sul tempo. Sono tutte grandezze che il motore gia' calcolava e che finivano in due numeri in fondo a una riga di stato, cioe' invisibili. Il pilota automatico. I parametri deducibili dalle misure non si chiedono piu': un cursore con l'indicatore auto mostra il valore scelto e il motivo, toccarlo passa il comando all'utente, l'indicatore lo restituisce. E' lo stesso modello che il menu dell'orientamento usava da solo, esteso a larghezza di analisi, finestra del deflicker, lunghezza della transizione, finestra della stabilizzazione e tetto di memoria. Non sono valori di comodo: la finestra del deflicker viene da quattro periodi dello sfarfallio misurati sull'autocorrelazione, quella della stabilizzazione e' la piu' corta che rende liscio il percorso, la transizione e' meta' della distanza tipica fra i cambi. Dove il valore giusto non si puo' misurare il direttore non inventa: restituisce meno decisioni e lascia il cursore dov'e'. Il riquadro sponsor sta soltanto nella scheda Esportazione, perche' quello e' l'unico momento in cui non c'e' niente da fare e uno spazio pubblicitario non toglie niente a nessuno; accanto a un cursore che si sta regolando sarebbe un ostacolo. Gli annunci si leggono da una cartella locale con un listino in formato testo, ed e' importante dire cosa NON fa: nessuna rete, nessun identificativo, nessun clic registrato. Non e' prudenza eccessiva - un circuito pubblicitario vero richiederebbe il suo SDK, che il vincolo sulle dipendenze esclude, e comunque significherebbe far uscire dati dalla macchina di chi sta montando un time-lapse. Le campagne si aggiornano copiando file; a listino vuoto compaiono note interne; il riquadro si spegne dalle preferenze. Aggiunge anche le preferenze dell'applicazione, distinte da quelle del progetto, con persistenza in un file di testo scritto a mano nello stesso spirito del resto: una riga per voce, correggibile con un editor. Il progetto descrive come trattare questi fotogrammi, le preferenze come si comporta il programma. Piu' due cose piccole che pesavano: immissione numerica sui cursori, perche' trascinare fino a 0,35 e' un esercizio di mira e non una regolazione, e anteprima dei fotogrammi mentre vengono codificati, che non migliora il risultato di un pixel ma cambia molto un'attesa di mezz'ora. Verifica: 54 controlli invariati, Debug e Release puliti, tutte e sei le sezioni ispezionate a video sulla scena di prova. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
878 lines
38 KiB
C#
878 lines
38 KiB
C#
using Titano.Analysis;
|
||
using Titano.Motion;
|
||
using Titano.Pipeline;
|
||
using Titano.Video;
|
||
|
||
namespace Titano.UI;
|
||
|
||
/// <summary>
|
||
/// Colonna delle impostazioni. Non ha più schede proprie: mostra la pagina della sezione
|
||
/// scelta nella barra di navigazione, così la barra governa insieme il contenuto principale
|
||
/// e i comandi che lo riguardano, e non esistono due gerarchie di schede da tenere allineate.
|
||
///
|
||
/// Ogni controllo dichiara quale parte della pipeline invalida. C'è differenza fra spostare
|
||
/// un cursore che ricalcola una curva già misurata e sceglierne uno che obbliga a rileggere
|
||
/// mille file, e il programma deve saperla.
|
||
/// </summary>
|
||
internal sealed class SettingsPanel : Panel
|
||
{
|
||
/// <summary>Valori Exif corrispondenti alle voci del menu dell'orientamento; null = automatico.</summary>
|
||
private static readonly int?[] OrientationValues = [null, 1, 2, 4, 6, 3, 8];
|
||
|
||
private readonly TitanoProject _project;
|
||
private readonly AppSettings _app;
|
||
private readonly Dictionary<WorkspaceSection, Panel> _pages = [];
|
||
private readonly Dictionary<AutoKey, ParameterSlider> _autoSliders = [];
|
||
|
||
private LabeledCombo? _orientationCombo;
|
||
private Label? _analysisNote;
|
||
private CameraEditor? _cameraEditor;
|
||
private SplineEditor? _rampEditor;
|
||
private ParameterSlider? _keyframeCentreX;
|
||
private ParameterSlider? _keyframeCentreY;
|
||
private ParameterSlider? _keyframeZoom;
|
||
private ParameterSlider? _keyframeEaseOut;
|
||
private ParameterSlider? _keyframeEaseIn;
|
||
private bool _syncingKeyframe;
|
||
|
||
public event EventHandler? DeflickerChanged;
|
||
public event EventHandler? AnalysisInvalidated;
|
||
public event EventHandler? PreviewInvalidated;
|
||
public event EventHandler? BrowseOutputRequested;
|
||
public event EventHandler? AppSettingsChanged;
|
||
|
||
public TextBox OutputPathBox { get; }
|
||
|
||
public SettingsPanel(TitanoProject project, AppSettings app)
|
||
{
|
||
_project = project;
|
||
_app = app;
|
||
BackColor = Theme.Surface;
|
||
|
||
OutputPathBox = new TextBox
|
||
{
|
||
BackColor = Theme.SurfaceAlt,
|
||
ForeColor = Theme.Text,
|
||
BorderStyle = BorderStyle.FixedSingle,
|
||
Font = Theme.Body,
|
||
Dock = DockStyle.Top,
|
||
};
|
||
OutputPathBox.TextChanged += (_, _) =>
|
||
{
|
||
_project.Export.OutputPath = OutputPathBox.Text;
|
||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||
};
|
||
|
||
_pages[WorkspaceSection.Sequence] = BuildSequencePage();
|
||
_pages[WorkspaceSection.Exposure] = BuildExposurePage();
|
||
_pages[WorkspaceSection.Motion] = BuildMotionPage();
|
||
_pages[WorkspaceSection.Timing] = BuildTimingPage();
|
||
_pages[WorkspaceSection.Export] = BuildExportPage();
|
||
_pages[WorkspaceSection.Preferences] = BuildPreferencesPage();
|
||
|
||
foreach (var page in _pages.Values)
|
||
{
|
||
page.Dock = DockStyle.Fill;
|
||
page.Visible = false;
|
||
Controls.Add(page);
|
||
}
|
||
|
||
ShowSection(WorkspaceSection.Sequence);
|
||
}
|
||
|
||
public void ShowSection(WorkspaceSection section)
|
||
{
|
||
foreach (var (key, page) in _pages) page.Visible = key == section;
|
||
}
|
||
|
||
// ------------------------------------------------------------------ aggiornamenti dall'esterno
|
||
|
||
public void ShowDetectedOrientation(Imaging.OrientationDetection detection)
|
||
{
|
||
if (_orientationCombo is null || _orientationCombo.Combo.Items.Count == 0) return;
|
||
|
||
int selected = _orientationCombo.Combo.SelectedIndex;
|
||
_orientationCombo.Combo.Items[0] = $"Automatico — {detection.Description}";
|
||
_orientationCombo.Combo.SelectedIndex = selected;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Riporta nei cursori i valori che il direttore ha dedotto, con il motivo di ciascuno.
|
||
/// Quelli di cui l'utente ha preso il controllo restano dove sono.
|
||
/// </summary>
|
||
public void ShowAutoDecisions(IReadOnlyDictionary<AutoKey, AutoDecision> decisions)
|
||
{
|
||
foreach (var (key, slider) in _autoSliders)
|
||
{
|
||
bool manual = _project.ManualParameters.Contains(key);
|
||
slider.IsAuto = !manual;
|
||
|
||
if (!decisions.TryGetValue(key, out var decision)) continue;
|
||
slider.AutoReason = decision.Reason;
|
||
if (!manual) slider.SetValueSilently(decision.Value);
|
||
}
|
||
Invalidate(true);
|
||
}
|
||
|
||
public void ShowAnalysis()
|
||
{
|
||
if (_analysisNote is null) return;
|
||
|
||
var lines = new List<string>();
|
||
|
||
if (_project.Regions.Mode != RegionMode.Off)
|
||
{
|
||
lines.Add(_project.Mask is { } mask
|
||
? "Regioni: " + mask.Description
|
||
: "Regioni: la scena non si divide in modo utile, resta la curva unica.");
|
||
}
|
||
|
||
if (_project.HolyGrail.Enabled && _project.Transitions is { } transitions)
|
||
{
|
||
lines.Add(transitions.StepCount == 0
|
||
? "Transizioni: nessun cambio di impostazione rilevato."
|
||
: $"Transizioni: {transitions.StepCount} cambi" +
|
||
(transitions.UnobservedSteps > 0 ? $", {transitions.UnobservedSteps} non recepiti" : string.Empty) +
|
||
$", il maggiore di {transitions.LargestStepStops:0.00} EV.");
|
||
}
|
||
|
||
if (_project.Stabilization.Enabled && _project.Motion is { } motion)
|
||
{
|
||
var (width, _) = _project.ResolveSourceSize();
|
||
lines.Add($"Stabilizzazione: tremolio medio {motion.MeanShake * Math.Max(1, width):0.0} px, " +
|
||
$"ritaglio {(_project.StabilizationZoom - 1) * 100:0.#}%.");
|
||
}
|
||
|
||
_analysisNote.Text = lines.Count > 0
|
||
? string.Join(Environment.NewLine, lines)
|
||
: "Esegui l'analisi per vedere cosa il motore ha dedotto dalla sequenza.";
|
||
LayoutNote(_analysisNote);
|
||
}
|
||
|
||
public void ShowSequenceGeometry()
|
||
{
|
||
if (_cameraEditor is null) return;
|
||
var (width, height) = _project.ResolveNativeSize();
|
||
if (width > 0 && height > 0) _cameraEditor.Aspect = width / (double)height;
|
||
_cameraEditor.Invalidate();
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Sequenza
|
||
|
||
private Panel BuildSequencePage()
|
||
{
|
||
var stack = NewStack();
|
||
|
||
stack.Add(new SectionHeader("Sequenza"));
|
||
stack.Add(Combo("Risoluzione di lavoro",
|
||
["Nativa (piena risoluzione)", "3840 px (4K UHD)", "2560 px", "1920 px (Full HD)", "1280 px"],
|
||
WorkingWidthToIndex(_project.General.WorkingWidth),
|
||
index =>
|
||
{
|
||
_project.General.WorkingWidth = index switch { 1 => 3840, 2 => 2560, 3 => 1920, 4 => 1280, _ => 0 };
|
||
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
_orientationCombo = Combo("Orientamento dei fotogrammi",
|
||
["Automatico", "Nessuna rotazione", "Specchia orizzontalmente", "Specchia verticalmente",
|
||
"Ruota 90° in senso orario", "Ruota 180°", "Ruota 90° in senso antiorario"],
|
||
Array.IndexOf(OrientationValues, _project.General.OrientationOverride),
|
||
index =>
|
||
{
|
||
_project.General.OrientationOverride = OrientationValues[Math.Clamp(index, 0, OrientationValues.Length - 1)];
|
||
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
|
||
});
|
||
stack.Add(_orientationCombo);
|
||
|
||
stack.Add(Slider("Tolleranza sulla cadenza", 0.05, 1.0, _project.General.CadenceTolerance, 0.05, "0.00", "×",
|
||
value =>
|
||
{
|
||
_project.General.CadenceTolerance = value;
|
||
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(new SectionHeader("Qualità di elaborazione"));
|
||
stack.Add(Combo("Profilo",
|
||
["Bozza — veloce", "Standard — equilibrio", "Massima — resa migliore"],
|
||
(int)_project.General.Quality,
|
||
index =>
|
||
{
|
||
_project.ApplyQualityProfile((QualityProfile)Math.Clamp(index, 0, 2));
|
||
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(Note("Il profilo governa finezza del campo vettoriale, campioni della sfocatura, " +
|
||
"riquadri della correlazione di fase e risoluzione delle passate di analisi."));
|
||
|
||
stack.Add(new SectionHeader("Prestazioni e memoria"));
|
||
|
||
stack.Add(AutoSlider(AutoKey.AnalysisWidth, "Larghezza della passata di analisi", 256, 2048,
|
||
_project.General.AnalysisWidth, 64, "0", "px",
|
||
value =>
|
||
{
|
||
_project.General.AnalysisWidth = (int)value;
|
||
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(Slider("Decodifiche simultanee", 1, 16, _project.General.DecodeParallelism, 1, "0", "thread",
|
||
value =>
|
||
{
|
||
_project.General.DecodeParallelism = (int)value;
|
||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(Slider("Lettura in anticipo", 0, 32, _project.Cache.PrefetchDepth, 1, "0", "fotogrammi",
|
||
value => _project.Cache.PrefetchDepth = (int)value));
|
||
|
||
stack.Add(AutoSlider(AutoKey.MemoryBudget, "Tetto di memoria", 256, 32768,
|
||
_project.Cache.MemoryBudgetMiB, 256, "0", "MiB",
|
||
value => _project.Cache.MemoryBudgetMiB = (int)value));
|
||
|
||
stack.Add(Check("Parcheggia su disco i fotogrammi in eccesso", _project.Cache.AllowDiskSpill,
|
||
value => _project.Cache.AllowDiskSpill = value));
|
||
|
||
stack.Add(Note("La finestra attiva sta sempre in memoria; il tetto governa la lettura in " +
|
||
"anticipo. Oltre il tetto i fotogrammi già letti aspettano su disco e vengono " +
|
||
"ripresi una volta sola. Il file di parcheggio si cancella da sé."));
|
||
|
||
stack.Add(new SectionHeader("Esito dell'analisi"));
|
||
_analysisNote = Note("Esegui l'analisi per vedere cosa il motore ha dedotto dalla sequenza.");
|
||
stack.Add(_analysisNote);
|
||
|
||
return stack.Panel;
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Esposizione
|
||
|
||
private Panel BuildExposurePage()
|
||
{
|
||
var stack = NewStack();
|
||
|
||
stack.Add(new SectionHeader("Deflicker"));
|
||
|
||
stack.Add(Check("Correzione dell'esposizione attiva", _project.Deflicker.Enabled, value =>
|
||
{
|
||
_project.Deflicker.Enabled = value;
|
||
DeflickerChanged?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(AutoSlider(AutoKey.DeflickerWindow, "Finestra temporale", 3, 121,
|
||
_project.Deflicker.WindowFrames, 2, "0", "fotogrammi",
|
||
value => { _project.Deflicker.WindowFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Slider("Intensità della correzione", 0, 1, _project.Deflicker.Strength, 0.05, "0.00", string.Empty,
|
||
value => { _project.Deflicker.Strength = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Slider("Correzione massima", 0.1, 3.0, _project.Deflicker.MaxCorrectionStops, 0.1, "0.0", "EV",
|
||
value => { _project.Deflicker.MaxCorrectionStops = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Check("Scarta i fotogrammi anomali", _project.Deflicker.RejectOutliers, value =>
|
||
{
|
||
_project.Deflicker.RejectOutliers = value;
|
||
DeflickerChanged?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(Check("Proteggi le alte luci", _project.Deflicker.ProtectHighlights, value =>
|
||
{
|
||
_project.Deflicker.ProtectHighlights = value;
|
||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(Slider("Innesco della compressione", 0.4, 0.98, _project.Deflicker.HighlightKnee, 0.02, "0.00",
|
||
string.Empty,
|
||
value => { _project.Deflicker.HighlightKnee = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(new SectionHeader("Deflicker per regioni"));
|
||
|
||
stack.Add(Combo("Divisione del fotogramma",
|
||
["Nessuna — una sola curva", "Cielo e paesaggio (linea d'orizzonte)", "Per luminanza"],
|
||
_project.Regions.Mode switch { RegionMode.SkyGround => 1, RegionMode.Luminance => 2, _ => 0 },
|
||
index =>
|
||
{
|
||
_project.Regions.Mode = index switch
|
||
{
|
||
1 => RegionMode.SkyGround,
|
||
2 => RegionMode.Luminance,
|
||
_ => RegionMode.Off,
|
||
};
|
||
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(Slider("Indipendenza delle regioni", 0, 1, _project.Regions.Independence, 0.05, "0.00", string.Empty,
|
||
value => { _project.Regions.Independence = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Slider("Sfumatura del confine", 0.01, 0.20, _project.Regions.Feather, 0.01, "0.00", string.Empty,
|
||
value => { _project.Regions.Feather = value; AnalysisInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Slider("Fotogrammi campionati", 3, 48, _project.Regions.SampleFrames, 1, "0", string.Empty,
|
||
value => { _project.Regions.SampleFrames = (int)value; AnalysisInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Note("La maschera nasce dalla mediana temporale di un campione di fotogrammi, che " +
|
||
"toglie di mezzo nuvole e passanti. Impedisce che il transito di una nuvola sul " +
|
||
"cielo faccia schiarire anche il paesaggio, che invece non è cambiato."));
|
||
|
||
stack.Add(new SectionHeader("Transizioni giorno-notte"));
|
||
|
||
stack.Add(Check("Ammorbidisci i cambi di impostazione", _project.HolyGrail.Enabled, value =>
|
||
{
|
||
_project.HolyGrail.Enabled = value;
|
||
DeflickerChanged?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(AutoSlider(AutoKey.TransitionFrames, "Lunghezza della transizione", 4, 240,
|
||
_project.HolyGrail.TransitionFrames, 2, "0", "fotogrammi",
|
||
value => { _project.HolyGrail.TransitionFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Slider("Soglia di riconoscimento", 0.05, 1.0, _project.HolyGrail.StepThresholdStops, 0.05, "0.00", "EV",
|
||
value => { _project.HolyGrail.StepThresholdStops = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Check("Ricava i salti dai metadati", _project.HolyGrail.UseMetadata, value =>
|
||
{
|
||
_project.HolyGrail.UseMetadata = value;
|
||
DeflickerChanged?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(Check("Liscia il bilanciamento del bianco", _project.HolyGrail.SmoothColor, value =>
|
||
{
|
||
_project.HolyGrail.SmoothColor = value;
|
||
DeflickerChanged?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(Slider("Finestra della lisciatura cromatica", 5, 201, _project.HolyGrail.ColorWindowFrames, 2, "0",
|
||
"fotogrammi",
|
||
value => { _project.HolyGrail.ColorWindowFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Slider("Intensità cromatica", 0, 1, _project.HolyGrail.ColorStrength, 0.05, "0.00", string.Empty,
|
||
value => { _project.HolyGrail.ColorStrength = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Note("Il valore di esposizione si legge nei metadati, quindi l'ampiezza di ogni salto " +
|
||
"è nota senza incertezza. Un salto che la luminanza non ha recepito — fotogramma " +
|
||
"già saturo — viene lasciato stare."));
|
||
|
||
return stack.Panel;
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Movimento
|
||
|
||
private Panel BuildMotionPage()
|
||
{
|
||
var stack = NewStack();
|
||
|
||
stack.Add(new SectionHeader("Stabilizzazione sub-pixel"));
|
||
|
||
stack.Add(Check("Compensa i micro-urti", _project.Stabilization.Enabled, value =>
|
||
{
|
||
_project.Stabilization.Enabled = value;
|
||
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(AutoSlider(AutoKey.StabilizationWindow, "Finestra del percorso", 5, 151,
|
||
_project.Stabilization.SmoothingFrames, 2, "0", "fotogrammi",
|
||
value => { _project.Stabilization.SmoothingFrames = (int)value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Slider("Intensità", 0, 1, _project.Stabilization.Strength, 0.05, "0.00", string.Empty,
|
||
value => { _project.Stabilization.Strength = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Slider("Correzione massima", 0.005, 0.20, _project.Stabilization.MaxCorrectionFraction, 0.005,
|
||
"0.000", "×L",
|
||
value => { _project.Stabilization.MaxCorrectionFraction = value; DeflickerChanged?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Check("Compensa anche la rotazione", _project.Stabilization.CompensateRotation, value =>
|
||
{
|
||
_project.Stabilization.CompensateRotation = value;
|
||
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(Slider("Lato dei riquadri di correlazione", 64, 512, _project.Stabilization.PatchSize, 64, "0", "px",
|
||
value =>
|
||
{
|
||
_project.Stabilization.PatchSize = Fourier.FloorPowerOfTwo((int)value);
|
||
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(Slider("Riquadri per lato", 1, 5, _project.Stabilization.Grid, 1, "0", string.Empty,
|
||
value => { _project.Stabilization.Grid = (int)value; AnalysisInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(new SectionHeader("Movimento di macchina virtuale"));
|
||
|
||
stack.Add(Check("Panoramiche e zoom virtuali", _project.Camera.Enabled, value =>
|
||
{
|
||
_project.Camera.Enabled = value;
|
||
AnalysisInvalidated?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
_cameraEditor = new CameraEditor { Settings = _project.Camera, Height = 200 };
|
||
_cameraEditor.Changed += (_, _) =>
|
||
{
|
||
SyncKeyframeControls();
|
||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||
};
|
||
_cameraEditor.SelectionChanged += (_, _) => SyncKeyframeControls();
|
||
stack.Add(_cameraEditor);
|
||
|
||
var keyframeButtons = new Panel { Height = 32, BackColor = Theme.Surface };
|
||
var addButton = new DarkButton { Text = "Aggiungi nodo", Width = 128, Height = 28 };
|
||
var removeButton = new DarkButton { Text = "Togli nodo", Width = 116, Height = 28, Left = 134 };
|
||
addButton.Click += (_, _) => { _cameraEditor.AddKeyframe(); PreviewInvalidated?.Invoke(this, EventArgs.Empty); };
|
||
removeButton.Click += (_, _) => { _cameraEditor.RemoveSelected(); PreviewInvalidated?.Invoke(this, EventArgs.Empty); };
|
||
keyframeButtons.Controls.Add(addButton);
|
||
keyframeButtons.Controls.Add(removeButton);
|
||
stack.Add(keyframeButtons);
|
||
|
||
_keyframeCentreX = Slider("Nodo — centro orizzontale", 0, 1, 0.5, 0.005, "0.000", string.Empty,
|
||
value => UpdateSelectedKeyframe(k => k.CentreX = value));
|
||
_keyframeCentreY = Slider("Nodo — centro verticale", 0, 1, 0.5, 0.005, "0.000", string.Empty,
|
||
value => UpdateSelectedKeyframe(k => k.CentreY = value));
|
||
_keyframeZoom = Slider("Nodo — ingrandimento", 1, 8, 1, 0.05, "0.00", "×",
|
||
value => UpdateSelectedKeyframe(k => k.Zoom = value));
|
||
_keyframeEaseOut = Slider("Nodo — indugio in partenza", 0, 1, 0.42, 0.02, "0.00", string.Empty,
|
||
value => UpdateSelectedKeyframe(k => k.EaseOut = value));
|
||
_keyframeEaseIn = Slider("Nodo — frenata in arrivo", 0, 1, 0.42, 0.02, "0.00", string.Empty,
|
||
value => UpdateSelectedKeyframe(k => k.EaseIn = value));
|
||
|
||
stack.Add(_keyframeCentreX);
|
||
stack.Add(_keyframeCentreY);
|
||
stack.Add(_keyframeZoom);
|
||
stack.Add(_keyframeEaseOut);
|
||
stack.Add(_keyframeEaseIn);
|
||
|
||
stack.Add(Check("Tieni l'inquadratura dentro il fotogramma", _project.Camera.KeepInsideFrame, value =>
|
||
{
|
||
_project.Camera.KeepInsideFrame = value;
|
||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(new SectionHeader("Motion blur sintetico"));
|
||
|
||
stack.Add(Check("Sfocatura di movimento attiva", _project.MotionBlur.Enabled, value =>
|
||
{
|
||
_project.MotionBlur.Enabled = value;
|
||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(Slider("Shutter angle obiettivo", 0, 360, _project.MotionBlur.TargetShutterAngle, 5, "0", "°",
|
||
value => { _project.MotionBlur.TargetShutterAngle = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Slider("Intensità", 0, 1, _project.MotionBlur.Strength, 0.05, "0.00", string.Empty,
|
||
value => { _project.MotionBlur.Strength = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Slider("Lunghezza massima della scia", 4, 160, _project.MotionBlur.MaxBlurPixels, 2, "0", "px",
|
||
value => { _project.MotionBlur.MaxBlurPixels = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Slider("Campioni per pixel", 3, 65, _project.MotionBlur.MaxSamples, 2, "0", string.Empty,
|
||
value => { _project.MotionBlur.MaxSamples = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(new SectionHeader("Campo vettoriale di movimento"));
|
||
|
||
stack.Add(Slider("Larghezza di analisi del movimento", 320, 1920, _project.Flow.AnalysisWidth, 32, "0", "px",
|
||
value => { _project.Flow.AnalysisWidth = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Slider("Passo della griglia", 4, 32, _project.Flow.CellSize, 1, "0", "px",
|
||
value => { _project.Flow.CellSize = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Slider("Livelli della piramide", 1, 6, _project.Flow.PyramidLevels, 1, "0", string.Empty,
|
||
value => { _project.Flow.PyramidLevels = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Slider("Raggio della finestra", 2, 12, _project.Flow.WindowRadius, 1, "0", "px",
|
||
value => { _project.Flow.WindowRadius = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Slider("Iterazioni per livello", 1, 12, _project.Flow.Iterations, 1, "0", string.Empty,
|
||
value => { _project.Flow.Iterations = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
SyncKeyframeControls();
|
||
return stack.Panel;
|
||
}
|
||
|
||
private void UpdateSelectedKeyframe(Action<CameraKeyframe> change)
|
||
{
|
||
if (_syncingKeyframe || _cameraEditor?.Selected is not { } keyframe) return;
|
||
change(keyframe);
|
||
_cameraEditor.Invalidate();
|
||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||
}
|
||
|
||
private void SyncKeyframeControls()
|
||
{
|
||
if (_cameraEditor?.Selected is not { } keyframe) return;
|
||
|
||
_syncingKeyframe = true;
|
||
_keyframeCentreX?.SetValueSilently(keyframe.CentreX);
|
||
_keyframeCentreY?.SetValueSilently(keyframe.CentreY);
|
||
_keyframeZoom?.SetValueSilently(keyframe.Zoom);
|
||
_keyframeEaseOut?.SetValueSilently(keyframe.EaseOut);
|
||
_keyframeEaseIn?.SetValueSilently(keyframe.EaseIn);
|
||
_syncingKeyframe = false;
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Tempo
|
||
|
||
private Panel BuildTimingPage()
|
||
{
|
||
var stack = NewStack();
|
||
|
||
stack.Add(new SectionHeader("Andamento temporale"));
|
||
stack.Add(Combo("Durata dei fotogrammi",
|
||
["Costante — un fotogramma per scatto",
|
||
"Adattiva — durata proporzionale all'intervallo",
|
||
"Interpolata — cadenza uniformata con fotogrammi sintetici"],
|
||
(int)_project.Export.Timing,
|
||
index =>
|
||
{
|
||
_project.Export.Timing = (FrameTimingMode)index;
|
||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(Slider("Dilatazione massima", 1.5, 8, _project.Export.MaxAdaptiveStretch, 0.5, "0.0", "×",
|
||
value => _project.Export.MaxAdaptiveStretch = value));
|
||
|
||
stack.Add(new SectionHeader("Rimappatura non lineare"));
|
||
|
||
stack.Add(Check("Curva di velocità attiva", _project.TimeRamp.Enabled, value =>
|
||
{
|
||
_project.TimeRamp.Enabled = value;
|
||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
_rampEditor = new SplineEditor
|
||
{
|
||
Minimum = 0.1,
|
||
Maximum = 8.0,
|
||
Height = 160,
|
||
StartLabel = "primo scatto",
|
||
EndLabel = "ultimo scatto",
|
||
};
|
||
_rampEditor.SetKnots(_project.TimeRamp.Speed);
|
||
_rampEditor.Changed += (_, _) =>
|
||
{
|
||
_project.TimeRamp.Speed = [.. _rampEditor.Knots];
|
||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||
};
|
||
stack.Add(_rampEditor);
|
||
|
||
stack.Add(Note("Trascina i nodi, aggiungine uno con un doppio clic, toglilo con il tasto destro. " +
|
||
"La curva dice quanti scatti vengono consumati per ogni fotogramma d'uscita: sopra 1 " +
|
||
"la sequenza accelera, sotto 1 rallenta e i fotogrammi mancanti vengono sintetizzati."));
|
||
|
||
stack.Add(new SectionHeader("Accumulo temporale"));
|
||
|
||
stack.Add(Combo("Modalità",
|
||
["Nessuna", "Mediana — rimuove gli elementi di passaggio", "Massimo — scie stellari"],
|
||
_project.Stacking.Mode switch { StackingMode.Median => 1, StackingMode.Maximum => 2, _ => 0 },
|
||
index =>
|
||
{
|
||
_project.Stacking.Mode = index switch
|
||
{
|
||
1 => StackingMode.Median,
|
||
2 => StackingMode.Maximum,
|
||
_ => StackingMode.Off,
|
||
};
|
||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(Slider("Finestra della mediana", 3, 49, _project.Stacking.WindowFrames, 2, "0", "fotogrammi",
|
||
value => { _project.Stacking.WindowFrames = (int)value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Slider("Intensità dell'accumulo", 0, 1, _project.Stacking.Strength, 0.05, "0.00", string.Empty,
|
||
value => { _project.Stacking.Strength = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Slider("Lunghezza delle scie", 0, 400, _project.Stacking.TrailFrames, 5, "0", "fotogrammi",
|
||
value => { _project.Stacking.TrailFrames = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Note("La mediana tiene il valore centrale della serie temporale di ogni pixel: la scena " +
|
||
"stabile resta se stessa, chi attraversa l'inquadratura una volta sola sparisce. " +
|
||
"Il massimo trasforma le stelle in archi; a zero le scie non si spengono mai."));
|
||
|
||
return stack.Panel;
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Esportazione
|
||
|
||
private Panel BuildExportPage()
|
||
{
|
||
var stack = NewStack();
|
||
|
||
stack.Add(new SectionHeader("Formato"));
|
||
stack.Add(Combo("Codec", ["H.264 / AVC", "H.265 / HEVC"], _project.Export.Codec == VideoCodec.H264 ? 0 : 1,
|
||
index =>
|
||
{
|
||
_project.Export.Codec = index == 0 ? VideoCodec.H264 : VideoCodec.Hevc;
|
||
PreviewInvalidated?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(Combo("Profilo H.264", ["Baseline", "Main", "High"],
|
||
_project.Export.Profile switch { H264Profile.Baseline => 0, H264Profile.Main => 1, _ => 2 },
|
||
index => _project.Export.Profile = index switch
|
||
{
|
||
0 => H264Profile.Baseline,
|
||
1 => H264Profile.Main,
|
||
_ => H264Profile.High,
|
||
}));
|
||
|
||
stack.Add(Slider("Frame rate", 6, 120, _project.Export.FrameRate, 1, "0", "fps",
|
||
value => { _project.Export.FrameRate = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Slider("Bitrate medio", 5, 250, _project.Export.BitrateMbps, 5, "0", "Mb/s",
|
||
value => { _project.Export.BitrateMbps = value; PreviewInvalidated?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Slider("Intervallo fra fotogrammi chiave", 1, 10, _project.Export.KeyframeIntervalSeconds, 1, "0", "s",
|
||
value => _project.Export.KeyframeIntervalSeconds = (int)value));
|
||
|
||
stack.Add(new SectionHeader("Codifica"));
|
||
stack.Add(Check("Preferisci l'encoder hardware", _project.Export.PreferHardware,
|
||
value => _project.Export.PreferHardware = value));
|
||
|
||
stack.Add(new SectionHeader("Destinazione"));
|
||
stack.Add(OutputPathBox);
|
||
|
||
var browse = new DarkButton { Text = "Scegli il file di destinazione…", Height = 32, Dock = DockStyle.Top };
|
||
browse.Click += (_, _) => BrowseOutputRequested?.Invoke(this, EventArgs.Empty);
|
||
stack.Add(browse);
|
||
|
||
stack.Add(Note("Il video viene scritto in un unico flusso continuo. L'unico altro file che " +
|
||
"l'elaborazione può creare è il parcheggio temporaneo, che si cancella da sé."));
|
||
|
||
return stack.Panel;
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Impostazioni dell'applicazione
|
||
|
||
private Panel BuildPreferencesPage()
|
||
{
|
||
var stack = NewStack();
|
||
|
||
stack.Add(new SectionHeader("All'apertura"));
|
||
|
||
stack.Add(Check("Ricorda le impostazioni per cartella", _app.RememberPerFolder, value =>
|
||
{
|
||
_app.RememberPerFolder = value;
|
||
AppSettingsChanged?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(Check("Analizza appena una sequenza è caricata", _app.AnalyzeOnLoad, value =>
|
||
{
|
||
_app.AnalyzeOnLoad = value;
|
||
AppSettingsChanged?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(new SectionHeader("Valori predefiniti dei nuovi progetti"));
|
||
|
||
stack.Add(Combo("Profilo di qualità", ["Bozza", "Standard", "Massima"], (int)_app.DefaultQuality,
|
||
index =>
|
||
{
|
||
_app.DefaultQuality = (QualityProfile)Math.Clamp(index, 0, 2);
|
||
AppSettingsChanged?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(Slider("Decodifiche simultanee", 1, 16, _app.DefaultDecodeParallelism, 1, "0", "thread",
|
||
value => { _app.DefaultDecodeParallelism = (int)value; AppSettingsChanged?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Slider("Tetto di memoria", 256, 32768, _app.DefaultMemoryBudgetMiB, 256, "0", "MiB",
|
||
value => { _app.DefaultMemoryBudgetMiB = (int)value; AppSettingsChanged?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Check("Parcheggia su disco i fotogrammi in eccesso", _app.DefaultAllowDiskSpill, value =>
|
||
{
|
||
_app.DefaultAllowDiskSpill = value;
|
||
AppSettingsChanged?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(new SectionHeader("Durante l'esportazione"));
|
||
|
||
stack.Add(Check("Chiedi conferma prima di sovrascrivere", _app.ConfirmOverwrite, value =>
|
||
{
|
||
_app.ConfirmOverwrite = value;
|
||
AppSettingsChanged?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(Check("Mostra i fotogrammi mentre vengono codificati", _app.LivePreviewDuringExport, value =>
|
||
{
|
||
_app.LivePreviewDuringExport = value;
|
||
AppSettingsChanged?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(Slider("Aggiorna l'anteprima ogni", 1, 60, _app.LivePreviewEvery, 1, "0", "fotogrammi",
|
||
value => { _app.LivePreviewEvery = (int)value; AppSettingsChanged?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
stack.Add(Check("Apri la cartella a esportazione conclusa", _app.RevealWhenFinished, value =>
|
||
{
|
||
_app.RevealWhenFinished = value;
|
||
AppSettingsChanged?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(new SectionHeader("Riquadro sponsor"));
|
||
|
||
stack.Add(Check("Mostra gli sponsor durante l'attesa", _app.ShowSponsors, value =>
|
||
{
|
||
_app.ShowSponsors = value;
|
||
AppSettingsChanged?.Invoke(this, EventArgs.Empty);
|
||
}));
|
||
|
||
stack.Add(Slider("Cambia annuncio ogni", 5, 180, _app.SponsorRotationSeconds, 5, "0", "s",
|
||
value => { _app.SponsorRotationSeconds = (int)value; AppSettingsChanged?.Invoke(this, EventArgs.Empty); }));
|
||
|
||
var openFolder = new DarkButton { Text = "Apri la cartella delle campagne", Height = 30, Dock = DockStyle.Top };
|
||
openFolder.Click += (_, _) =>
|
||
{
|
||
try
|
||
{
|
||
SponsorCatalogue.EnsureExample(_app.ResolvedSponsorFolder);
|
||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(_app.ResolvedSponsorFolder)
|
||
{
|
||
UseShellExecute = true,
|
||
});
|
||
}
|
||
catch (Exception ex) when (ex is IOException or System.ComponentModel.Win32Exception)
|
||
{
|
||
// Cartella non apribile: non c'è nulla di sensato da fare.
|
||
}
|
||
};
|
||
stack.Add(openFolder);
|
||
|
||
stack.Add(Note("Gli annunci si leggono da una cartella locale: il programma non contatta alcun " +
|
||
"servizio, non invia identificativi e non registra clic. Le campagne si " +
|
||
"aggiornano copiando file dentro quella cartella, con un elenco chiamato " +
|
||
"campagne.txt. Il riquadro compare soltanto nella scheda Esportazione, dove per " +
|
||
"forza di cose si aspetta."));
|
||
|
||
stack.Add(new SectionHeader("Dove stanno i file"));
|
||
stack.Add(Note($"Impostazioni: {AppSettings.SettingsPath}"));
|
||
stack.Add(Note($"Campagne: {_app.ResolvedSponsorFolder}"));
|
||
|
||
return stack.Panel;
|
||
}
|
||
|
||
// ------------------------------------------------------------------ costruttori di controlli
|
||
|
||
private sealed class Stack(Panel panel)
|
||
{
|
||
public Panel Panel { get; } = panel;
|
||
private int _y;
|
||
|
||
public void Add(Control control)
|
||
{
|
||
control.Dock = DockStyle.None;
|
||
control.Left = 14;
|
||
control.Top = _y;
|
||
control.Width = Panel.ClientSize.Width - 34;
|
||
control.Anchor = AnchorStyles.Left | AnchorStyles.Top | AnchorStyles.Right;
|
||
|
||
if (control is Label note && !note.AutoSize) MeasureNote(note);
|
||
|
||
Panel.Controls.Add(control);
|
||
_y += control.Height + 6;
|
||
}
|
||
}
|
||
|
||
private static void MeasureNote(Label note)
|
||
{
|
||
var measured = TextRenderer.MeasureText(note.Text, note.Font,
|
||
new Size(Math.Max(40, note.Width), 0),
|
||
TextFormatFlags.WordBreak);
|
||
note.Height = measured.Height + 8;
|
||
}
|
||
|
||
private static void LayoutNote(Label note)
|
||
{
|
||
int before = note.Height;
|
||
MeasureNote(note);
|
||
if (note.Height == before || note.Parent is null) return;
|
||
|
||
int delta = note.Height - before;
|
||
foreach (Control sibling in note.Parent.Controls)
|
||
{
|
||
if (sibling != note && sibling.Top > note.Top) sibling.Top += delta;
|
||
}
|
||
}
|
||
|
||
private static Stack NewStack()
|
||
{
|
||
var panel = new Panel { BackColor = Theme.Surface, Padding = new Padding(0, 8, 0, 12), Width = 372 };
|
||
var host = new Panel { BackColor = Theme.Surface, Dock = DockStyle.Fill, AutoScroll = true, Width = 372 };
|
||
panel.Controls.Add(host);
|
||
return new Stack(host);
|
||
}
|
||
|
||
private static ParameterSlider Slider(string caption, double min, double max, double value, double step,
|
||
string format, string unit, Action<double> onChange)
|
||
{
|
||
var slider = new ParameterSlider
|
||
{
|
||
Caption = caption,
|
||
Minimum = min,
|
||
Maximum = max,
|
||
Step = step,
|
||
ValueFormat = format,
|
||
Unit = unit,
|
||
};
|
||
slider.SetValueSilently(value);
|
||
slider.ValueChanged += (_, _) => onChange(slider.Value);
|
||
return slider;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Cursore che il direttore sa impostare da sé. L'indicatore «auto» dice chi comanda;
|
||
/// toccare il cursore passa il comando all'utente, e il pulsante lo restituisce.
|
||
/// </summary>
|
||
private ParameterSlider AutoSlider(AutoKey key, string caption, double min, double max, double value,
|
||
double step, string format, string unit, Action<double> onChange)
|
||
{
|
||
var slider = Slider(caption, min, max, value, step, format, unit, onChange);
|
||
slider.AutoSupported = true;
|
||
slider.IsAuto = !_project.ManualParameters.Contains(key);
|
||
|
||
slider.AutoChanged += (_, _) =>
|
||
{
|
||
if (slider.IsAuto)
|
||
{
|
||
_project.ManualParameters.Remove(key);
|
||
if (_project.AutoDecisions.TryGetValue(key, out var decision))
|
||
{
|
||
slider.SetValueSilently(decision.Value);
|
||
onChange(decision.Value);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
_project.ManualParameters.Add(key);
|
||
}
|
||
};
|
||
|
||
_autoSliders[key] = slider;
|
||
return slider;
|
||
}
|
||
|
||
private static DarkCheckBox Check(string caption, bool value, Action<bool> onChange)
|
||
{
|
||
var box = new DarkCheckBox { Text = caption, Checked = value };
|
||
box.CheckedChanged += (_, _) => onChange(box.Checked);
|
||
return box;
|
||
}
|
||
|
||
private static LabeledCombo Combo(string caption, string[] items, int selected, Action<int> onChange)
|
||
{
|
||
var row = new LabeledCombo(caption);
|
||
row.Combo.Items.AddRange(items);
|
||
row.Combo.SelectedIndex = Math.Clamp(selected, 0, items.Length - 1);
|
||
row.Combo.SelectedIndexChanged += (_, _) => onChange(row.Combo.SelectedIndex);
|
||
return row;
|
||
}
|
||
|
||
private static Label Note(string text) => new()
|
||
{
|
||
Text = text,
|
||
Font = Theme.Small,
|
||
ForeColor = Theme.TextFaint,
|
||
AutoSize = false,
|
||
Height = 52,
|
||
BackColor = Theme.Surface,
|
||
};
|
||
|
||
private static int WorkingWidthToIndex(int width) => width switch
|
||
{
|
||
3840 => 1,
|
||
2560 => 2,
|
||
1920 => 3,
|
||
1280 => 4,
|
||
_ => 0,
|
||
};
|
||
}
|