Skip to content

Instantly share code, notes, and snippets.

@davidfowl
Last active June 18, 2025 23:01
Show Gist options
  • Save davidfowl/e88526b280b2de490ea821cb44a6ed88 to your computer and use it in GitHub Desktop.
Save davidfowl/e88526b280b2de490ea821cb44a6ed88 to your computer and use it in GitHub Desktop.
React like framework based on Spectre.Console
#:package [email protected]
using Spectre.Console;
using Spectre.Console.Rendering;
using System.Threading.Channels;
// Entry point
var app = new ConsoleApp();
app.Render(new App());
// Example Counter Component
public class Counter : Component, IInputHandler
{
public Counter(Dictionary<string, object?>? props = null) : base(props)
{
// No state initialization in constructor
}
public override void ComponentDidMount()
{
// Move initial state setup here
SetState("count", GetProp("initialCount", 0));
}
private void Increment()
{
var count = GetState<int?>("count") ?? 0;
SetState("count", count + 1);
}
private void Decrement()
{
var count = GetState<int?>("count") ?? 0;
SetState("count", count - 1);
}
public void HandleInput(ConsoleKeyInfo keyInfo)
{
switch (keyInfo.KeyChar)
{
case 'i':
Increment();
break;
case 'd':
Decrement();
break;
case 'q':
Environment.Exit(0);
break;
}
}
public override object Render()
{
var count = GetState<int?>("count") ?? 0;
var title = GetProp<string>("title") ?? "Counter";
var content = Layout.Rows(
new Markup($"[bold]Current Count: [green]{count}[/][/]"),
new Markup(""),
new Markup("[dim]Press 'i' to increment, 'd' to decrement, 'q' to quit[/]")
);
return Layout.Panel(title, content, Color.Blue);
}
}
// Example Dashboard Component
public class Dashboard : Component, IInputHandler
{
public Dashboard(Dictionary<string, object?>? props = null) : base(props)
{
// No state initialization in constructor
}
public override void ComponentDidMount()
{
// Move initial state setup here
SetState("selectedTab", 0);
}
public void HandleInput(ConsoleKeyInfo keyInfo)
{
var selectedTab = GetState<int?>("selectedTab") ?? 0;
var tabs = new[] { "Home", "Stats", "Settings" };
switch (keyInfo.Key)
{
case ConsoleKey.LeftArrow:
SetState("selectedTab", Math.Max(0, selectedTab - 1));
break;
case ConsoleKey.RightArrow:
SetState("selectedTab", Math.Min(tabs.Length - 1, selectedTab + 1));
break;
}
}
public override object Render()
{
var selectedTab = GetState<int?>("selectedTab") ?? 0;
var tabs = new[] { "Home", "Stats", "Settings" };
var tabContent = selectedTab switch
{
0 => new Markup("[green]Welcome to the Dashboard![/]\n\nThis is the home tab."),
1 => new Markup("[yellow]Statistics[/]\n\nUsers: 1,234\nSessions: 5,678"),
2 => new Markup("[blue]Settings[/]\n\nTheme: Dark\nLanguage: English"),
_ => new Markup("Unknown tab")
};
// Create tab headers
var tabHeaders = tabs.Select((tab, index) =>
index == selectedTab
? $"[bold blue on white] {tab} [/]"
: $"[dim] {tab} [/]"
);
var dashboard = Layout.Rows(
new Markup(string.Join(" ", tabHeaders)),
Layout.Rule(),
tabContent,
Layout.Rule(),
new Markup("[dim]Use ← → arrows to navigate tabs, 'q' to quit[/]")
);
return Layout.Panel("Dashboard", dashboard, Color.Blue);
}
}
// Example Todo List Component
public class TodoList : Component, IInputHandler
{
public TodoList(Dictionary<string, object?>? props = null) : base(props)
{
// No state initialization in constructor
}
public override void ComponentDidMount()
{
// Move initial state setup here
SetState("todos", new List<(string text, bool completed)>
{
("Learn React-like patterns", true),
("Build console framework", false),
("Create awesome apps", false)
});
SetState("inputMode", false);
SetState("newTodo", "");
}
public void HandleInput(ConsoleKeyInfo keyInfo)
{
var inputMode = GetState<bool?>("inputMode") ?? false;
var newTodo = GetState<string>("newTodo") ?? "";
var currentTodos = GetState<List<(string, bool)>>("todos") ?? new List<(string, bool)>();
if (inputMode)
{
if (keyInfo.Key == ConsoleKey.Enter && !string.IsNullOrWhiteSpace(newTodo))
{
currentTodos.Add((newTodo, false));
SetState("todos", currentTodos);
SetState("inputMode", false);
SetState("newTodo", "");
}
else if (keyInfo.Key == ConsoleKey.Escape)
{
SetState("inputMode", false);
SetState("newTodo", "");
}
else if (keyInfo.Key == ConsoleKey.Backspace && newTodo.Length > 0)
{
SetState("newTodo", newTodo.Substring(0, newTodo.Length - 1));
}
else if (char.IsLetterOrDigit(keyInfo.KeyChar) || char.IsPunctuation(keyInfo.KeyChar) || keyInfo.KeyChar == ' ')
{
SetState("newTodo", newTodo + keyInfo.KeyChar);
}
}
else
{
switch (keyInfo.KeyChar)
{
case 'a':
SetState("inputMode", true);
break;
case 't':
for (int i = 0; i < currentTodos.Count; i++)
{
if (!currentTodos[i].Item2)
{
currentTodos[i] = (currentTodos[i].Item1, true);
SetState("todos", currentTodos);
break;
}
}
break;
}
}
}
public override object Render()
{
var todos = GetState<List<(string text, bool completed)>>("todos") ?? new List<(string, bool)>();
var inputMode = GetState<bool?>("inputMode") ?? false;
var newTodo = GetState<string>("newTodo") ?? "";
var todoItems = todos.Select((todo, index) =>
{
var checkbox = todo.Item2 ? "[green]✓[/]" : "[ ]";
var text = todo.Item2 ? $"[dim strikethrough]{todo.Item1}[/]" : todo.Item1;
return new Markup($"{checkbox} {text}");
}).Cast<object>().ToArray();
var content = new List<object>();
content.AddRange(todoItems);
if (inputMode)
{
content.Add(new Markup(""));
content.Add(new Markup($"[yellow]New todo: {newTodo}_[/]"));
content.Add(new Markup("[dim]Type your todo, Enter to add, Esc to cancel[/]"));
}
else
{
content.Add(new Markup(""));
content.Add(new Markup("[dim]Press 'a' to add todo, 't' to toggle first incomplete[/]"));
}
return Layout.Panel("Todo List", Layout.Rows(content.ToArray()), Color.Green);
}
}
// Example App Component that combines multiple components
public class App : Component, IInputHandler
{
public App() : base()
{
SetState("currentView", "counter");
}
private Component? _activeChild;
public void HandleInput(ConsoleKeyInfo keyInfo)
{
switch (keyInfo.KeyChar)
{
case '1':
SetState("currentView", "counter");
_activeChild = new Counter(new Dictionary<string, object?> { { "title", "My Counter" }, { "initialCount", 5 } });
break;
case '2':
SetState("currentView", "dashboard");
_activeChild = new Dashboard();
break;
case '3':
SetState("currentView", "todo");
_activeChild = new TodoList();
break;
case 'q':
Environment.Exit(0);
break;
}
(_activeChild as IInputHandler)?.HandleInput(keyInfo);
}
public override void ComponentDidMount()
{
// Set initial view and create initial component
SetState("currentView", "counter");
_activeChild = new Counter(new Dictionary<string, object?> { { "title", "My Counter" }, { "initialCount", 5 } });
_activeChild.ComponentDidMount();
}
public override object Render()
{
var currentView = GetState<string>("currentView") ?? "counter";
// Ensure we have an active child component
if (_activeChild == null)
{
_activeChild = currentView switch
{
"counter" => new Counter(new Dictionary<string, object?> { { "title", "My Counter" }, { "initialCount", 5 } }),
"dashboard" => new Dashboard(),
"todo" => new TodoList(),
_ => null
};
if (_activeChild != null)
{
_activeChild.ComponentDidMount();
}
}
object currentComponent = _activeChild?.Render() ?? new Markup("[red]Unknown view[/]");
var navigation = new Markup("[bold]Navigation:[/] [blue]1[/] Counter | [blue]2[/] Dashboard | [blue]3[/] Todo | [blue]q[/] Quit");
return Layout.Rows(
Layout.Panel("React-like Console Framework", navigation, Color.Yellow),
currentComponent
);
}
}
// Base Component class - similar to React.Component
public abstract class Component
{
protected Dictionary<string, object?> State { get; private set; } = new();
protected Dictionary<string, object?> Props { get; private set; } = new();
public bool ShouldRerender { get; protected set; } = true;
public Component(Dictionary<string, object?>? props = null)
{
Props = props ?? new Dictionary<string, object?>();
}
// setState equivalent
protected void SetState(Dictionary<string, object?> newState)
{
foreach (var kvp in newState)
{
State[kvp.Key] = kvp.Value;
}
ShouldRerender = true;
ConsoleApp.Instance?.ScheduleRerender();
}
protected void SetState(string key, object? value)
{
State[key] = value;
ShouldRerender = true;
ConsoleApp.Instance?.ScheduleRerender();
}
protected T? GetState<T>(string key, T? defaultValue = default(T))
{
return State.ContainsKey(key) ? (T?)State[key] : defaultValue;
}
protected T? GetProp<T>(string key, T? defaultValue = default(T))
{
return Props.ContainsKey(key) ? (T?)Props[key] : defaultValue;
}
// Lifecycle methods
public virtual void ComponentDidMount() { }
public virtual void ComponentWillUnmount() { }
public virtual bool ShouldComponentUpdate() => ShouldRerender;
// Render method - must be implemented by subclasses
public abstract object Render();
// Reset rerender flag after rendering
public void MarkAsRendered()
{
ShouldRerender = false;
}
}
// Hook-like functionality for functional components
public static class Hooks
{
private static Dictionary<string, object?> _state = new();
private static Dictionary<string, List<Action>> _effects = new();
public static (T? value, Action<T?> setValue) UseState<T>(string key, T? initialValue = default(T))
{
if (!_state.ContainsKey(key))
{
_state[key] = initialValue;
}
return ((T?)_state[key], (newValue) =>
{
_state[key] = newValue;
ConsoleApp.Instance?.ScheduleRerender();
}
);
}
public static void UseEffect(string key, Action effect, object[]? dependencies = null)
{
if (!_effects.ContainsKey(key))
{
_effects[key] = new List<Action>();
}
_effects[key].Add(effect);
// For simplicity, we'll run effects immediately
// In a real implementation, you'd track dependencies
effect();
}
}
// Main App class - similar to ReactDOM
public class ConsoleApp
{
public static ConsoleApp? Instance { get; private set; }
private Component? _rootComponent;
private LiveDisplayContext? _liveContext;
private readonly Channel<ConsoleKeyInfo> _inputChannel;
private readonly ChannelWriter<ConsoleKeyInfo> _inputWriter;
private readonly ChannelReader<ConsoleKeyInfo> _inputReader;
private CancellationTokenSource _cancellationTokenSource = new();
public ConsoleApp()
{
Instance = this;
_inputChannel = Channel.CreateUnbounded<ConsoleKeyInfo>();
_inputWriter = _inputChannel.Writer;
_inputReader = _inputChannel.Reader;
}
public void Render(Component component)
{
_rootComponent = component;
_rootComponent.ComponentDidMount();
// Start input thread
var inputTask = Task.Run(InputLoop, _cancellationTokenSource.Token);
StartLiveDisplay();
}
public void ScheduleRerender()
{
UpdateDisplay();
}
private void StartLiveDisplay()
{
if (_rootComponent == null)
{
AnsiConsole.WriteLine("Error: Root component is null!");
return;
}
AnsiConsole.WriteLine("Rendering root component...");
var initialRenderable = _rootComponent.Render() as IRenderable;
if (initialRenderable == null)
{
AnsiConsole.WriteLine("Error: Component render returned null!");
initialRenderable = new Markup("Error rendering component");
}
else
{
AnsiConsole.WriteLine("Component rendered successfully!");
}
AnsiConsole.Live(initialRenderable)
.AutoClear(false)
.Start(ctx =>
{
_liveContext = ctx;
AnsiConsole.WriteLine("Live context started!");
// Start input on background thread
Task.Run(async () => await InputLoop());
// Process input on main thread
ProcessInputLoop();
});
}
private void UpdateDisplay()
{
if (_liveContext == null || _rootComponent == null) return;
var renderable = _rootComponent.Render() as IRenderable ?? new Markup("Error rendering component");
_liveContext.UpdateTarget(renderable);
_rootComponent.MarkAsRendered();
}
private async Task InputLoop()
{
try
{
while (!_cancellationTokenSource.Token.IsCancellationRequested)
{
if (Console.KeyAvailable)
{
var keyInfo = Console.ReadKey(true);
await _inputWriter.WriteAsync(keyInfo, _cancellationTokenSource.Token);
}
await Task.Delay(10, _cancellationTokenSource.Token); // Small delay to prevent busy wait
}
}
catch (OperationCanceledException)
{
// Expected when cancellation is requested
}
}
private void ProcessInputLoop()
{
try
{
while (!_cancellationTokenSource.Token.IsCancellationRequested)
{
if (_inputReader.TryRead(out var keyInfo))
{
// Handle global input
if (keyInfo.KeyChar == 'q' && keyInfo.Modifiers == ConsoleModifiers.Control)
{
Environment.Exit(0);
}
// Pass input to component
if (_rootComponent != null)
{
(_rootComponent as IInputHandler)?.HandleInput(keyInfo);
UpdateDisplay();
}
}
Thread.Sleep(10); // Small delay to prevent busy wait
}
}
catch (OperationCanceledException)
{
// Expected when cancellation is requested
}
}
}
// Interface for components that handle input
public interface IInputHandler
{
void HandleInput(ConsoleKeyInfo keyInfo);
}
// Layout components
public static class Layout
{
public static Panel Panel(string title, object content, Color? borderColor = null)
{
IRenderable renderableContent = content switch
{
IRenderable renderable => renderable,
string text => new Markup(text),
_ => new Markup(content?.ToString() ?? "")
};
var panel = new Panel(renderableContent)
.Header(title)
.BorderColor(borderColor ?? Color.Blue);
return panel;
}
public static Rows Rows(params object[] children)
{
var renderables = children.Select(child => child switch
{
IRenderable renderable => renderable,
string text => new Markup(text),
_ => new Markup(child?.ToString() ?? "")
});
return new Rows(renderables);
}
public static Columns Columns(params object[] children)
{
var renderables = children.Select(child => child switch
{
IRenderable renderable => renderable,
string text => new Markup(text),
_ => new Markup(child?.ToString() ?? "")
});
return new Columns(renderables);
}
public static Rule Rule(string text = "")
{
return new Rule(text);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment