Last active
June 18, 2025 23:01
-
-
Save davidfowl/e88526b280b2de490ea821cb44a6ed88 to your computer and use it in GitHub Desktop.
React like framework based on Spectre.Console
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#: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