Skip to content
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
732e5ab
[patch] Design a data table widget with sorting, filtering, and cell …
matt-edmondson Sep 29, 2026
42e81ea
[patch] Plan the data table widget implementation
matt-edmondson Sep 29, 2026
add2444
[patch] Share the key chord matcher and let held chords repeat
matt-edmondson Sep 29, 2026
e979c23
[minor] Add typed data table columns and their edit sessions
matt-edmondson Sep 29, 2026
78d531c
[patch] Document the data table edit sessions' members
matt-edmondson Sep 29, 2026
44e2838
[minor] Sort and filter a data table's rows into a view
matt-edmondson Sep 29, 2026
c8e944d
[minor] Move a data table's active cell and select its rows
matt-edmondson Sep 29, 2026
1cc7333
[minor] Edit one data table cell at a time and report each edit
matt-edmondson Sep 29, 2026
840d8ab
[minor] Declare the data table's keyboard commands for ktsu.Keybinding
matt-edmondson Sep 29, 2026
8583b10
[minor] Draw a data table with sortable headers and clickable cells
matt-edmondson Sep 29, 2026
1698ca0
[minor] Filter a data table's columns from a row under the headers
matt-edmondson Sep 29, 2026
65eab0e
[minor] Drive a data table from the keyboard and edit cells in place
matt-edmondson Sep 29, 2026
1a7be86
[patch] Leave the clipboard as found and ignore characters a cell can…
matt-edmondson Sep 29, 2026
17e0131
[minor] Open a caller-drawn context menu on a data table cell
matt-edmondson Sep 29, 2026
6dd88c7
[patch] Show the data table in the widgets demo and document it
matt-edmondson Sep 29, 2026
0ad9d46
[patch] State which frameworks ktsu.Keybinding brings System.Text.Jso…
matt-edmondson Sep 29, 2026
d957daf
[patch] Give the data table's clipper the row pitch with cell padding
matt-edmondson Sep 29, 2026
fa47bcb
[patch] Commit a data table edit on keypad Enter and keep the edited …
matt-edmondson Sep 29, 2026
bf17711
[patch] Rebuild a data table over a list replaced after Refresh
matt-edmondson Sep 29, 2026
05fa663
[minor] Let a caller set a data table's selection and active cell
matt-edmondson Sep 29, 2026
2be9107
[patch] Report no edit for a null string committed unchanged
matt-edmondson Sep 29, 2026
f2ca46a
[patch] Begin a data table edit from a character typed with AltGr
matt-edmondson Sep 29, 2026
a7cade9
[patch] Require Shift for a chord that names it even with Shift ignored
matt-edmondson Sep 29, 2026
3eec0e6
[patch] Drive the data table through a keymap in the UI tests
matt-edmondson Sep 29, 2026
55099c7
[patch] Skip parts already in stock when the demo marks them in stock
matt-edmondson Sep 29, 2026
9b1fa84
[patch] Drop a semicolon from the data table's Key Files line
matt-edmondson Sep 29, 2026
d0cad3c
Merge main into the data table branch
claude Sep 29, 2026
0684ca2
Merge main into the data table branch
matt-edmondson Sep 29, 2026
bb3e38a
[patch] Read an empty clipboard without throwing and address code qua…
matt-edmondson Sep 29, 2026
ae144de
[patch] Show the data table demo once, with the advanced demos
matt-edmondson Sep 29, 2026
479f9a7
Merge main into the data table branch and give DataTable a gallery tile
matt-edmondson Sep 29, 2026
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
18 changes: 16 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,9 @@ This is the **ktsu ImGui Suite**, a collection of .NET libraries for building De
- `ImGui.NodeEditor/NodeEditorEngine.cs` - Node graph business logic (`NodeEditorEngine.CommentBoxes.cs` holds comment boxes, `NodeEditorEngine.Snapshots.cs` the internal capture/restore primitives the history uses)
- `ImGui.NodeEditor/NodeEditorHistory.cs` - Undo/redo over `ktsu.UndoRedo`; see [Undo, snapping and comment boxes](#undo-snapping-and-comment-boxes)
- `ImGui.NodeEditor/NodeEditorRenderer.CommentBoxes.cs` - Comment box drawing and gestures
- `ImGui.NodeEditor/NodeEditorCommands.cs` - The keyboard commands as `ktsu.Keybinding` commands, and the chord-to-ImGui matcher
- `ImGui.NodeEditor/NodeEditorCommands.cs` - The keyboard commands as `ktsu.Keybinding` commands
- `ImGui.Widgets/KeyChordMatcher.cs` - The chord-to-ImGui matcher shared by the node editor and the data table, with optional key repeat and optional Shift
- `ImGui.Widgets/DataTableState.cs` - The data table's state, split across `DataTableState.Navigation.cs` and `DataTableState.Editing.cs`, with no ImGui calls. `DataTable.cs` and `DataTable.Input.cs` draw it

### Dependencies

Expand All @@ -150,7 +152,7 @@ This is the **ktsu ImGui Suite**, a collection of .NET libraries for building De
- **ktsu.Invoker** (1.1.2) - Delegate invocation utilities
- **ktsu.ScopedAction** (1.1.6) - RAII-pattern scoped actions
- **ktsu.UndoRedo** (2.0.3) - Undo/redo stack backing `NodeEditorHistory` in `ImGui.NodeEditor`
- **ktsu.Keybinding** (2.0.3) - Keymap `NodeEditorInputHandler` can read its commands from (`NodeEditorCommands`)
- **ktsu.Keybinding** (2.0.3) - Keymap `NodeEditorInputHandler` and `DataTable` read their commands from (`NodeEditorCommands`, `DataTableCommands`). Referenced by `ImGui.Widgets` since the data table, so every consumer of `ktsu.ImGui.Widgets` restores it, and with it `Microsoft.Extensions.DependencyInjection.Abstractions` on every target framework, plus `System.Text.Json` on net8.0 and net9.0 only. Accepted because it's small and the table's keys have to follow the host's keymap.
- **Polyfill** (9.7.7) - Backport newer .NET APIs
- **Markdig** - CommonMark markdown parser backing `ImGui.Markdown`

Expand Down Expand Up @@ -380,6 +382,18 @@ Five things are worth knowing before changing any of it:
is ambiguous to `ItemProbe`, so tests address elements as `Tags/[0]` and their remove buttons as
`Tags/[0]/remove`.

### Data table

`DataTable` is two pieces. `DataTableState<TRow>` holds the view (source indices, filtered and then sorted, with ties broken by source index), the active cell, the selection, and the one edit session, and makes no ImGui calls, so its rules are unit tests. The renderer draws a frame from it and turns input into its commands.

Rows are always addressed by source index. The view rebuilds only when the sort, a filter, or the row count changes, or when the caller calls `Refresh()`, so an edited row stays put. A change in row count clears the selection and cancels an edit, because the table can't tell which rows the old indices meant.

Edits are reported, never applied. The session is cleared before `OnEdit` runs, so a callback can apply the edit and call `Refresh()`.

Tab stops are off inside the table (`ImGuiItemFlags.NoTabStop`), because Tab is the table's own command and ImGui's tabbing would otherwise hand the keyboard to a filter box.

`ImGui.Widgets.csproj` sets `PolyUseEmbeddedAttribute` because `NodeEditorInputHandler` needs internal access to the shared `KeyChordMatcher`, and giving `ImGui.NodeEditor` that access through `InternalsVisibleTo` would otherwise make Polyfill's generated extension methods ambiguous between the two assemblies.

### Hexa-backed vs ktsu widgets

Seven widget pairs were recorded as deliberate duplication pending a verdict (#338). The verdict:
Expand Down
118 changes: 0 additions & 118 deletions ImGui.NodeEditor/NodeEditorCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@

namespace ktsu.ImGui.NodeEditor;

using System;
using System.Collections.Generic;
using System.Linq;
using Hexa.NET.ImGui;
using ktsu.Keybinding.Core.Contracts;
using ktsu.Keybinding.Core.Models;

Expand Down Expand Up @@ -93,119 +91,3 @@ public static int Register(ICommandRegistry registry, IKeybindingService keybind
return bound;
}
}

/// <summary>
/// Answers whether a <c>ktsu.Keybinding</c> chord was pressed this frame, in ImGui's terms.
/// </summary>
/// <remarks>
/// A chord matches when its modifiers are exactly the ones held — so Ctrl+Z does not fire for
/// Ctrl+Shift+Z — every other key in it is down, and at least one of them went down this frame. Key
/// repeat is ignored, so holding a chord fires it once.
/// </remarks>
internal static class KeyChordMatcher
{
private static readonly Dictionary<string, ImGuiKey> Aliases = new(StringComparer.OrdinalIgnoreCase)
{
["ESC"] = ImGuiKey.Escape,
["RETURN"] = ImGuiKey.Enter,
["DEL"] = ImGuiKey.Delete,
["INS"] = ImGuiKey.Insert,
["UP"] = ImGuiKey.UpArrow,
["DOWN"] = ImGuiKey.DownArrow,
["LEFT"] = ImGuiKey.LeftArrow,
["RIGHT"] = ImGuiKey.RightArrow,
["ARROWUP"] = ImGuiKey.UpArrow,
["ARROWDOWN"] = ImGuiKey.DownArrow,
["ARROWLEFT"] = ImGuiKey.LeftArrow,
["ARROWRIGHT"] = ImGuiKey.RightArrow,
["PGUP"] = ImGuiKey.PageUp,
["PGDN"] = ImGuiKey.PageDown,
["SPACEBAR"] = ImGuiKey.Space,
};

public static bool IsPressed(Chord chord)
{
bool ctrl = false;
bool alt = false;
bool shift = false;
bool meta = false;
List<ImGuiKey> keys = [];

foreach (string name in chord.Notes.Select(note => note.ToString()))
{
switch (name)
{
case "CTRL" or "CONTROL":
ctrl = true;
break;

case "ALT":
alt = true;
break;

case "SHIFT":
shift = true;
break;

case "META" or "WIN" or "WINDOWS" or "CMD" or "COMMAND" or "SUPER":
meta = true;
break;

default:
if (!TryMapKey(name, out ImGuiKey key))
{
// A key ImGui has no name for can never be pressed, so neither can the chord.
return false;
}

keys.Add(key);
break;
}
}

if (keys.Count == 0)
{
return false;
}

ImGuiIOPtr io = ImGui.GetIO();
if (io.KeyCtrl != ctrl || io.KeyAlt != alt || io.KeyShift != shift || io.KeySuper != meta)
{
return false;
}

return keys.All(ImGui.IsKeyDown) && keys.Any(key => ImGui.IsKeyPressed(key, repeat: false));
}

/// <summary>Find the ImGui key a note names.</summary>
/// <param name="name">The note's name, upper-cased as <c>ktsu.Keybinding</c> stores it.</param>
/// <param name="key">The key.</param>
/// <returns>True if ImGui has such a key.</returns>
public static bool TryMapKey(string name, out ImGuiKey key)
{
if (name.Length == 1 && name[0] is >= 'A' and <= 'Z')
{
key = (ImGuiKey)((int)ImGuiKey.A + (name[0] - 'A'));
return true;
}

if (name.Length == 1 && name[0] is >= '0' and <= '9')
{
key = (ImGuiKey)((int)ImGuiKey.Key0 + (name[0] - '0'));
return true;
}

if (Aliases.TryGetValue(name, out key))
{
return true;
}

// Everything else by ImGui's own name — DELETE, BACKSPACE, F5, PAGEUP, COMMA — but never a
// modifier, a mouse button or one of the range markers, none of which is a key to press.
return Enum.TryParse(name, ignoreCase: true, out key)
&& key > ImGuiKey.NamedKeyBegin
&& key < ImGuiKey.GamepadStart
&& key is not (ImGuiKey.LeftCtrl or ImGuiKey.RightCtrl or ImGuiKey.LeftShift or ImGuiKey.RightShift
or ImGuiKey.LeftAlt or ImGuiKey.RightAlt or ImGuiKey.LeftSuper or ImGuiKey.RightSuper);
}
}
1 change: 1 addition & 0 deletions ImGui.NodeEditor/NodeEditorInputHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ namespace ktsu.ImGui.NodeEditor;
using System.Linq;
using Hexa.NET.ImGui;
using Hexa.NET.ImNodes;
using ktsu.ImGui.Widgets;
using ktsu.Keybinding.Core.Contracts;
using ktsu.Keybinding.Core.Models;

Expand Down
218 changes: 218 additions & 0 deletions ImGui.Widgets/DataTable.Input.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.ImGui.Widgets;

using System.Numerics;

using Hexa.NET.ImGui;

using ktsu.Keybinding.Core.Contracts;
using ktsu.Keybinding.Core.Models;

public static partial class ImGuiWidgets
{
/// <summary>A cell's rectangle on screen, in display pixels.</summary>
internal readonly record struct CellRect(Vector2 Min, Vector2 Max);

/// <summary>Turns the keyboard and clicks outside the editor into data table commands.</summary>
internal static class DataTableInput
{
// Movements that Shift extends into a selection, which is why they're matched with Shift
// ignored. Tab and Shift+Tab are separate commands and are not in this list.
private static readonly (string Command, DataTableMove Move)[] ExtendableMoves =
[
(DataTableCommands.MoveUp, DataTableMove.Up),
(DataTableCommands.MoveDown, DataTableMove.Down),
(DataTableCommands.MoveLeft, DataTableMove.Left),
(DataTableCommands.MoveRight, DataTableMove.Right),
(DataTableCommands.PageUp, DataTableMove.PageUp),
(DataTableCommands.PageDown, DataTableMove.PageDown),
(DataTableCommands.Home, DataTableMove.Home),
(DataTableCommands.End, DataTableMove.End),
(DataTableCommands.First, DataTableMove.First),
(DataTableCommands.Last, DataTableMove.Last),
];

/// <summary>Handles this frame's input. Called inside the table, after the rows are drawn.</summary>
/// <param name="state">The table's state.</param>
/// <param name="options">The table's options, for the keymap.</param>
/// <param name="editorRect">Where the editor was drawn this frame, or <see langword="null"/> if it wasn't.</param>
internal static void Handle<TRow>(DataTableState<TRow> state, DataTableOptions options, CellRect? editorRect)
{
// Checked before focus, because a click outside the table is exactly the case that takes focus
// away from it.
if (state.IsEditing && editorRect is CellRect rect && IsClickedAway(rect))
{
state.CommitEdit();
}

if (!ImGui.IsWindowFocused(ImGuiFocusedFlags.ChildWindows))
{
return;
}

if (state.IsEditing)
{
HandleEditingKeys(state, options);
return;
}

// A filter box has the keyboard. Its keys are its own.
if (ImGui.GetIO().WantTextInput)
{
return;
}

HandleIdleKeys(state, options);
}

private static void HandleEditingKeys<TRow>(DataTableState<TRow> state, DataTableOptions options)
{
if (IsPressed(options, DataTableCommands.Cancel))
{
state.CancelEdit();
}
else if (IsPressed(options, DataTableCommands.Commit) || IsKeypadEnterWithoutKeymap(options))
{
state.CommitEdit();
state.Move(DataTableMove.Down);
}
else if (IsPressed(options, DataTableCommands.Next))
{
state.Move(DataTableMove.Next);
}
else if (IsPressed(options, DataTableCommands.Previous))
{
state.Move(DataTableMove.Previous);
}
}

private static void HandleIdleKeys<TRow>(DataTableState<TRow> state, DataTableOptions options)
{
bool isShiftHeld = ImGui.GetIO().KeyShift;

foreach ((string command, DataTableMove move) in ExtendableMoves)
{
if (IsPressed(options, command, canRepeat: true, isShiftIgnored: true))
{
state.Move(move, isExtending: isShiftHeld);
return;
}
}

if (IsPressed(options, DataTableCommands.Next, canRepeat: true))
{
state.Move(DataTableMove.Next);
}
else if (IsPressed(options, DataTableCommands.Previous, canRepeat: true))
{
state.Move(DataTableMove.Previous);
}
else if (IsPressed(options, DataTableCommands.BeginEdit) || IsEnterWithoutKeymap(options))
{
state.BeginEdit();
}
else if (IsPressed(options, DataTableCommands.Toggle))
{
state.Toggle();
}
else if (IsPressed(options, DataTableCommands.Copy))
{
string text = state.BuildCopyText();
if (text.Length > 0)
{
ImGui.SetClipboardText(text);
}
}
else if (IsPressed(options, DataTableCommands.SelectAll))
{
state.SelectAll();
}
else if (TryTakeTypedCharacter(out char typed))
{
state.BeginEdit(typed);
}
}

private static bool IsPressed(DataTableOptions options, string command, bool canRepeat = false, bool isShiftIgnored = false)
{
Chord? chord = options.Keybindings is IKeybindingService keybindings
? keybindings.GetChord(command)
: DataTableCommands.DefaultChordOf(command);

return chord is not null && KeyChordMatcher.IsPressed(chord, canRepeat, isShiftIgnored);
}

// Enter and keypad Enter begin editing as well as F2 when there's no keymap. A profile holds one
// chord per command, so with a keymap only the bound chord counts.
private static bool IsEnterWithoutKeymap(DataTableOptions options) =>
options.Keybindings is null && (ImGui.IsKeyPressed(ImGuiKey.Enter, false) || IsKeypadEnterWithoutKeymap(options));

// ImGui's text box lets go of the keyboard on keypad Enter as it does on Enter, so without a keymap
// the table commits on it too, rather than leave the edit open behind a text box that's let go.
private static bool IsKeypadEnterWithoutKeymap(DataTableOptions options) =>
options.Keybindings is null && ImGui.IsKeyPressed(ImGuiKey.KeypadEnter, false);

/// <summary>Takes the first printable character typed this frame, which begins an edit with it.</summary>
private static bool TryTakeTypedCharacter(out char typed)
{
typed = default;
ImGuiIOPtr io = ImGui.GetIO();

// Space is the toggle chord, and a character typed with Ctrl, Alt or Super held is a shortcut.
// Ctrl and Alt together are AltGr, though, which is how many layouts type @ and the euro sign.
bool isAltGr = io.KeyCtrl && io.KeyAlt;
bool isShortcut = io.KeySuper || ((io.KeyCtrl || io.KeyAlt) && !isAltGr);
if (isShortcut || io.InputQueueCharacters.Size == 0)
{
return false;
}

// A codepoint above U+FFFF doesn't fit in one char, and a lone surrogate isn't a character, so
// neither can begin an edit.
uint codepoint = io.InputQueueCharacters[0];
if (codepoint > char.MaxValue || char.IsSurrogate((char)codepoint))
{
return false;
}

typed = (char)codepoint;
return !char.IsControl(typed) && !char.IsWhiteSpace(typed);
}

private static bool IsClickedAway(CellRect rect) =>
ImGui.IsMouseClicked(ImGuiMouseButton.Left)
&& !ImGui.IsMouseHoveringRect(rect.Min, rect.Max, false)
&& !ImGui.IsPopupOpen(string.Empty, ImGuiPopupFlags.AnyPopupId | ImGuiPopupFlags.AnyPopupLevel);
}

/// <summary>Opens and draws a data table's context menu.</summary>
internal static class DataTableContextMenus
{
private const string PopupId = "##dataTableContextMenu";

/// <summary>
/// Draws the menu. Called inside the table, so the popup is opened and drawn in the same id scope
/// wherever in the table the right-click landed.
/// </summary>
internal static void Draw<TRow>(DataTableState<TRow> state, DataTableOptions options)
{
// Taken whether or not there's a menu, so a request never lingers into a later frame.
if (state.TakeContextMenuRequest() && options.OnContextMenu is not null)
{
ImGui.OpenPopup(PopupId);
}

if (options.OnContextMenu is null || state.ContextMenu is not DataTableContextMenu menu)
{
return;
}

if (ImGui.BeginPopup(PopupId))
{
options.OnContextMenu(menu);
ImGui.EndPopup();
}
}
}
}
Loading
Loading