Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions src/XTerm.NET.Tests/Options/TerminalOptionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -668,3 +668,166 @@ public void Properties_CanBeSet()
Assert.Equal(13, keyEvent.KeyCode);
}
}

/// <summary>
/// Options that a host changes while the terminal is running, rather than at construction.
/// A settable property that quietly does nothing is worse than one that is not there.
/// </summary>
public class LiveOptionsTests
{
private static Terminal WithHistory(int rows, int scrollback, int linesWritten)
{
var terminal = new Terminal(new TerminalOptions { Cols = 20, Rows = rows, Scrollback = scrollback });
for (var i = 0; i < linesWritten; i++)
terminal.WriteLine($"line{i}");
return terminal;
}
private static bool Holds(Terminal t, string text)
{
for (var y = 0; y < t.Buffer.Lines.Length; y++)
if (t.Buffer.Lines[y]?.TranslateToString(true).Trim() == text)
return true;
return false;
}
[Fact]
public void Lowering_the_scrollback_after_construction_shrinks_the_history()
{
// Scrollback was read once, when the buffer was built, and never again -- so a host
// reclaiming memory set a property that reported the new value and changed nothing.
var terminal = WithHistory(rows: 4, scrollback: 50, linesWritten: 40);
Assert.Equal(54, terminal.Buffer.Lines.MaxLength);
terminal.Options.Scrollback = 5;
Assert.Equal(9, terminal.Buffer.Lines.MaxLength);
}
[Fact]
public void Shrinking_the_scrollback_drops_the_oldest_lines_and_keeps_the_screen()
{
// CircularList.Resize keeps the FRONT of the list, which for a scrollback is backwards:
// it would discard the screen the user is looking at and keep the history nobody asked to
// keep. The oldest go.
var terminal = WithHistory(rows: 4, scrollback: 50, linesWritten: 40);
Assert.True(Holds(terminal, "line0"), "the oldest line should still be here before shrinking");
terminal.Options.Scrollback = 5;
Assert.False(Holds(terminal, "line0"), "the oldest line should have been dropped");
Assert.True(Holds(terminal, "line39"), "the newest line must survive -- it is on screen");
}
[Fact]
public void Shrinking_the_scrollback_leaves_the_viewport_on_the_live_bottom()
{
// The viewport is recomputed against what is left rather than shifted by the trim amount,
// or it ends up a fixed distance from rows that no longer exist and everything written
// afterwards lands outside the visible area.
var terminal = WithHistory(rows: 4, scrollback: 50, linesWritten: 40);
terminal.Options.Scrollback = 5;
Assert.Equal(terminal.Buffer.YBase, terminal.Buffer.YDisp);
terminal.WriteLine("after");
Assert.True(Holds(terminal, "after"));
}
[Fact]
public void Raising_the_scrollback_after_construction_grows_the_history()
{
var terminal = WithHistory(rows: 4, scrollback: 5, linesWritten: 20);
Assert.Equal(9, terminal.Buffer.Lines.MaxLength);
terminal.Options.Scrollback = 100;
Assert.Equal(104, terminal.Buffer.Lines.MaxLength);
Assert.True(Holds(terminal, "line19"), "growing must not disturb what is already held");
}
[Fact]
public void The_alternate_screen_keeps_no_history_whatever_the_scrollback_says()
{
// The alternate buffer is constructed with none by definition, and a later write to the
// option must not give it any -- a full-screen program's scrollback is the shell's.
var terminal = WithHistory(rows: 4, scrollback: 50, linesWritten: 20);
terminal.Write($"{((char)0x1B)}[?1049h");
var altCapacity = terminal.Buffer.Lines.MaxLength;
terminal.Options.Scrollback = 500;
Assert.Equal(altCapacity, terminal.Buffer.Lines.MaxLength);
}
[Fact]
public void Setting_the_scrollback_to_what_it_already_is_changes_nothing()
{
var terminal = WithHistory(rows: 4, scrollback: 50, linesWritten: 40);
var before = terminal.Buffer.Lines.MaxLength;
terminal.Options.Scrollback = 50;
Assert.Equal(before, terminal.Buffer.Lines.MaxLength);
Assert.True(Holds(terminal, "line0"), "a no-op write must not trim anything");
}
[Fact]
public void Assigning_a_theme_after_construction_reseeds_the_palette()
{
// ColorPalette.ApplyTheme documents itself as the runtime path for an embedder following
// the OS light/dark setting -- but the option that names the theme was read once, to build
// the palette, and never again. An embedder assigning a new theme watched nothing happen.
var terminal = new Terminal(new TerminalOptions
{
Cols = 20,
Rows = 4,
Theme = new ThemeOptions { Background = "#000000", Foreground = "#ffffff" },
});

terminal.Options.Theme = new ThemeOptions { Background = "#ffffff", Foreground = "#000000" };

Assert.Equal(0xFFFFFF, terminal.Colors.Background);
Assert.Equal(0x000000, terminal.Colors.Foreground);
}

[Fact]
public void A_new_theme_reseeds_colours_an_application_had_changed()
{
// Half in the old theme and half in the new one is not a theme, so OSC 10/11 changes are
// re-seeded away rather than preserved across a theme switch.
var terminal = new Terminal(new TerminalOptions
{
Cols = 20,
Rows = 4,
Theme = new ThemeOptions { Background = "#000000" },
});
terminal.Write($"{((char)0x1B)}]11;#123456{((char)0x1B)}\\"); // OSC 11: application sets it

terminal.Options.Theme = new ThemeOptions { Background = "#ffffff" };

Assert.Equal(0xFFFFFF, terminal.Colors.Background);
}

[Fact]
public void Changing_the_tab_stop_width_lays_the_stops_out_again()
{
// ResetTabStops read this, but only ran at construction, on a resize and on RIS -- so the
// change looked ignored, and then took effect later when something unrelated resized the
// window.
var terminal = new Terminal(new TerminalOptions { Cols = 40, Rows = 4, TabStopWidth = 8 });
terminal.Write("\t");
Assert.Equal(8, terminal.Buffer.X);

terminal.Options.TabStopWidth = 4;

terminal.Write($"{((char)0x1B)}[1;1H\t");
Assert.Equal(4, terminal.Buffer.X);
}

[Fact]
public void A_resize_keeps_the_options_size_in_step_with_the_terminal()
{
// Options.Cols went on reporting the number the terminal was BUILT with while Terminal.Cols
// reported the number it is. Two public properties of the same name, disagreeing.
var terminal = new Terminal(new TerminalOptions { Cols = 80, Rows = 24 });

terminal.Resize(120, 40);

Assert.Equal(120, terminal.Options.Cols);
Assert.Equal(40, terminal.Options.Rows);
Assert.Equal(terminal.Cols, terminal.Options.Cols);
Assert.Equal(terminal.Rows, terminal.Options.Rows);
}

[Fact]
public void The_options_object_a_caller_kept_does_not_reach_the_terminal()
{
// The snapshot contract from #101, restated here because the live hook is installed on the
// terminal's own copy: making Scrollback live must not quietly re-alias the two.
var mine = new TerminalOptions { Cols = 20, Rows = 4, Scrollback = 50 };
var terminal = new Terminal(mine);
mine.Scrollback = 5;
Assert.Equal(54, terminal.Buffer.Lines.MaxLength);
}
}
49 changes: 49 additions & 0 deletions src/XTerm.NET/Buffer/TerminalBuffer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,55 @@ public void RefreshMultiRowSizedRuns()
HasMultiRowSizedRuns = false;
}

/// <summary>
/// Sets how many lines of history this buffer keeps behind the screen.
/// </summary>
/// <remarks>
/// The ring holds the screen and the history together, so its capacity is rows + scrollback and
/// the scrollback is whatever the rows do not account for — the same arithmetic
/// <see cref="Resize"/> uses to carry the history across a change of row count.
///
/// Shrinking drops the OLDEST lines. <see cref="CircularList{T}.Resize"/> keeps the front of
/// the list, which for a scrollback is precisely backwards: it would discard the screen the
/// user is looking at and keep the history nobody asked to keep. So the front is trimmed first
/// and the viewport recomputed against what is left, exactly as the resize path does.
///
/// A buffer with no scrollback of its own — the alternate screen — ignores this.
/// </remarks>
public void SetScrollback(int scrollback)
{
if (!_hasScrollback)
return;

var newMaxLength = Math.Max(_rows, _rows + Math.Max(0, scrollback));
if (newMaxLength == _lines.MaxLength)
return;

if (newMaxLength > _lines.MaxLength)
{
_lines.Resize(newMaxLength);
return;
}

var amountToTrim = _lines.Length - newMaxLength;
if (amountToTrim > 0)
{
// Read BEFORE the trim, because afterwards there is nothing left to tell it from.
var wasFollowingBottom = _yDisp == _yBase;

_lines.TrimStart(amountToTrim);
Trimmed?.Invoke(amountToTrim);

_yBase = Math.Max(0, _lines.Length - _rows);
_yDisp = wasFollowingBottom
? _yBase
: Math.Clamp(_yDisp - amountToTrim, 0, _yBase);
SavedCursorState.Y = Math.Max(SavedCursorState.Y - amountToTrim, 0);
}

_lines.Resize(newMaxLength);
}

/// <summary>
/// Resizes the buffer.
/// </summary>
Expand Down
111 changes: 107 additions & 4 deletions src/XTerm.NET/Options/TerminalOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,93 @@ public class TerminalOptions : ICloneable
/// <summary>
/// Number of columns in the terminal.
/// </summary>
/// <remarks>
/// The starting size, kept in step afterwards: <see cref="Terminal.Resize"/> writes the new
/// size back here, so this and <see cref="Terminal.Cols"/> do not drift apart.
///
/// Writing it does NOT resize a running terminal — call <see cref="Terminal.Resize"/>, which
/// changes both dimensions at once. Honouring each property on its own would resize twice,
/// the first time through a width-and-height pair the caller never asked for, reflowing the
/// buffer through a geometry that existed only between two statements.
/// </remarks>
public int Cols { get; set; } = 80;

/// <summary>
/// Number of rows in the terminal.
/// Number of rows in the terminal. See <see cref="Cols"/>: the starting size, kept in step by
/// <see cref="Terminal.Resize"/>, which is also the way to change it.
/// </summary>
public int Rows { get; set; } = 24;

/// <summary>
/// Amount of scrollback in the terminal. 0 disables scrollback.
/// </summary>
public int Scrollback { get; set; } = 1000;
/// <remarks>
/// Live: setting this resizes the history a terminal already holds, growing it or dropping the
/// oldest lines to fit. It was read once at construction and never again, so a host lowering a
/// scrollback to reclaim memory, or raising it because the user asked, was answered by a
/// property that reported the new value and changed nothing.
///
/// Only the terminal this options object belongs to is affected, which is what makes the hook
/// below safe: since options are snapshotted at construction, exactly one terminal reads any
/// given instance.
/// </remarks>
public int Scrollback
{
get => _scrollback;
set
{
if (value == _scrollback)
return;

_scrollback = value;
ScrollbackChanged?.Invoke(value);
}
}

private int _scrollback = 1000;

/// <summary>
/// Installed by <see cref="Terminal"/> on the snapshot it owns, so a later write to
/// <see cref="Scrollback"/> reaches the buffer. Deliberately not copied by the copy
/// constructor: a clone belongs to whoever cloned it, and carrying the hook would let one
/// terminal resize another's history.
/// </summary>
internal Action<int>? ScrollbackChanged;

/// <summary>
/// Tab stop width.
/// </summary>
public int TabStopWidth { get; set; } = 8;
/// <remarks>
/// Live: setting this lays the stops out again at the new spacing. It used to be read only by
/// the reset that runs at construction, on a resize and on RIS, so a host changing it saw
/// nothing happen and then saw it take effect later, when something unrelated resized the
/// window -- which is worse than either, because the change looked ignored rather than
/// pending.
///
/// This lays the stops out from scratch, so stops an application placed itself with HTS are
/// discarded. That is what changing the width means: the stops are no longer where the
/// application put them.
/// </remarks>
public int TabStopWidth
{
get => _tabStopWidth;
set
{
if (value == _tabStopWidth)
return;

_tabStopWidth = value;
TabStopWidthChanged?.Invoke();
}
}

private int _tabStopWidth = 8;

/// <summary>
/// Installed by <see cref="Terminal"/> on the snapshot it owns. Not copied by the copy
/// constructor, for the reason given on <see cref="ScrollbackChanged"/>.
/// </summary>
internal Action? TabStopWidthChanged;

/// <summary>
/// Whether to enable bell sound/notification.
Expand Down Expand Up @@ -298,7 +369,39 @@ public class TerminalOptions : ICloneable
/// <summary>
/// Theme colors.
/// </summary>
public ThemeOptions Theme { get; set; } = new ThemeOptions();
/// <remarks>
/// Live: assigning a theme re-seeds the palette of the terminal this options object belongs
/// to. It was read once, to build that palette, and never again -- so an embedder following
/// the OS light/dark setting assigned a new theme and watched nothing happen, even though
/// <see cref="ColorPalette.ApplyTheme"/> existed for exactly that purpose and says so.
///
/// Assignment, not mutation. Changing a property ON a theme object already assigned here is
/// not observed, because the object is shared rather than copied; assign a new
/// <see cref="ThemeOptions"/>, or call <see cref="ColorPalette.ApplyTheme"/> directly.
///
/// Colours an application set through OSC 4/10/11/12 are re-seeded away, which is the point:
/// a palette half in the old theme and half in the new one is not a theme.
/// </remarks>
public ThemeOptions Theme
{
get => _theme;
set
{
if (ReferenceEquals(value, _theme))
return;

_theme = value;
ThemeChanged?.Invoke(value);
}
}

private ThemeOptions _theme = new ThemeOptions();

/// <summary>
/// Installed by <see cref="Terminal"/> on the snapshot it owns. Not copied by the copy
/// constructor, for the reason given on <see cref="ScrollbackChanged"/>.
/// </summary>
internal Action<ThemeOptions>? ThemeChanged;

/// <summary>
/// Minimum contrast ratio.
Expand Down
Loading
Loading