feat(ncurses): optimize rendering, fix memory leaks, and add rounded corners

Significantly improves the stability, performance, and visual quality of the ncurses renderer.
**Renderer Improvements (`clay_renderer_ncurses.c`):**
*   **Flicker Reduction**:
    *   Removed `erase()` call at the start of the frame to enable differential rendering.
    *   Implemented "Dirty Check" optimizations for Rectangles, Borders, and Text. The renderer now reads the existing screen content (using `mvinch`, `mvin_wch`, `mvin_wchnstr`) and only issues draw commands if the content or color differs.
    *   Hardened Rectangle dirty check to mask out volatile attributes (comparing only `A_CHARTEXT | A_COLOR`), preventing false-positive redraws caused by internal terminal flags.
*   **Memory Safety**:
    *   Fixed internal ncurses memory leaks by calling `delscreen(set_term(NULL))` in `Clay_Ncurses_Terminate` to properly free the default screen wrapper.
*   **Visual Features**:
    *   Added support for **Rounded Corners**: Borders with `cornerRadius > 0` now render using Unicode arc characters (`╭`, `╮`, `╯`, `╰`).
    *   Upgraded standard borders to use full Unicode box-drawing characters.
**Example Application Updates (`ncurses-example/main.c`):**
*   **Layout Stability**:
    *   Refactored all layout dimensions and gaps to use `CLAY_NCURSES_CELL_WIDTH` (8) and `CLAY_NCURSES_CELL_HEIGHT` (16) macros, ensuring strict grid alignment.
    *   Fixed vertical jitter in "Profile Icon" and text headers by enforcing exact height multiples and top-alignment, eliminating sub-pixel rounding errors during scroll.
*   **New UI Elements**:
    *   Added a **Floating Help Modal** (toggled via 'H') to demonstrate Z-ordering and localized input handling.
    *   Added "Server Status" progress bars to the Sidebar to demonstrate percent-based sizing and colored rectangles.
    *   Added "Mixed Border" examples to the Sidebar to showcase the new rounded corner capabilities.
    *   Added "Black" background constant usage for cleaner code.
This commit is contained in:
Seintian 2025-12-28 18:05:06 +01:00
parent d4a48a07fc
commit de3d63cf61
2 changed files with 257 additions and 113 deletions

View file

@ -4,15 +4,22 @@
#include <time.h> // for nanosleep
#define DEFAULT_SCROLL_DELTA 3.0f
#define BLACK_BG_COLOR {20, 20, 20, 255}
// State for the example
typedef struct {
bool sidebarOpen;
float scrollDelta;
bool showHelp;
bool shouldQuit;
} AppState;
static AppState appState = { .sidebarOpen = true, .scrollDelta = 0.0f, .shouldQuit = false };
static AppState appState = {
.sidebarOpen = true,
.scrollDelta = 0.0f,
.shouldQuit = false,
.showHelp = false
};
void HandleInput() {
// Reset delta per frame
@ -26,6 +33,9 @@ void HandleInput() {
if (ch == 's' || ch == 'S') {
appState.sidebarOpen = !appState.sidebarOpen;
}
if (ch == 'h' || ch == 'H') {
appState.showHelp = !appState.showHelp;
}
if (ch == KEY_UP) {
appState.scrollDelta += DEFAULT_SCROLL_DELTA;
}
@ -35,35 +45,149 @@ void HandleInput() {
}
}
void RenderProgressBar(Clay_String label, float percent, Clay_Color color) {
CLAY(CLAY_ID_LOCAL("ProgressBarWrapper"), {
.layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .layoutDirection = CLAY_TOP_TO_BOTTOM, .childGap = CLAY_NCURSES_CELL_HEIGHT }
}) {
CLAY(CLAY_ID_LOCAL("LabelRow"), {
.layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .layoutDirection = CLAY_LEFT_TO_RIGHT, .childGap = CLAY_NCURSES_CELL_HEIGHT, .childAlignment = {.y = CLAY_ALIGN_Y_CENTER} }
}) {
CLAY_TEXT(label, CLAY_TEXT_CONFIG({ .textColor = {200, 200, 200, 255}, .fontSize = 16 }));
}
CLAY(CLAY_ID_LOCAL("BarBackground"), {
.layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT) } },
.backgroundColor = {40, 40, 40, 255},
.cornerRadius = {1}
}) {
CLAY(CLAY_ID_LOCAL("BarFill"), {
.layout = { .sizing = { CLAY_SIZING_PERCENT(percent), CLAY_SIZING_GROW() } },
.backgroundColor = color,
.cornerRadius = {1}
}) {}
}
}
}
void RenderServerStatus() {
CLAY(CLAY_ID("ServerStatus"), {
.layout = {
.sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) },
.padding = {16, 16, 16, 16},
.childGap = 16,
.layoutDirection = CLAY_TOP_TO_BOTTOM
},
.backgroundColor = {25, 25, 25, 255},
.border = { .color = {60, 60, 60, 255}, .width = {2, 2, 2, 2} }
}) {
CLAY_TEXT(CLAY_STRING("SERVER STATUS"), CLAY_TEXT_CONFIG({ .textColor = {255, 255, 255, 255} }));
RenderProgressBar(CLAY_STRING("CPU"), 0.45f, (Clay_Color){0, 200, 0, 255});
RenderProgressBar(CLAY_STRING("Mem"), 0.82f, (Clay_Color){200, 150, 0, 255});
}
}
void RenderHelpModal() {
if (!appState.showHelp) return;
CLAY(CLAY_ID("HelpModalOverlay"), {
.layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_GROW() }, .childAlignment = {CLAY_ALIGN_X_CENTER, CLAY_ALIGN_Y_CENTER} },
.floating = { .zIndex = 100, .attachTo = CLAY_ATTACH_TO_ROOT, .pointerCaptureMode = CLAY_POINTER_CAPTURE_MODE_CAPTURE },
.backgroundColor = {0, 0, 0, 150}
}) {
CLAY(CLAY_ID("HelpModalWindow"), {
.layout = {
.sizing = { CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_WIDTH * 60), CLAY_SIZING_FIT(0) },
.padding = CLAY_PADDING_ALL(CLAY_NCURSES_CELL_HEIGHT),
.childGap = CLAY_NCURSES_CELL_WIDTH,
.layoutDirection = CLAY_TOP_TO_BOTTOM
},
.backgroundColor = {30, 30, 30, 255},
.cornerRadius = {4},
.border = { .color = {255, 255, 255, 255}, .width = {2, 2, 2, 2} }
}) {
CLAY_TEXT(CLAY_STRING("Ncurses Example Help"), CLAY_TEXT_CONFIG({ .textColor = {255, 255, 255, 255} }));
CLAY(CLAY_ID("HelpLine1"), { .layout = { .sizing = {CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0)} } }) {
CLAY_TEXT(CLAY_STRING("Keys:"), CLAY_TEXT_CONFIG({ .textColor = {200, 200, 0, 255} }));
}
CLAY_TEXT(CLAY_STRING("- ARROW KEYS: Scroll Feed"), CLAY_TEXT_CONFIG({ .textColor = {200, 200, 200, 255} }));
CLAY_TEXT(CLAY_STRING("- S: Toggle Sidebar"), CLAY_TEXT_CONFIG({ .textColor = {200, 200, 200, 255} }));
CLAY_TEXT(CLAY_STRING("- H: Toggle This Help"), CLAY_TEXT_CONFIG({ .textColor = {200, 200, 200, 255} }));
CLAY_TEXT(CLAY_STRING("- Q: Quit Application"), CLAY_TEXT_CONFIG({ .textColor = {200, 200, 200, 255} }));
CLAY(CLAY_ID("HelpCloseTip"), { .layout = { .sizing = {CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0)}, .padding = {.top = 16} } }) {
CLAY_TEXT(CLAY_STRING("Press 'H' to close."), CLAY_TEXT_CONFIG({ .textColor = {100, 100, 100, 255} }));
}
}
}
}
void RenderSidebar() {
if (!appState.sidebarOpen) return;
CLAY(CLAY_ID("Sidebar"), {
.layout = {
.sizing = { CLAY_SIZING_FIXED(240), CLAY_SIZING_GROW() }, // 30 cells wide
.padding = CLAY_PADDING_ALL(16),
.childGap = 16,
.sizing = { CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_WIDTH * 30), CLAY_SIZING_GROW() },
.padding = CLAY_PADDING_ALL(CLAY_NCURSES_CELL_HEIGHT),
.childGap = CLAY_NCURSES_CELL_HEIGHT,
.layoutDirection = CLAY_TOP_TO_BOTTOM
},
.backgroundColor = {20, 20, 20, 255}, // Uniform Dark BG
.backgroundColor = {20, 20, 20, 255},
.border = { .color = {100, 100, 100, 255}, .width = { .right = 2 } } // Lighter Grey Border
}) {
CLAY_TEXT(CLAY_STRING("SIDEBAR"), CLAY_TEXT_CONFIG({
.textColor = {255, 255, 0, 255} // Bright Yellow
}));
RenderServerStatus();
CLAY(CLAY_ID("SidebarItem1"), {
.layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(32) } },
.backgroundColor = {20, 20, 20, 255} // Uniform BG
.layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 2) } },
.backgroundColor = BLACK_BG_COLOR
}) {
CLAY_TEXT(CLAY_STRING(" > Item 1 (Hello 🌍)"), CLAY_TEXT_CONFIG({ .textColor = {0, 255, 255, 255} })); // Cyan
CLAY_TEXT(CLAY_STRING(" > Item 1 🌍"), CLAY_TEXT_CONFIG({ .textColor = {0, 255, 255, 255} }));
}
CLAY(CLAY_ID("SidebarItem2"), {
.layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(32) } },
.backgroundColor = {20, 20, 20, 255} // Uniform BG
.layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 2) } },
.backgroundColor = BLACK_BG_COLOR
}) {
CLAY_TEXT(CLAY_STRING(" > Item 2"), CLAY_TEXT_CONFIG({ .textColor = {255, 255, 255, 255} })); // White
CLAY_TEXT(CLAY_STRING(" > Item 2 🌐"), CLAY_TEXT_CONFIG({ .textColor = {255, 255, 255, 255} }));
}
CLAY(CLAY_ID("SidebarItemMixed1"), {
.layout = {
.sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 3) },
.childAlignment = { .y = CLAY_ALIGN_Y_CENTER }
},
.backgroundColor = {20, 20, 20, 255},
.cornerRadius = { .topLeft = 8 },
.border = { .color = {255, 100, 100, 255}, .width = {2, 2, 2, 2} }
}) {
CLAY_TEXT(CLAY_STRING(" > TL Round"), CLAY_TEXT_CONFIG({ .textColor = {255, 100, 100, 255} }));
}
CLAY(CLAY_ID("SidebarItemMixed2"), {
.layout = {
.sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 3) },
.childAlignment = { .y = CLAY_ALIGN_Y_CENTER }
},
.backgroundColor = {20, 20, 20, 255},
.cornerRadius = { .topLeft = 8, .bottomRight = 8 },
.border = { .color = {100, 255, 100, 255}, .width = {2, 2, 2, 2} }
}) {
CLAY_TEXT(CLAY_STRING(" > Diagonal"), CLAY_TEXT_CONFIG({ .textColor = {100, 255, 100, 255} }));
}
CLAY(CLAY_ID("SidebarItemMixed3"), {
.layout = {
.sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 3) },
.childAlignment = { .y = CLAY_ALIGN_Y_CENTER }
},
.backgroundColor = {20, 20, 20, 255},
.cornerRadius = { .topLeft = 8, .topRight = 8 },
.border = { .color = {100, 100, 255, 255}, .width = {2, 2, 2, 2} }
}) {
CLAY_TEXT(CLAY_STRING(" > Top Round"), CLAY_TEXT_CONFIG({ .textColor = {100, 100, 255, 255} }));
}
}
}
@ -101,33 +225,33 @@ void RenderPost(int index) {
CLAY(CLAY_IDI("Post", index), {
.layout = {
.sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) },
.padding = CLAY_PADDING_ALL(16),
.childGap = 8,
.padding = CLAY_PADDING_ALL(CLAY_NCURSES_CELL_HEIGHT),
.childGap = CLAY_NCURSES_CELL_HEIGHT,
.layoutDirection = CLAY_TOP_TO_BOTTOM
},
.backgroundColor = {20, 20, 20, 255}, // Uniform BG
.cornerRadius = {8}, // Rounded corners (will render as square in TUI usually unless ACS handled)
.border = { .color = {80, 80, 80, 255}, .width = { .left = 1, .right = 1, .top = 1, .bottom = 1 } }
.backgroundColor = BLACK_BG_COLOR,
.cornerRadius = {1},
.border = { .color = {80, 80, 80, 255}, .width = {2, 2, 2, 2} }
}) {
// Post Header: Avatar + Name + Time
CLAY(CLAY_IDI("PostHeader", index), {
.layout = {
.sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) },
.childGap = 12,
.childAlignment = { .y = CLAY_ALIGN_Y_CENTER },
.childGap = CLAY_NCURSES_CELL_WIDTH * 2,
.childAlignment = { .y = CLAY_ALIGN_Y_TOP },
.layoutDirection = CLAY_LEFT_TO_RIGHT
}
}) {
// Avatar
CLAY(CLAY_IDI("Avatar", index), {
.layout = { .sizing = { CLAY_SIZING_FIXED(32), CLAY_SIZING_FIXED(16) } }, // 2x1 cells approx
.layout = { .sizing = { CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_WIDTH * 4), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 2) } },
.backgroundColor = { (index * 50) % 255, (index * 80) % 255, (index * 30) % 255, 255 },
.cornerRadius = {8}
.cornerRadius = {1}
}) {}
// Name & Title
CLAY(CLAY_IDI("AuthorInfo", index), {
.layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .layoutDirection = CLAY_TOP_TO_BOTTOM, .childGap = 4 }
.layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .layoutDirection = CLAY_TOP_TO_BOTTOM, .childGap = 0 }
}) {
Clay_String name = { .length = strlen(NAMES[index % 8]), .chars = NAMES[index % 8] };
Clay_String title = { .length = strlen(TITLES[index % 8]), .chars = TITLES[index % 8] };
@ -138,7 +262,7 @@ void RenderPost(int index) {
// Post Body
CLAY(CLAY_IDI("PostBody", index), {
.layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .padding = { .top = 8, .bottom = 8 } }
.layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .padding = { .top = CLAY_NCURSES_CELL_HEIGHT, .bottom = CLAY_NCURSES_CELL_HEIGHT } }
}) {
Clay_String lorem = { .length = strlen(LOREM[index % 5]), .chars = LOREM[index % 5] };
CLAY_TEXT(lorem, CLAY_TEXT_CONFIG({ .textColor = {200, 200, 200, 255} }));
@ -146,7 +270,7 @@ void RenderPost(int index) {
// Post Actions
CLAY(CLAY_IDI("PostActions", index), {
.layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .childGap = 16, .layoutDirection = CLAY_LEFT_TO_RIGHT }
.layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIT(0) }, .childGap = CLAY_NCURSES_CELL_HEIGHT, .layoutDirection = CLAY_LEFT_TO_RIGHT }
}) {
CLAY_TEXT(CLAY_STRING("[ Like ]"), CLAY_TEXT_CONFIG({ .textColor = {0, 255, 0, 255} })); // Bright Green
CLAY_TEXT(CLAY_STRING("[ Comment ]"), CLAY_TEXT_CONFIG({ .textColor = {0, 100, 255, 255} })); // Bright Blue
@ -159,20 +283,20 @@ void RenderContent() {
CLAY(CLAY_ID("ContentArea"), {
.layout = {
.sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_GROW() },
.padding = CLAY_PADDING_ALL(16),
.childGap = 16,
.padding = CLAY_PADDING_ALL(CLAY_NCURSES_CELL_HEIGHT),
.childGap = CLAY_NCURSES_CELL_HEIGHT,
.layoutDirection = CLAY_TOP_TO_BOTTOM
},
.backgroundColor = {20, 20, 20, 255} // Uniform BG
.backgroundColor = BLACK_BG_COLOR
}) {
// Sticky Header
CLAY(CLAY_ID("Header"), {
.layout = {
.sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(48) }, // 3 cells high
.padding = { .left = 16, .right=16 },
.sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_FIXED(CLAY_NCURSES_CELL_HEIGHT * 3) }, // 3 cells high
.padding = { .left = CLAY_NCURSES_CELL_WIDTH * 2, .right=CLAY_NCURSES_CELL_WIDTH * 2 },
.childAlignment = { .y = CLAY_ALIGN_Y_CENTER }
},
.backgroundColor = {20, 20, 20, 255}, // Uniform BG
.backgroundColor = BLACK_BG_COLOR,
.border = { .color = {0, 100, 255, 255}, .width = { .bottom = 1 } }
}) {
CLAY_TEXT(CLAY_STRING("Clay Social Feed"), CLAY_TEXT_CONFIG({ .textColor = {255, 255, 255, 255} }));
@ -186,7 +310,7 @@ void RenderContent() {
.padding = { .top = 8, .bottom = 8 }
},
.clip = { .vertical = true, .childOffset = Clay_GetScrollOffset() },
.backgroundColor = {20, 20, 20, 255} // Uniform BG
.backgroundColor = BLACK_BG_COLOR
}) {
CLAY(CLAY_ID("FeedList"), {
.layout = {
@ -213,10 +337,10 @@ void RenderContent() {
void RenderMainLayout() {
CLAY(CLAY_ID("Root"), {
.layout = { .sizing = { CLAY_SIZING_GROW(), CLAY_SIZING_GROW() }, .layoutDirection = CLAY_LEFT_TO_RIGHT },
.backgroundColor = {20, 20, 20, 255} // Uniform BG
}) {
RenderSidebar();
RenderContent();
RenderHelpModal();
}
}
@ -257,7 +381,7 @@ int main() {
Clay_Ncurses_Render(commands);
struct timespec ts = { .tv_sec = 0, .tv_nsec = 32000 * 1000 };
struct timespec ts = { .tv_sec = 0, .tv_nsec = 16000 * 1000 };
nanosleep(&ts, NULL);
}