From 5e97b9423d8eee53b2532fbd723e3ee714747cea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C5=A1a=20Bari=C5=A1i=C4=87?= Date: Fri, 31 Jul 2026 16:25:37 +0200 Subject: [PATCH] Add Windows 98 Notepad sample --- FishUI/Controls/MultiLineEditbox.cs | 295 +++++-- FishUIDemos/Samples/SampleWindows98Notepad.cs | 765 ++++++++++++++++++ FishUISample/Program.cs | 2 +- FishUISample/SampleChooser.cs | 24 +- README.md | 1 + 5 files changed, 1019 insertions(+), 68 deletions(-) create mode 100644 FishUIDemos/Samples/SampleWindows98Notepad.cs diff --git a/FishUI/Controls/MultiLineEditbox.cs b/FishUI/Controls/MultiLineEditbox.cs index 791156e..ffac3f3 100644 --- a/FishUI/Controls/MultiLineEditbox.cs +++ b/FishUI/Controls/MultiLineEditbox.cs @@ -16,7 +16,23 @@ namespace FishUI.Controls /// public class MultiLineEditbox : Control { + private readonly struct VisualLine + { + public int LogicalRow { get; } + public int StartColumn { get; } + public int Length { get; } + public int EndColumn => StartColumn + Length; + + public VisualLine(int logicalRow, int startColumn, int length) + { + LogicalRow = logicalRow; + StartColumn = startColumn; + Length = length; + } + } + private List _lines = new List { "" }; + private List _visualLines = new List { new VisualLine(0, 0, 0) }; private float _scrollOffsetPixels = 0f; /// @@ -212,6 +228,122 @@ public float ScrollOffsetPixels [YamlIgnore] private bool _scrollBarDriving = false; + private void EnsureVisualLayout() + { + FishUI ui = FishUI; + if (ui?.Settings?.FontDefault == null) + { + _visualLines = BuildVisualLines(null, float.MaxValue); + return; + } + + _cachedFont = ui.Settings.FontDefault; + _lineHeight = ui.Graphics.MeasureText(_cachedFont, "Mg").Y; + if (_lineHeight <= 0) + _lineHeight = 16f; + + Vector2 size = ScaledSize; + float leftOffset = ShowLineNumbers ? Scale(LineNumberWidth) : 0; + float availableWidth = Math.Max(1f, size.X - leftOffset - Scale(TextPadding) * 2); + _visualLines = BuildVisualLines(ui, availableWidth); + + float viewHeight = GetTextAreaHeight(); + if (WordWrap && ShowScrollBar && _visualLines.Count * _lineHeight > viewHeight) + { + availableWidth = Math.Max(1f, availableWidth - Scale(ScrollBarWidth)); + _visualLines = BuildVisualLines(ui, availableWidth); + } + } + + private List BuildVisualLines(FishUI ui, float availableWidth) + { + List result = new List(); + + for (int row = 0; row < _lines.Count; row++) + { + string line = _lines[row] ?? ""; + if (!WordWrap || ui == null || line.Length == 0) + { + result.Add(new VisualLine(row, 0, line.Length)); + continue; + } + + int start = 0; + while (start < line.Length) + { + int remaining = line.Length - start; + int fit = 0; + for (int length = 1; length <= remaining; length++) + { + float width = ui.Graphics.MeasureText(_cachedFont, line.Substring(start, length)).X; + if (width > availableWidth) + break; + fit = length; + } + + if (fit == 0) + fit = 1; + + if (fit < remaining) + { + int whitespaceBreak = -1; + for (int i = start + fit - 1; i >= start; i--) + { + if (char.IsWhiteSpace(line[i])) + { + whitespaceBreak = i; + break; + } + } + + if (whitespaceBreak >= start) + fit = whitespaceBreak - start + 1; + } + + result.Add(new VisualLine(row, start, fit)); + start += fit; + } + } + + if (result.Count == 0) + result.Add(new VisualLine(0, 0, 0)); + + return result; + } + + private int GetVisualLineIndex(int logicalRow, int column) + { + int lastMatch = 0; + for (int i = 0; i < _visualLines.Count; i++) + { + VisualLine visual = _visualLines[i]; + if (visual.LogicalRow != logicalRow) + continue; + + lastMatch = i; + bool isLastSegment = i == _visualLines.Count - 1 || _visualLines[i + 1].LogicalRow != logicalRow; + if (column < visual.EndColumn || (isLastSegment && column <= visual.EndColumn)) + return i; + } + + return lastMatch; + } + + private void MoveCursorByVisualLines(int delta) + { + EnsureVisualLayout(); + if (_visualLines.Count == 0) + return; + + int currentIndex = GetVisualLineIndex(CursorRow, CursorColumn); + VisualLine current = _visualLines[currentIndex]; + int visualColumn = Math.Max(0, CursorColumn - current.StartColumn); + int targetIndex = Math.Clamp(currentIndex + delta, 0, _visualLines.Count - 1); + VisualLine target = _visualLines[targetIndex]; + CursorRow = target.LogicalRow; + CursorColumn = target.StartColumn + Math.Min(visualColumn, target.Length); + } + public MultiLineEditbox() { Size = new Vector2(300, 200); @@ -471,7 +603,8 @@ private void CreateScrollBar() /// private float GetMaxScrollPixels() { - float contentHeight = _lines.Count * _lineHeight; + EnsureVisualLayout(); + float contentHeight = _visualLines.Count * _lineHeight; float viewHeight = GetTextAreaHeight(); return Math.Max(0, contentHeight - viewHeight); } @@ -489,7 +622,7 @@ private void UpdateScrollBar() if (_scrollBar == null) return; - float contentHeight = _lines.Count * _lineHeight; + float contentHeight = _visualLines.Count * _lineHeight; float viewHeight = GetTextAreaHeight(); // Update scrollbar position and size @@ -538,7 +671,7 @@ public int GetVisibleLineCount() Vector2 size = ScaledSize; float leftOffset = ShowLineNumbers ? Scale(LineNumberWidth) : 0; - float contentHeight = _lines.Count * _lineHeight; + float contentHeight = _visualLines.Count * _lineHeight; float viewHeight = GetTextAreaHeight(); float rightOffset = (ShowScrollBar && contentHeight > viewHeight) ? Scale(ScrollBarWidth) : 0; @@ -570,18 +703,9 @@ public override void DrawControl(FishUI UI, float Dt, float Time) _cursorVisible = false; } - // Get font and calculate line height + // Get font metrics and build visual rows for the current width. var font = UI.Settings.FontDefault; - _cachedFont = font; - if (font != null) - { - var textSize = UI.Graphics.MeasureText(font, "Mg"); - _lineHeight = textSize.Y; - } - else - { - _lineHeight = 16f; - } + EnsureVisualLayout(); // Draw background using textbox NPatch NPatch bg = HasFocus ? UI.Settings.ImgTextboxActive : UI.Settings.ImgTextboxNormal; @@ -608,7 +732,7 @@ public override void DrawControl(FishUI UI, float Dt, float Time) // Calculate text area var (textAreaPos, textAreaSize) = GetTextAreaBounds(); - float contentHeight = _lines.Count * _lineHeight; + float contentHeight = _visualLines.Count * _lineHeight; float viewHeight = GetTextAreaHeight(); // Clamp scroll offset @@ -622,45 +746,35 @@ public override void DrawControl(FishUI UI, float Dt, float Time) var (selStart, selEnd) = GetSelectionRange(); bool hasSelection = HasSelection && HasFocus; - // Draw all lines with pixel offset - for (int lineIndex = 0; lineIndex < _lines.Count; lineIndex++) + // Draw all visual rows with pixel offset. Logical text remains unchanged. + for (int visualIndex = 0; visualIndex < _visualLines.Count; visualIndex++) { - string line = _lines[lineIndex]; - float lineY = textAreaPos.Y + lineIndex * _lineHeight - _scrollOffsetPixels; + VisualLine visual = _visualLines[visualIndex]; + string logicalLine = _lines[visual.LogicalRow]; + string line = logicalLine.Substring(visual.StartColumn, visual.Length); + float lineY = textAreaPos.Y + visualIndex * _lineHeight - _scrollOffsetPixels; // Skip lines completely outside visible area if (lineY + _lineHeight < textAreaPos.Y || lineY > textAreaPos.Y + viewHeight) continue; // Draw selection highlight for this line - if (hasSelection && font != null && lineIndex >= selStart.Row && lineIndex <= selEnd.Row) + if (hasSelection && font != null && visual.LogicalRow >= selStart.Row && visual.LogicalRow <= selEnd.Row) { - int startCol = 0; - int endCol = line.Length; + int logicalStartCol = visual.LogicalRow == selStart.Row ? selStart.Col : 0; + int logicalEndCol = visual.LogicalRow == selEnd.Row ? selEnd.Col : logicalLine.Length; + int startCol = Math.Max(visual.StartColumn, logicalStartCol); + int endCol = Math.Min(visual.EndColumn, logicalEndCol); - if (lineIndex == selStart.Row) - startCol = selStart.Col; - if (lineIndex == selEnd.Row) - endCol = selEnd.Col; - - if (startCol < endCol || (lineIndex > selStart.Row && lineIndex < selEnd.Row)) + if (startCol < endCol) { float selStartX = textAreaPos.X; float selEndX = textAreaPos.X; - if (line.Length > 0) - { - if (startCol > 0) - selStartX += UI.Graphics.MeasureText(font, line.Substring(0, Math.Min(startCol, line.Length))).X; - if (endCol > 0) - selEndX += UI.Graphics.MeasureText(font, line.Substring(0, Math.Min(endCol, line.Length))).X; - } - - // For full line selections (middle lines), extend to a minimum width - if (lineIndex > selStart.Row && lineIndex < selEnd.Row && line.Length == 0) - { - selEndX = selStartX + UI.Graphics.MeasureText(font, " ").X; - } + if (startCol > visual.StartColumn) + selStartX += UI.Graphics.MeasureText(font, logicalLine.Substring(visual.StartColumn, startCol - visual.StartColumn)).X; + if (endCol > visual.StartColumn) + selEndX += UI.Graphics.MeasureText(font, logicalLine.Substring(visual.StartColumn, endCol - visual.StartColumn)).X; float selWidth = selEndX - selStartX; if (selWidth > 0) @@ -680,9 +794,11 @@ public override void DrawControl(FishUI UI, float Dt, float Time) } // Draw cursor on this line - if (HasFocus && _cursorVisible && CursorRow == lineIndex) + if (HasFocus && _cursorVisible && CursorRow == visual.LogicalRow && + visualIndex == GetVisualLineIndex(CursorRow, CursorColumn)) { - string textBeforeCursor = line.Substring(0, Math.Min(CursorColumn, line.Length)); + int cursorInVisual = Math.Clamp(CursorColumn - visual.StartColumn, 0, visual.Length); + string textBeforeCursor = line.Substring(0, cursorInVisual); float cursorX = textAreaPos.X; if (font != null && textBeforeCursor.Length > 0) { @@ -706,13 +822,17 @@ public override void DrawControl(FishUI UI, float Dt, float Time) float gutterW = Scale(LineNumberWidth); UI.Graphics.BeginScissor(new Vector2(gutterX, pos.Y), new Vector2(gutterW, size.Y)); - for (int lineIndex = 0; lineIndex < _lines.Count; lineIndex++) + for (int visualIndex = 0; visualIndex < _visualLines.Count; visualIndex++) { - float lineY = textAreaPos.Y + lineIndex * _lineHeight - _scrollOffsetPixels; + VisualLine visual = _visualLines[visualIndex]; + if (visual.StartColumn != 0) + continue; + + float lineY = textAreaPos.Y + visualIndex * _lineHeight - _scrollOffsetPixels; if (lineY + _lineHeight < pos.Y || lineY > pos.Y + size.Y) continue; - string lineNum = (lineIndex + 1).ToString(); + string lineNum = (visual.LogicalRow + 1).ToString(); var numSize = UI.Graphics.MeasureText(font, lineNum); float numX = gutterX + gutterW - numSize.X - Scale(8); UI.Graphics.DrawTextColor(font, lineNum, new Vector2(numX, lineY), LineNumberColor); @@ -742,10 +862,11 @@ public override void DrawControl(FishUI UI, float Dt, float Time) private void EnsureCursorVisible() { + EnsureVisualLayout(); if (_lineHeight <= 0) return; - float cursorY = CursorRow * _lineHeight; + float cursorY = GetVisualLineIndex(CursorRow, CursorColumn) * _lineHeight; float viewHeight = GetTextAreaHeight(); // Scroll up if cursor is above visible area @@ -857,57 +978,92 @@ public override void HandleKeyPress(FishUI UI, FishInputState InState, FishKey K if (InState.ShiftDown) { StartSelection(); - MoveCursorUpInternal(); + if (WordWrap) + MoveCursorByVisualLines(-1); + else + MoveCursorUpInternal(); ExtendSelection(); } else { ClearSelection(); - MoveCursorUpInternal(); + if (WordWrap) + MoveCursorByVisualLines(-1); + else + MoveCursorUpInternal(); } break; case FishKey.Down: if (InState.ShiftDown) { StartSelection(); - MoveCursorDownInternal(); + if (WordWrap) + MoveCursorByVisualLines(1); + else + MoveCursorDownInternal(); ExtendSelection(); } else { ClearSelection(); - MoveCursorDownInternal(); + if (WordWrap) + MoveCursorByVisualLines(1); + else + MoveCursorDownInternal(); } break; case FishKey.Home: + int homeColumn = 0; + if (WordWrap) + { + EnsureVisualLayout(); + homeColumn = _visualLines[GetVisualLineIndex(CursorRow, CursorColumn)].StartColumn; + } if (InState.ShiftDown) { StartSelection(); - CursorColumn = 0; + CursorColumn = homeColumn; ExtendSelection(); } else { - CursorColumn = 0; + CursorColumn = homeColumn; ClearSelection(); } break; case FishKey.End: + int endColumn = _lines[CursorRow].Length; + if (WordWrap) + { + EnsureVisualLayout(); + endColumn = _visualLines[GetVisualLineIndex(CursorRow, CursorColumn)].EndColumn; + } if (InState.ShiftDown) { StartSelection(); - CursorColumn = _lines[CursorRow].Length; + CursorColumn = endColumn; ExtendSelection(); } else { - CursorColumn = _lines[CursorRow].Length; + CursorColumn = endColumn; ClearSelection(); } break; case FishKey.PageUp: { int visibleLines = GetVisibleLineCount(); + if (WordWrap) + { + if (InState.ShiftDown) + StartSelection(); + else + ClearSelection(); + MoveCursorByVisualLines(-visibleLines); + if (InState.ShiftDown) + ExtendSelection(); + break; + } if (InState.ShiftDown) { StartSelection(); @@ -926,6 +1082,17 @@ public override void HandleKeyPress(FishUI UI, FishInputState InState, FishKey K case FishKey.PageDown: { int visibleLines = GetVisibleLineCount(); + if (WordWrap) + { + if (InState.ShiftDown) + StartSelection(); + else + ClearSelection(); + MoveCursorByVisualLines(visibleLines); + if (InState.ShiftDown) + ExtendSelection(); + break; + } if (InState.ShiftDown) { StartSelection(); @@ -1089,22 +1256,24 @@ public override void HandleMouseWheel(FishUI UI, FishInputState InState, float D private void PositionCursorFromMouse(FishUI UI, Vector2 mousePos) { + EnsureVisualLayout(); var (textAreaPos, textAreaSize) = GetTextAreaBounds(); - // Calculate clicked row based on pixel position - int clickedRow = (int)((mousePos.Y - textAreaPos.Y + _scrollOffsetPixels) / _lineHeight); - clickedRow = Math.Clamp(clickedRow, 0, _lines.Count - 1); - CursorRow = clickedRow; + // Calculate clicked visual row based on pixel position. + int visualIndex = (int)((mousePos.Y - textAreaPos.Y + _scrollOffsetPixels) / _lineHeight); + visualIndex = Math.Clamp(visualIndex, 0, _visualLines.Count - 1); + VisualLine visual = _visualLines[visualIndex]; + CursorRow = visual.LogicalRow; // Calculate clicked column string line = _lines[CursorRow]; - if (_cachedFont != null && line.Length > 0) + if (_cachedFont != null && visual.Length > 0) { float relativeX = mousePos.X - textAreaPos.X; - int col = 0; + int col = visual.StartColumn; float accumulatedWidth = 0f; - for (int i = 0; i < line.Length; i++) + for (int i = visual.StartColumn; i < visual.EndColumn; i++) { float charWidth = UI.Graphics.MeasureText(_cachedFont, line[i].ToString()).X; if (accumulatedWidth + charWidth / 2 >= relativeX) @@ -1116,7 +1285,7 @@ private void PositionCursorFromMouse(FishUI UI, Vector2 mousePos) } else { - CursorColumn = 0; + CursorColumn = visual.StartColumn; } } diff --git a/FishUIDemos/Samples/SampleWindows98Notepad.cs b/FishUIDemos/Samples/SampleWindows98Notepad.cs new file mode 100644 index 0000000..5212ea7 --- /dev/null +++ b/FishUIDemos/Samples/SampleWindows98Notepad.cs @@ -0,0 +1,765 @@ +using FishUI; +using FishUI.Controls; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace FishUIDemos +{ + /// + /// A functional, self-contained recreation of the Windows 98 Notepad experience. + /// Documents are stored in a private in-memory file system and never touch the host disk. + /// + public class SampleWindows98Notepad : ISample + { + private const string UntitledName = "Untitled"; + + private FishUI.FishUI _fui; + private IFishUIInput _input; + private Window _notepadWindow; + private MultiLineEditbox _editor; + private readonly DemoNotepadFileSystem _fileSystem = new DemoNotepadFileSystem(); + private string _currentPath; + private string _lastDirectory = DemoNotepadFileSystem.DocumentsDirectory; + private bool _dirty; + private bool _suppressDirtyTracking; + private string _findText = ""; + private bool _matchCase; + private Action _pendingAction; + + public string Name => "Windows 98 Notepad"; + + public TakeScreenshotFunc TakeScreenshot { get; set; } + + public FishUI.FishUI CreateUI(FishUISettings UISettings, IFishUIGfx Gfx, IFishUIInput Input, IFishUIEvents Events) + { + _input = Input; + _fui = new FishUI.FishUI(UISettings, Gfx, Input, Events); + _fui.Init(); + _fui.Resized(Gfx.GetWindowWidth(), Gfx.GetWindowHeight()); + + // This sample intentionally uses the classic GWEN skin regardless of the chooser preference. + UISettings.LoadTheme("data/themes/gwen.yaml", applyImmediately: true); + return _fui; + } + + public void Init() + { + CreateNotepadWindow(); + RegisterHotkeys(); + _fui.FocusControl(_editor); + } + + private void CreateNotepadWindow() + { + Vector2 windowSize = new Vector2(820, 560); + _notepadWindow = new Window(GetWindowTitle(), windowSize) + { + Position = new Vector2( + Math.Max(20, (_fui.Width - windowSize.X) / 2), + Math.Max(20, (_fui.Height - windowSize.Y) / 2)), + MinSize = new Vector2(420, 280), + IsResizable = true, + ShowCloseButton = true, + ShowShadow = true + }; + _notepadWindow.OnClosing += HandleNotepadClosing; + _fui.AddControl(_notepadWindow); + + Vector2 contentSize = _notepadWindow.GetContentSize(); + MenuBar menuBar = new MenuBar + { + Position = Vector2.Zero, + Size = new Vector2(contentSize.X, 24), + Anchor = FishUIAnchor.Top | FishUIAnchor.Left | FishUIAnchor.Right, + BarHeight = 24 + }; + _notepadWindow.AddChild(menuBar); + + _editor = new MultiLineEditbox + { + Position = new Vector2(0, 24), + Size = new Vector2(contentSize.X, contentSize.Y - 24), + Anchor = FishUIAnchor.All, + TextPadding = 3, + ShowLineNumbers = false, + ShowScrollBar = true, + WordWrap = false, + BackgroundColor = FishColor.White, + TextColor = FishColor.Black + }; + _editor.OnTextChanged += (_, _) => + { + if (!_suppressDirtyTracking) + _dirty = true; + }; + _notepadWindow.AddChild(_editor); + + BuildMenus(menuBar); + } + + private void BuildMenus(MenuBar menuBar) + { + MenuBarItem fileMenu = menuBar.AddMenu("File"); + MenuItem newItem = fileMenu.AddItem("New"); + newItem.ShortcutText = "Ctrl+N"; + newItem.OnClicked += _ => RequestNewDocument(); + + MenuItem openItem = fileMenu.AddItem("Open..."); + openItem.ShortcutText = "Ctrl+O"; + openItem.OnClicked += _ => ShowOpenDialog(); + + MenuItem saveItem = fileMenu.AddItem("Save"); + saveItem.ShortcutText = "Ctrl+S"; + saveItem.OnClicked += _ => SaveDocument(); + + MenuItem saveAsItem = fileMenu.AddItem("Save As..."); + saveAsItem.OnClicked += _ => ShowSaveAsDialog(); + + fileMenu.AddSeparator(); + fileMenu.AddItem("Page Setup...").Disabled = true; + fileMenu.AddItem("Print...").Disabled = true; + fileMenu.AddSeparator(); + + MenuItem exitItem = fileMenu.AddItem("Exit"); + exitItem.OnClicked += _ => _notepadWindow.Close(); + + MenuBarItem editMenu = menuBar.AddMenu("Edit"); + editMenu.AddItem("Undo").Disabled = true; + editMenu.AddSeparator(); + + MenuItem cutItem = editMenu.AddItem("Cut"); + cutItem.ShortcutText = "Ctrl+X"; + cutItem.OnClicked += _ => CutSelection(); + + MenuItem copyItem = editMenu.AddItem("Copy"); + copyItem.ShortcutText = "Ctrl+C"; + copyItem.OnClicked += _ => CopySelection(); + + MenuItem pasteItem = editMenu.AddItem("Paste"); + pasteItem.ShortcutText = "Ctrl+V"; + pasteItem.OnClicked += _ => PasteClipboard(); + + MenuItem deleteItem = editMenu.AddItem("Delete"); + deleteItem.ShortcutText = "Del"; + deleteItem.OnClicked += _ => DeleteSelection(); + + editMenu.AddSeparator(); + MenuItem selectAllItem = editMenu.AddItem("Select All"); + selectAllItem.ShortcutText = "Ctrl+A"; + selectAllItem.OnClicked += _ => + { + _editor.SelectAll(); + _fui.FocusControl(_editor); + }; + + MenuItem timeDateItem = editMenu.AddItem("Time/Date"); + timeDateItem.ShortcutText = "F5"; + timeDateItem.OnClicked += _ => InsertTimeAndDate(); + + editMenu.AddSeparator(); + MenuItem wordWrapItem = editMenu.AddCheckItem("Word Wrap", false); + wordWrapItem.OnClicked += item => + { + _editor.WordWrap = item.IsChecked; + _editor.ScrollOffsetPixels = 0; + _fui.FocusControl(_editor); + }; + + editMenu.OnOpened += _ => + { + bool hasSelection = _editor.HasSelection; + cutItem.Disabled = !hasSelection; + copyItem.Disabled = !hasSelection; + deleteItem.Disabled = !hasSelection; + }; + + MenuBarItem searchMenu = menuBar.AddMenu("Search"); + MenuItem findItem = searchMenu.AddItem("Find..."); + findItem.ShortcutText = "Ctrl+F"; + findItem.OnClicked += _ => ShowFindDialog(); + + MenuItem findNextItem = searchMenu.AddItem("Find Next"); + findNextItem.ShortcutText = "F3"; + findNextItem.OnClicked += _ => FindNext(); + + MenuBarItem helpMenu = menuBar.AddMenu("Help"); + helpMenu.AddItem("Help Topics").Disabled = true; + helpMenu.AddSeparator(); + MenuItem aboutItem = helpMenu.AddItem("About Notepad"); + aboutItem.OnClicked += _ => ShowAboutDialog(); + } + + private void RegisterHotkeys() + { + RegisterHotkey(FishKey.N, FishKeyModifiers.Control, RequestNewDocument, "notepad.new"); + RegisterHotkey(FishKey.O, FishKeyModifiers.Control, ShowOpenDialog, "notepad.open"); + RegisterHotkey(FishKey.S, FishKeyModifiers.Control, () => SaveDocument(), "notepad.save"); + RegisterHotkey(FishKey.F, FishKeyModifiers.Control, ShowFindDialog, "notepad.find"); + RegisterHotkey(FishKey.F3, FishKeyModifiers.None, FindNext, "notepad.find-next"); + RegisterHotkey(FishKey.F5, FishKeyModifiers.None, InsertTimeAndDate, "notepad.time-date"); + } + + private void RegisterHotkey(FishKey key, FishKeyModifiers modifiers, Action action, string id) + { + _fui.Hotkeys.Register(key, modifiers, _ => + { + if (_fui.ModalControl == null && _notepadWindow.Visible) + action(); + }, id); + } + + private void RequestNewDocument() + { + RequestDestructiveAction(() => SetDocument(null, "")); + } + + private void HandleNotepadClosing(object sender, WindowCloseEventArgs args) + { + if (!_dirty) + return; + + args.Cancel = true; + RequestDestructiveAction(() => + { + _dirty = false; + _notepadWindow.Close(); + }); + } + + private void RequestDestructiveAction(Action action) + { + if (!_dirty) + { + action(); + return; + } + + _pendingAction = action; + string documentName = _currentPath == null ? UntitledName : _fileSystem.GetFileName(_currentPath); + ShowChoiceDialog( + "Notepad", + $"The text in the {documentName} file has changed.\nDo you want to save the changes?", + ("Yes", () => SaveDocument(RunPendingAction, CancelPendingAction)), + ("No", RunPendingAction), + ("Cancel", CancelPendingAction)); + } + + private void RunPendingAction() + { + Action action = _pendingAction; + _pendingAction = null; + action?.Invoke(); + } + + private void CancelPendingAction() + { + _pendingAction = null; + _fui.FocusControl(_editor); + } + + private void ShowOpenDialog() + { + FilePickerDialog dialog = new FilePickerDialog( + FilePickerMode.Open, + _fileSystem, + _lastDirectory, + "*.txt") + { + Title = "Open", + IsModal = true + }; + + dialog.OnFileConfirmed += (_, path) => + { + _lastDirectory = _fileSystem.GetDirectoryName(path) ?? _lastDirectory; + RequestDestructiveAction(() => LoadDocument(path)); + }; + dialog.OnDialogCancelled += _ => _fui.FocusControl(_editor); + ShowFilePicker(dialog); + } + + private void SaveDocument(Action afterSave = null, Action onCancel = null) + { + if (_currentPath == null) + { + ShowSaveAsDialog(afterSave, onCancel); + return; + } + + _fileSystem.WriteAllText(_currentPath, _editor.Text); + _dirty = false; + UpdateWindowTitle(); + afterSave?.Invoke(); + } + + private void ShowSaveAsDialog(Action afterSave = null, Action onCancel = null) + { + FilePickerDialog dialog = new FilePickerDialog( + FilePickerMode.Save, + _fileSystem, + _lastDirectory, + "*.txt") + { + Title = "Save As", + FileName = _currentPath == null ? "Untitled.txt" : _fileSystem.GetFileName(_currentPath), + IsModal = true + }; + + dialog.OnFileConfirmed += (_, selectedPath) => + { + string path = EnsureTextExtension(selectedPath); + _lastDirectory = _fileSystem.GetDirectoryName(path) ?? _lastDirectory; + + if (_fileSystem.Exists(path) && !string.Equals(path, _currentPath, StringComparison.OrdinalIgnoreCase)) + { + ShowChoiceDialog( + "Save As", + $"{_fileSystem.GetFileName(path)} already exists.\nDo you want to replace it?", + ("Yes", () => CompleteSaveAs(path, afterSave)), + ("No", () => ShowSaveAsDialog(afterSave, onCancel))); + return; + } + + CompleteSaveAs(path, afterSave); + }; + dialog.OnDialogCancelled += _ => + { + onCancel?.Invoke(); + _fui.FocusControl(_editor); + }; + ShowFilePicker(dialog); + } + + private void CompleteSaveAs(string path, Action afterSave) + { + _currentPath = path; + _fileSystem.WriteAllText(path, _editor.Text); + _dirty = false; + UpdateWindowTitle(); + afterSave?.Invoke(); + } + + private void ShowFilePicker(FilePickerDialog dialog) + { + dialog.Show(_fui); + _fui.SetModalControl(dialog); + } + + private void LoadDocument(string path) + { + SetDocument(path, _fileSystem.ReadAllText(path)); + } + + private void SetDocument(string path, string text) + { + _suppressDirtyTracking = true; + _editor.Text = text ?? ""; + _editor.CursorRow = 0; + _editor.CursorColumn = 0; + _editor.ClearSelection(); + _editor.ScrollToStart(); + _suppressDirtyTracking = false; + _currentPath = path; + _dirty = false; + UpdateWindowTitle(); + _fui.FocusControl(_editor); + } + + private void UpdateWindowTitle() + { + _notepadWindow.Title = GetWindowTitle(); + } + + private string GetWindowTitle() + { + string documentName = _currentPath == null ? UntitledName : _fileSystem.GetFileName(_currentPath); + return $"{documentName} - Notepad"; + } + + private static string EnsureTextExtension(string path) + { + string fileName = path.Replace('/', '\\'); + int separator = fileName.LastIndexOf('\\'); + int period = fileName.LastIndexOf('.'); + return period <= separator ? path + ".txt" : path; + } + + private void CopySelection() + { + string text = _editor.Copy(); + if (!string.IsNullOrEmpty(text)) + _input.SetClipboardText(text); + _fui.FocusControl(_editor); + } + + private void CutSelection() + { + string text = _editor.Cut(); + if (!string.IsNullOrEmpty(text)) + _input.SetClipboardText(text); + _fui.FocusControl(_editor); + } + + private void PasteClipboard() + { + _editor.Paste(_input.GetClipboardText() ?? ""); + _fui.FocusControl(_editor); + } + + private void DeleteSelection() + { + if (_editor.HasSelection) + _editor.Cut(); + _fui.FocusControl(_editor); + } + + private void InsertTimeAndDate() + { + DateTime now = DateTime.Now; + _editor.InsertText($"{now:t} {now:d}"); + _fui.FocusControl(_editor); + } + + private void ShowFindDialog() + { + Window dialog = CreateModalWindow("Find", new Vector2(430, 165)); + + Label findLabel = new Label("Find what:") + { + Position = new Vector2(12, 14), + Size = new Vector2(85, 24), + Alignment = Align.Left + }; + dialog.AddChild(findLabel); + + Textbox findBox = new Textbox(_findText) + { + Position = new Vector2(100, 12), + Size = new Vector2(210, 25) + }; + dialog.AddChild(findBox); + + CheckBox matchCase = new CheckBox("Match case") + { + Position = new Vector2(100, 55), + Size = new Vector2(16, 16), + IsChecked = _matchCase + }; + dialog.AddChild(matchCase); + + Button findNext = new Button + { + Text = "Find Next", + Position = new Vector2(325, 12), + Size = new Vector2(90, 28) + }; + findNext.OnButtonPressed += (_, _, _) => + { + _findText = findBox.Text; + _matchCase = matchCase.IsChecked; + dialog.Close(); + FindNext(); + }; + dialog.AddChild(findNext); + + Button cancel = new Button + { + Text = "Cancel", + Position = new Vector2(325, 50), + Size = new Vector2(90, 28) + }; + cancel.OnButtonPressed += (_, _, _) => + { + dialog.Close(); + _fui.FocusControl(_editor); + }; + dialog.AddChild(cancel); + + ShowModalWindow(dialog); + _fui.FocusControl(findBox); + } + + private void FindNext() + { + if (string.IsNullOrEmpty(_findText)) + { + ShowFindDialog(); + return; + } + + string text = _editor.Text; + int startOffset = GetDocumentOffset(_editor.CursorRow, _editor.CursorColumn); + if (_editor.HasSelection) + { + var (_, end) = _editor.GetSelectionRange(); + startOffset = GetDocumentOffset(end.Row, end.Col); + } + + StringComparison comparison = _matchCase ? StringComparison.CurrentCulture : StringComparison.CurrentCultureIgnoreCase; + int match = text.IndexOf(_findText, Math.Clamp(startOffset, 0, text.Length), comparison); + if (match < 0) + { + ShowMessageDialog("Notepad", $"Cannot find \"{_findText}\""); + return; + } + + (int startRow, int startColumn) = GetRowAndColumn(match); + (int endRow, int endColumn) = GetRowAndColumn(match + _findText.Length); + _editor.SelectionStartRow = startRow; + _editor.SelectionStartColumn = startColumn; + _editor.SelectionEndRow = endRow; + _editor.SelectionEndColumn = endColumn; + _editor.CursorRow = endRow; + _editor.CursorColumn = endColumn; + _fui.FocusControl(_editor); + } + + private int GetDocumentOffset(int row, int column) + { + int offset = 0; + for (int i = 0; i < row && i < _editor.Lines.Count; i++) + offset += _editor.Lines[i].Length + 1; + return offset + column; + } + + private (int Row, int Column) GetRowAndColumn(int offset) + { + int remaining = Math.Clamp(offset, 0, _editor.Text.Length); + for (int row = 0; row < _editor.Lines.Count; row++) + { + int length = _editor.Lines[row].Length; + if (remaining <= length) + return (row, remaining); + remaining -= length + 1; + } + + int lastRow = Math.Max(0, _editor.Lines.Count - 1); + return (lastRow, _editor.Lines[lastRow].Length); + } + + private void ShowAboutDialog() + { + Window dialog = CreateModalWindow("About Notepad", new Vector2(420, 210)); + + ImageRef icon = _fui.Graphics.LoadImage("data/images/help_win95.png"); + ImageBox image = new ImageBox(icon) + { + Position = new Vector2(18, 22), + Size = new Vector2(64, 64), + ScaleMode = ImageScaleMode.Fit, + FilterMode = ImageFilterMode.Pixelated + }; + dialog.AddChild(image); + + Label title = new Label("Microsoft Notepad") + { + Position = new Vector2(100, 22), + Size = new Vector2(280, 24), + Alignment = Align.Left + }; + dialog.AddChild(title); + + Label description = new Label("Windows 98-style sample for FishUI\nDocuments are stored in memory only.") + { + Position = new Vector2(100, 52), + Size = new Vector2(290, 55), + Alignment = Align.Left + }; + dialog.AddChild(description); + + Button ok = new Button + { + Text = "OK", + Position = new Vector2(310, 125), + Size = new Vector2(80, 28) + }; + ok.OnButtonPressed += (_, _, _) => + { + dialog.Close(); + _fui.FocusControl(_editor); + }; + dialog.AddChild(ok); + + ShowModalWindow(dialog); + } + + private void ShowMessageDialog(string title, string message) + { + ShowChoiceDialog(title, message, ("OK", () => _fui.FocusControl(_editor))); + } + + private void ShowChoiceDialog(string title, string message, params (string Text, Action Action)[] choices) + { + Window dialog = CreateModalWindow(title, new Vector2(460, 175)); + + Label label = new Label(message) + { + Position = new Vector2(18, 18), + Size = new Vector2(420, 55), + Alignment = Align.Left + }; + dialog.AddChild(label); + + const float buttonWidth = 90; + const float gap = 10; + float totalWidth = choices.Length * buttonWidth + Math.Max(0, choices.Length - 1) * gap; + float startX = 460 - 12 - totalWidth; + + for (int i = 0; i < choices.Length; i++) + { + (string buttonText, Action action) = choices[i]; + Button button = new Button + { + Text = buttonText, + Position = new Vector2(startX + i * (buttonWidth + gap), 92), + Size = new Vector2(buttonWidth, 28) + }; + button.OnButtonPressed += (_, _, _) => + { + dialog.Close(); + action?.Invoke(); + }; + dialog.AddChild(button); + } + + ShowModalWindow(dialog); + } + + private Window CreateModalWindow(string title, Vector2 size) + { + return new Window(title, size) + { + IsResizable = false, + IsModal = true, + AlwaysOnTop = true, + ShowShadow = true + }; + } + + private void ShowModalWindow(Window dialog) + { + _fui.AddControl(dialog); + dialog.CenterOnScreen(); + dialog.ShowModal(); + } + + public void Update(float dt) + { + } + + private sealed class DemoNotepadFileSystem : IFishUIFileSystem + { + public const string RootDirectory = "C:\\"; + public const string DocumentsDirectory = "C:\\My Documents"; + + private readonly Dictionary _files = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [$"{DocumentsDirectory}\\WELCOME.TXT"] = + "Welcome to the FishUI Windows 98 Notepad sample!\n\n" + + "This document lives entirely in memory. Try editing it, using Find, and saving a copy.", + [$"{DocumentsDirectory}\\NOTES.TXT"] = + "FishUI Notepad notes:\n- File dialogs use a virtual drive\n- Clipboard commands use the host clipboard\n- Word Wrap is implemented by MultiLineEditbox" + }; + + public bool Exists(string path) => _files.ContainsKey(Normalize(path)); + + public string ReadAllText(string path) + { + return _files.TryGetValue(Normalize(path), out string value) ? value : ""; + } + + public void WriteAllText(string path, string contents) + { + _files[Normalize(path)] = contents ?? ""; + } + + public string GetFullPath(string path) + { + if (string.IsNullOrWhiteSpace(path) || path == ".") + return DocumentsDirectory; + return Normalize(path); + } + + public string GetDirectoryName(string path) + { + string normalized = Normalize(path); + if (string.Equals(normalized, RootDirectory, StringComparison.OrdinalIgnoreCase)) + return null; + + int separator = normalized.LastIndexOf('\\'); + if (separator <= 2) + return RootDirectory; + return normalized.Substring(0, separator); + } + + public string CombinePath(string path1, string path2) + { + if (!string.IsNullOrEmpty(path2) && path2.Length >= 2 && path2[1] == ':') + return Normalize(path2); + return Normalize($"{path1?.TrimEnd('\\', '/') ?? RootDirectory}\\{path2?.TrimStart('\\', '/') ?? ""}"); + } + + public string GetFileName(string path) + { + string normalized = Normalize(path); + int separator = normalized.LastIndexOf('\\'); + return separator >= 0 ? normalized.Substring(separator + 1) : normalized; + } + + public string[] GetDirectories(string path) + { + string normalized = Normalize(path); + if (string.Equals(normalized, RootDirectory, StringComparison.OrdinalIgnoreCase)) + return new[] { DocumentsDirectory }; + return Array.Empty(); + } + + public string[] GetFiles(string path, string searchPattern = "*") + { + string directory = Normalize(path); + bool textOnly = string.Equals(searchPattern, "*.txt", StringComparison.OrdinalIgnoreCase); + return _files.Keys + .Where(file => string.Equals(GetDirectoryName(file), directory, StringComparison.OrdinalIgnoreCase)) + .Where(file => !textOnly || file.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) + .OrderBy(file => file, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public bool IsDirectory(string path) + { + string normalized = Normalize(path); + return string.Equals(normalized, RootDirectory, StringComparison.OrdinalIgnoreCase) || + string.Equals(normalized, DocumentsDirectory, StringComparison.OrdinalIgnoreCase); + } + + public string GetParentDirectory(string path) + { + string normalized = Normalize(path); + if (string.Equals(normalized, DocumentsDirectory, StringComparison.OrdinalIgnoreCase)) + return RootDirectory; + if (string.Equals(normalized, RootDirectory, StringComparison.OrdinalIgnoreCase)) + return null; + return GetDirectoryName(normalized); + } + + private static string Normalize(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return RootDirectory; + + string normalized = path.Trim().Replace('/', '\\'); + while (normalized.Contains("\\\\")) + normalized = normalized.Replace("\\\\", "\\"); + + if (string.Equals(normalized, "C:", StringComparison.OrdinalIgnoreCase) || + string.Equals(normalized, RootDirectory, StringComparison.OrdinalIgnoreCase)) + return RootDirectory; + + return normalized.TrimEnd('\\'); + } + } + } +} + diff --git a/FishUISample/Program.cs b/FishUISample/Program.cs index e4373b4..f59f8b0 100644 --- a/FishUISample/Program.cs +++ b/FishUISample/Program.cs @@ -107,7 +107,7 @@ static void Main(string[] args) FishUISettings UISettings = new FishUISettings(); UISettings.UIScale = 1.0f; - RaylibGfx Gfx = new RaylibGfx(1920, 1080, "FishUI - " + Cur.Name); + RaylibGfx Gfx = new RaylibGfx(1280, 720, "FishUI - " + Cur.Name); Gfx.UseBeginDrawing = false; IFishUIInput Input = new RaylibInput(); IFishUIEvents Events = new EvtHandler(); diff --git a/FishUISample/SampleChooser.cs b/FishUISample/SampleChooser.cs index e0aec75..1fa27cd 100644 --- a/FishUISample/SampleChooser.cs +++ b/FishUISample/SampleChooser.cs @@ -16,6 +16,8 @@ namespace FishUISample.Samples /// internal class SampleChooser { + private const string FeaturedSampleName = "Windows 98 Notepad"; + private ISample[] _samples; private ISample _selectedSample; private bool _selectionMade; @@ -127,10 +129,17 @@ private void CreateChooserUI() _sampleListBox.TooltipText = "Select a sample to launch"; _fui.AddControl(_sampleListBox); - // Add samples to ListBox + // Keep the featured sample visible instead of hiding it below the fold. + // UserData preserves the original sample object and command-line numbering. + int featuredIndex = Array.FindIndex(_samples, + sample => sample.Name.Equals(FeaturedSampleName, StringComparison.OrdinalIgnoreCase)); + if (featuredIndex >= 0) + AddSampleListItem(featuredIndex); + for (int i = 0; i < _samples.Length; i++) { - _sampleListBox.AddItem($"{i + 1}. {_samples[i].Name}"); + if (i != featuredIndex) + AddSampleListItem(i); } // Select first item by default @@ -146,9 +155,10 @@ private void CreateChooserUI() launchBtn.OnButtonPressed += (btn, mbtn, pos) => { int idx = _sampleListBox.SelectedIndex; - if (idx >= 0 && idx < _samples.Length) + if (idx >= 0 && idx < _sampleListBox.Items.Count && + _sampleListBox.Items[idx].UserData is ISample sample) { - _selectedSample = _samples[idx]; + _selectedSample = sample; _selectionMade = true; } }; @@ -209,5 +219,11 @@ private void CreateChooserUI() infoLabel.Alignment = Align.Center; infoPanel.AddChild(infoLabel); } + + private void AddSampleListItem(int sampleIndex) + { + ISample sample = _samples[sampleIndex]; + _sampleListBox.AddItem(new ListBoxItem($"{sampleIndex + 1}. {sample.Name}", sample)); + } } } diff --git a/README.md b/README.md index d9b1907..0b8ea93 100644 --- a/README.md +++ b/README.md @@ -628,6 +628,7 @@ dotnet run -- --sample 0 - **Theme Switcher**: Runtime theme switching - **Virtual Cursor**: Keyboard/gamepad navigation - **Game Menu**: Example game-style UI +- **Windows 98 Notepad**: Classic text editor clone with in-memory Open/Save, Find, clipboard commands, and Word Wrap - **Editor Layout**: Load and display layouts from FishUIEditor - **Data Controls**: DataGrid, SpreadsheetGrid, DatePicker, TimePicker - **Serialization**: Layout save/load with event handler binding