Skip to content
Open
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
702 changes: 656 additions & 46 deletions Source/MaterialModOptions.cs

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions Source/MaterialTextInputFocus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ public static bool Pressed(Keys key) => key != Keys.None
&& rawCurrentState.IsKeyDown(key)
&& rawPreviousState.IsKeyUp(key);

public static bool Down(Keys key) => key != Keys.None && rawCurrentState.IsKeyDown(key);

public static bool ControlDown => Down(Keys.LeftControl) || Down(Keys.RightControl);

public static bool ShiftDown => Down(Keys.LeftShift) || Down(Keys.RightShift);

public static Keys[] GetPressedKeys() => rawCurrentState.GetPressedKeys();

private static void KeyboardUpdate(
Expand Down
131 changes: 131 additions & 0 deletions Source/TextEditBuffer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
using System.Text;

namespace Celeste.Mod.MicroblocksQolUtils;

/// <summary>
/// Editable single-line text with a caret and a selection anchor. The value is kept verbatim
/// (spaces stay spaces); width measurement and the visible window live in
/// <see cref="TextEditGeometry"/> so this type can be exercised without the game assemblies.
/// </summary>
internal sealed class TextEditBuffer {
public string Text { get; private set; }
public int Cursor { get; private set; }
public int Anchor { get; private set; }
public int MaxLength { get; }

public TextEditBuffer(string value, int maxLength) {
Text = value ?? "";
MaxLength = Math.Max(1, maxLength);
Cursor = Text.Length;
Anchor = Cursor;
}

public int SelectionStart => Math.Min(Cursor, Anchor);
public int SelectionLength => Math.Abs(Cursor - Anchor);
public bool HasSelection => Cursor != Anchor;
public string SelectedText => Text.Substring(SelectionStart, SelectionLength);

public void SetText(string? value) {
string text = value ?? "";
Text = text.Length > MaxLength ? text[..MaxLength] : text;
Cursor = Text.Length;
Anchor = Cursor;
}

public bool Insert(char character) => InsertText(character.ToString());

/// <summary>
/// Inserts <paramref name="value"/> at the caret, replacing the selection. Clipboard input
/// can carry line breaks and tabs, so control characters are dropped first: this is a
/// single-line field. Returns true when the value actually changed.
/// </summary>
public bool InsertText(string? value) {
value = SingleLine(value);
if (value.Length == 0) return false;
int room = MaxLength - (Text.Length - SelectionLength);
if (room <= 0) return false;
if (value.Length > room) value = value[..room];
int start = SelectionStart;
Text = string.Concat(Text.AsSpan(0, start), value, Text.AsSpan(start + SelectionLength));
Cursor = start + value.Length;
Anchor = Cursor;
return true;
}

/// <summary>Strips characters that a single-line field cannot show (CR/LF/TAB and friends).</summary>
public static string SingleLine(string? value) {
if (string.IsNullOrEmpty(value)) return "";
StringBuilder builder = new(value.Length);
foreach (char character in value) {
if (!char.IsControl(character)) builder.Append(character);
}
return builder.ToString();
}

public bool Backspace() {
if (HasSelection) return DeleteSelection();
if (Cursor <= 0) return false;
Text = Text.Remove(Cursor - 1, 1);
Cursor--;
Anchor = Cursor;
return true;
}

public bool Delete() {
if (HasSelection) return DeleteSelection();
if (Cursor >= Text.Length) return false;
Text = Text.Remove(Cursor, 1);
return true;
}

/// <summary>Removes the selected range; the caret ends up where the selection started.</summary>
public bool DeleteSelection() {
if (!HasSelection) return false;
int start = SelectionStart;
Text = Text.Remove(start, SelectionLength);
Cursor = start;
Anchor = start;
return true;
}

public void SelectAll() {
Anchor = 0;
Cursor = Text.Length;
}

public void CollapseSelection() {
Cursor = SelectionStart;
Anchor = Cursor;
}

public void MoveLeft(bool extend = false) {
if (!extend && HasSelection) {
Cursor = SelectionStart;
Anchor = Cursor;
return;
}
MoveTo(Cursor - 1, extend);
}

public void MoveRight(bool extend = false) {
if (!extend && HasSelection) {
Cursor = SelectionStart + SelectionLength;
Anchor = Cursor;
return;
}
MoveTo(Cursor + 1, extend);
}

public void MoveHome(bool extend = false) => MoveTo(0, extend);

public void MoveEnd(bool extend = false) => MoveTo(Text.Length, extend);

public void SetCursor(int index, bool extend = false) => MoveTo(index, extend);

private void MoveTo(int index, bool extend) {
index = Math.Clamp(index, 0, Text.Length);
if (extend && !HasSelection) Anchor = Cursor;
Cursor = index;
if (!extend) Anchor = index;
}
}
52 changes: 52 additions & 0 deletions Source/TextEditGeometry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using Microsoft.Xna.Framework;

namespace Celeste.Mod.MicroblocksQolUtils;

/// <summary>
/// Width measurement for <see cref="TextEditBuffer"/>: every position comes from the TTF font,
/// so CJK/full-width glyphs count twice and the caret never drifts half a cell.
/// </summary>
internal static class TextEditGeometry {
public static float CaretWidth(TextEditBuffer buffer, float scale, UiFontWeight weight) =>
SystemTtfFont.MeasureVisible(buffer.Text[..buffer.Cursor], scale, weight).X;

/// <summary>
/// Window of the text around the caret, plus the index where the window starts and the
/// caret offset inside it. Used to draw fields that are narrower than the value without
/// the caret drifting off-screen.
/// </summary>
public static TextWindow Window(TextEditBuffer buffer, float maxWidth, float scale,
UiFontWeight weight) {
string text = buffer.Text;
int start = 0;
while (start < buffer.Cursor
&& SystemTtfFont.MeasureVisible(text[start..buffer.Cursor], scale, weight).X
> maxWidth - 10f) {
start++;
}
string shown = start >= text.Length ? "" : text[start..];
if (SystemTtfFont.MeasureVisible(shown, scale, weight).X > maxWidth) {
shown = MaterialTextUtil.Ellipsize(shown, maxWidth, scale, weight);
}
return new TextWindow(shown, start, Math.Clamp(buffer.Cursor - start, 0, shown.Length));
}

/// <summary>Maps a local x offset inside a <see cref="TextWindow"/> to a caret index.</summary>
public static int IndexFromX(TextEditBuffer buffer, float localX, TextWindow window, float scale,
UiFontWeight weight) {
string text = buffer.Text;
int end = window.Start + window.Text.Length;
int best = window.Start;
float bestDistance = float.MaxValue;
for (int index = window.Start; index <= Math.Min(end, text.Length); index++) {
float width = SystemTtfFont.MeasureVisible(text[window.Start..index], scale, weight).X;
float distance = Math.Abs(width - localX);
if (distance >= bestDistance) continue;
bestDistance = distance;
best = index;
}
return Math.Clamp(best, 0, text.Length);
}

public readonly record struct TextWindow(string Text, int Start, int CaretOffset);
}
50 changes: 50 additions & 0 deletions Source/TextEditShortcuts.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using Microsoft.Xna.Framework.Input;

namespace Celeste.Mod.MicroblocksQolUtils;

/// <summary>
/// Native text box shortcuts shared by every custom field: Ctrl+A/C/X/V. Clipboard access goes
/// through Everest's <see cref="TextInput"/> wrapper (SDL), so it works on Windows, Linux and
/// Android. Nothing here touches TextInputEXT's start/stop state, so an IME composition that is
/// in flight keeps running underneath the shortcut.
/// </summary>
internal static class TextEditShortcuts {
/// <summary>Applies a Ctrl shortcut; returns true when one was consumed.</summary>
public static bool Handle(TextEditBuffer buffer) {
if (!MaterialTextInputFocus.ControlDown) return false;
if (MaterialTextInputFocus.Pressed(Keys.A)) {
buffer.SelectAll();
return true;
}
if (MaterialTextInputFocus.Pressed(Keys.C)) return Copy(buffer);
if (MaterialTextInputFocus.Pressed(Keys.X)) return Cut(buffer);
if (MaterialTextInputFocus.Pressed(Keys.V)) return Paste(buffer);
return false;
}

private static bool Copy(TextEditBuffer buffer) {
if (!buffer.HasSelection) return false;
try {
TextInput.SetClipboardText(buffer.SelectedText);
return true;
} catch {
return false;
}
}

private static bool Cut(TextEditBuffer buffer) {
if (!Copy(buffer)) return false;
buffer.DeleteSelection();
return true;
}

private static bool Paste(TextEditBuffer buffer) {
string text;
try {
text = TextInput.GetClipboardText() ?? "";
} catch {
return false;
}
return buffer.InsertText(text);
}
}
Loading