From 3db813fcc2349a6440e1ab958cbd3ab67961dcb1 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 31 Jan 2026 15:17:13 +0300 Subject: [PATCH 01/10] tests: initial unit testing Signed-off-by: NotAShelf Change-Id: Ib67a52ddcdbb9d5378dc3dd2dd7b5d106a6a6964 --- tests/test.c | 253 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 tests/test.c diff --git a/tests/test.c b/tests/test.c new file mode 100644 index 0000000..0ee79e5 --- /dev/null +++ b/tests/test.c @@ -0,0 +1,253 @@ +#include "test_common.h" +#include +#include + +int test_failures = 0; +int test_total = 0; + +static int test_null_image_handling(void) { + uint8_t *result = downsample_image(NULL, 100, 100, 4, NULL, NULL, 0.5f); + TEST_ASSERT(result == NULL, "Null image should return NULL"); + return TEST_PASSED; +} + +static int test_zero_dimensions(void) { + int dw, dh; + uint8_t *result = downsample_image(NULL, 0, 0, 4, &dw, &dh, 0.5f); + TEST_ASSERT(result == NULL, "Zero dimensions should return NULL"); + return TEST_PASSED; +} + +static int test_scale_one_preserves_size(void) { + uint8_t *src = create_uniform_image(100, 100, 128, 64, 255); + TEST_ASSERT_PTR_NOT_NULL(src, "Source image allocation"); + + int dw, dh; + uint8_t *dst = downsample_image(src, 100, 100, 4, &dw, &dh, 1.0f); + TEST_ASSERT_PTR_NOT_NULL(dst, "Scale 1.0 result"); + + TEST_ASSERT_EQ(dw, 100, "Width preserved at scale 1.0"); + TEST_ASSERT_EQ(dh, 100, "Height preserved at scale 1.0"); + + int match = compare_images(src, dst, 100, 100, 4, 0.01f); + TEST_ASSERT(match == 1, "Image data preserved at scale 1.0"); + + free(src); + free(dst); + return TEST_PASSED; +} + +static int test_downsample_half_size(void) { + uint8_t *src = create_gradient_image(100, 100); + TEST_ASSERT_PTR_NOT_NULL(src, "Gradient source"); + + int dw, dh; + uint8_t *dst = downsample_image(src, 100, 100, 4, &dw, &dh, 0.5f); + TEST_ASSERT_PTR_NOT_NULL(dst, "Downsample result"); + + TEST_ASSERT_EQ(dw, 50, "Width halved at 0.5 scale"); + TEST_ASSERT_EQ(dh, 50, "Height halved at 0.5 scale"); + + free(src); + free(dst); + return TEST_PASSED; +} + +static int test_minimum_one_pixel(void) { + uint8_t *src = create_uniform_image(1, 1, 100, 100, 100); + TEST_ASSERT_PTR_NOT_NULL(src, "Single pixel source"); + + int dw, dh; + uint8_t *dst = downsample_image(src, 1, 1, 4, &dw, &dh, 0.01f); + TEST_ASSERT_PTR_NOT_NULL(dst, "Minimum size result"); + + TEST_ASSERT_EQ(dw, 1, "Width minimum 1 pixel"); + TEST_ASSERT_EQ(dh, 1, "Height minimum 1 pixel"); + + free(src); + free(dst); + return TEST_PASSED; +} + +static int test_large_image_handling(void) { + uint8_t *src = create_noise_image(4096, 4096, 42); + TEST_ASSERT_PTR_NOT_NULL(src, "Large image allocation"); + + int dw, dh; + uint8_t *dst = downsample_image(src, 4096, 4096, 4, &dw, &dh, 0.25f); + TEST_ASSERT_PTR_NOT_NULL(dst, "Large image downsample"); + + TEST_ASSERT_EQ(dw, 1024, "Correct width at 0.25 scale"); + TEST_ASSERT_EQ(dh, 1024, "Correct height at 0.25 scale"); + + free(src); + free(dst); + return TEST_PASSED; +} + +static int test_alpha_channel_preserved(void) { + uint8_t *src = create_uniform_image(50, 50, 255, 0, 0); + TEST_ASSERT_PTR_NOT_NULL(src, "Red source"); + + int dw, dh; + uint8_t *dst = downsample_image(src, 50, 50, 4, &dw, &dh, 0.5f); + TEST_ASSERT_PTR_NOT_NULL(dst, "Downsample with alpha"); + + TEST_ASSERT_EQ(dw, 25, "Width halved"); + TEST_ASSERT_EQ(dh, 25, "Height halved"); + + TEST_ASSERT(dst[0 * 4 + 3] == 255, "Alpha channel preserved"); + + free(src); + free(dst); + return TEST_PASSED; +} + +static int test_uniform_color_accuracy(void) { + uint8_t *src = create_uniform_image(100, 100, 200, 100, 50); + TEST_ASSERT_PTR_NOT_NULL(src, "Uniform color source"); + + int dw, dh; + uint8_t *dst = downsample_image(src, 100, 100, 4, &dw, &dh, 0.25f); + TEST_ASSERT_PTR_NOT_NULL(dst, "Uniform color downsample"); + + int correct = 1; + for (int i = 0; i < dw * dh; i++) { + if (dst[i * 4 + 0] != 200 || dst[i * 4 + 1] != 100 || dst[i * 4 + 2] != 50) { + correct = 0; + break; + } + } + TEST_ASSERT(correct == 1, "Uniform color preserved in downsampling"); + + free(src); + free(dst); + return TEST_PASSED; +} + +static int test_gradient_smoothness(void) { + uint8_t *src = create_gradient_image(100, 100); + TEST_ASSERT_PTR_NOT_NULL(src, "Gradient source"); + + int dw, dh; + uint8_t *dst = downsample_image(src, 100, 100, 4, &dw, &dh, 0.5f); + TEST_ASSERT_PTR_NOT_NULL(dst, "Gradient downsample"); + + float psnr = compute_psnr(src, dst, dw, dh, 4); + TEST_ASSERT_FTZ(psnr, 9.0f, 5.0f, "Gradient PSNR within expected range"); + + free(src); + free(dst); + return TEST_PASSED; +} + +static int test_checkerboard_no_aliasing(void) { + uint8_t *src = create_checkerboard(100, 100, 10); + TEST_ASSERT_PTR_NOT_NULL(src, "Checkerboard source"); + + int dw, dh; + uint8_t *dst = downsample_image(src, 100, 100, 4, &dw, &dh, 0.5f); + TEST_ASSERT_PTR_NOT_NULL(dst, "Checkerboard downsample"); + + TEST_ASSERT_EQ(dw, 50, "Width halved"); + TEST_ASSERT_EQ(dh, 50, "Height halved"); + + free(src); + free(dst); + return TEST_PASSED; +} + +static TestCase tests[] = { + {"null_image_handling", test_null_image_handling}, + {"zero_dimensions", test_zero_dimensions}, + {"scale_one_preserves_size", test_scale_one_preserves_size}, + {"downsample_half_size", test_downsample_half_size}, + {"minimum_one_pixel", test_minimum_one_pixel}, + {"large_image_handling", test_large_image_handling}, + {"alpha_channel_preserved", test_alpha_channel_preserved}, + {"uniform_color_accuracy", test_uniform_color_accuracy}, + {"gradient_smoothness", test_gradient_smoothness}, + {"checkerboard_no_aliasing", test_checkerboard_no_aliasing}, +}; + +int main(int argc, char **argv) { + int profile = 0; + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--profile") == 0) { + profile = 1; + } + } + + if (profile) { + const char *scenarios[] = {"No_Downsampling", "1080p_Target", "1440p_Target", "4K_Target"}; + float scales[] = {1.0f, 0.5f, 0.42f, 0.25f}; + + for (int s = 0; s < 4; s++) { + char filename[256]; + snprintf(filename, sizeof(filename), "/tmp/chroma_memory_%s.csv", scenarios[s]); + + FILE *f = fopen(filename, "w"); + if (!f) { + fprintf(stderr, "Failed to create %s\n", filename); + continue; + } + + fprintf(f, "Resolution,OriginalSizeMB,DownsampledSizeMB,MemorySavingsPercent\n"); + + const char *res_names[] = {"1080p", "1440p", "4K", "5K", "6K", "8K"}; + int widths[] = {1920, 2560, 3840, 5120, 6016, 7680}; + int heights[] = {1080, 1440, 2160, 2880, 3200, 4320}; + + for (int r = 0; r < 6; r++) { + int w = widths[r]; + int h = heights[r]; + size_t original_bytes = (size_t)w * h * 4; + double original_mb = original_bytes / (1024.0 * 1024.0); + + int dw, dh; + uint8_t *src = create_noise_image(w, h, 42); + uint8_t *dst = downsample_image(src, w, h, 4, &dw, &dh, scales[s]); + size_t downsampled_bytes = (size_t)dw * dh * 4; + double downsampled_mb = downsampled_bytes / (1024.0 * 1024.0); + + double savings = 0.0; + if (original_mb > 0) { + savings = ((original_mb - downsampled_mb) / original_mb) * 100.0; + } + + fprintf(f, "%s,%.2f,%.2f,%.1f\n", res_names[r], original_mb, downsampled_mb, savings); + + free(src); + free(dst); + } + + fclose(f); + printf("Generated: %s\n", filename); + } + return 0; + } + + (void)argc; + (void)argv; + + printf("Chroma Unit Tests\n"); + printf("=================\n\n"); + + test_failures = 0; + test_total = 0; + + for (int i = 0; i < (int)(sizeof(tests) / sizeof(tests[0])); i++) { + int result = tests[i].fn(); + test_total++; + if (result == TEST_PASSED) { + printf(" [PASS] %s\n", tests[i].name); + } else { + printf(" [FAIL] %s\n", tests[i].name); + } + } + + printf("\n-----------------\n"); + printf("Results: %d/%d passed\n", test_total - test_failures, test_total); + + return test_failures > 0 ? 1 : 0; +} From 23527908c28dc5cc0890411240419c1f5183db35 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 31 Jan 2026 15:17:39 +0300 Subject: [PATCH 02/10] config: fix wording Signed-off-by: NotAShelf Change-Id: I41e3e89470fa8181d887de0584c965176a6a6964 --- src/config.c | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/config.c b/src/config.c index 9bae27c..3f713d3 100644 --- a/src/config.c +++ b/src/config.c @@ -196,7 +196,7 @@ static int add_output_mapping(chroma_config_t *config, const char *output_name, return CHROMA_ERROR_MEMORY; } - // Validate string lengths to prevent buffer overflow + // XXX: Validate string lengths to prevent buffer overflow size_t output_len = strlen(output_name); size_t path_len = strlen(image_path); @@ -419,9 +419,9 @@ static int parse_config_line(chroma_config_t *config, char *line, return CHROMA_OK; } - // Check for extended output configuration with properties - // Format: output.DP-1.scale = fill - // Format: output.DP-1.filter = linear + // Check for extended output configuration with properties. Format: + // output.DP-1.scale = fill + // output.DP-1.filter = linear char *dot = strchr(output_name, '.'); if (dot) { // This is an output property (scale or filter) From 5df01492ec6fbbbabb58816ea19e12f00905b888 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 31 Jan 2026 15:17:51 +0300 Subject: [PATCH 03/10] docs: simplify README Signed-off-by: NotAShelf Change-Id: I893d51c6a084a0ed56a27cf0bcfae14b6a6a6964 --- README.md | 60 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 68cdf6d..7018606 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,9 @@ comprehensive monitor management. Here's what makes Chroma stand out: - Wayland development headers - EGL/OpenGL development headers +See [development section](#development) for more details. This section might get +outdated at any given moment, so refer to the Nix shell if in doubt. + ### Building #### Quick Build @@ -66,6 +69,9 @@ make # Or build debug version make debug + +# Alternatively, create a static build +make static ``` ### Installation @@ -179,44 +185,56 @@ Chroma works with any Wayland compositor that supports: - `wl_output` interface - EGL window surface creation -Tested only on Hyprland. +Tested only on Hyprland, but should work fine with any compositor that meets the +above criteria. Which is basically all of them I think? -## Development +## Contributing -### Building Debug Version +You might want to contribute to Chroma for a variety of reasons. I usually will +not judge, however, there are some conventions I expect you to adhere to. Mainly +I would like for you to follow the project's **code style**: + +- C11 standard (I really wished for C99) +- 2-space indentation (use `make format`) +- No tabs (except for the Makefile, obviously) +- Function names: `chroma_function_name` +- Constants: `CHROMA_CONSTANT_NAME` + +Once your changes are done, fork this repository and create a feature branch. +This is not a strict requirement but I'd rather not deal with rebase failures. +Create your feature branch, make your changes, _test thoroughly_ and submit your +pull request when you are done. With your pull request, I'd _really_ like a tiny +snippet of text that explains your motive of changes. While I can infer what you +are trying to do, I'd rather _know_ what was going on in your head. + +### Development + +A Nix shell is provided within the repository. You may use both `nix-shell` and +`nix develop` to enter a development shell with all of the required dependencies +for _dynamic linking_. Additionally, [Direnv](https://direnv.net) users may use +`direnv allow` to use the shell provided by the repository. + +A few convenience commands are provided by the Makefile, which you may invoke at +your own discretion. + +#### Building Debug Version ```bash make debug ``` -### Code Formatting +#### Code Formatting ```bash make format # requires clang-format ``` -### Static Analysis +#### Static Analysis ```bash make analyze # requires cppcheck ``` -## Contributing - -1. Fork the repository -2. Create a feature branch -3. Make your changes -4. Test thoroughly -5. Submit a pull request - -### Code Style - -- C11 standard -- 2-space indentation -- No tabs (except for the Makefile, obviously) -- Function names: `chroma_function_name` -- Constants: `CHROMA_CONSTANT_NAME` - ## License From 4d100741815950891775083d36a2f08d8da3b500 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 31 Jan 2026 15:22:09 +0300 Subject: [PATCH 04/10] nix: streamline packaging Signed-off-by: NotAShelf Change-Id: I4643ed2c8e6f8ceb5e722612cc67a74e6a6a6964 --- nix/package.nix | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/nix/package.nix b/nix/package.nix index 10d2387..497f106 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -15,7 +15,7 @@ in stdenv.mkDerivation { pname = "chroma"; - version = "1.0.0"; + version = "1.0.1"; src = fs.toSource { root = s; @@ -23,8 +23,11 @@ in (s + /include) (s + /protocols) (s + /src) - (s + /Makefile) + + # For testing + (s + /lib) + (s + /tests) ]; }; @@ -49,9 +52,17 @@ in makeFlags = [ "PREFIX=$(out)" - "SYSTEMD_DIR=$(out)/lib/systemd/system" + "SYSTEMD_DIR=$(out)/lib/systemd/system" # FIXME: this is an user service, actually ]; + checkPhase = '' + runHook preCheck + + make test + + runHook postCheck + ''; + postInstall = '' install -Dm755 ${../chroma.conf.sample} $out/share/chroma.conf.sample ''; From 9be9c8276a56161d5d4be43bf349e901faa5e5f3 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 31 Jan 2026 15:22:24 +0300 Subject: [PATCH 05/10] lib: add test helpers Signed-off-by: NotAShelf Change-Id: Iaefc0d503288b4ffe8e6922130acc2ec6a6a6964 --- lib/test_common.c | 190 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 lib/test_common.c diff --git a/lib/test_common.c b/lib/test_common.c new file mode 100644 index 0000000..c19e34f --- /dev/null +++ b/lib/test_common.c @@ -0,0 +1,190 @@ +#define STB_IMAGE_IMPLEMENTATION +#define STB_IMAGE_WRITE_IMPLEMENTATION +#include "test_common.h" +#include "stb_image.h" +#include "stb_image_write.h" +#include +#include + +uint8_t *load_image(const char *path, int *width, int *height, int *channels) { + return stbi_load(path, width, height, channels, 4); +} + +int save_image(const char *path, uint8_t *data, int width, int height, int channels) { + return stbi_write_png(path, width, height, channels, data, width * channels); +} + +uint8_t *downsample_image(uint8_t *src, int sw, int sh, int sc, int *dw, int *dh, float scale) { + if (!src || sw <= 0 || sh <= 0 || sc <= 0 || scale <= 0) { + if (dw) *dw = 0; + if (dh) *dh = 0; + return NULL; + } + + int tw = (int)(sw * scale); + int th = (int)(sh * scale); + + if (tw < 1) tw = 1; + if (th < 1) th = 1; + + uint8_t *dst = malloc(tw * th * sc); + if (!dst) return NULL; + + float inv_scale = 1.0f / scale; + for (int y = 0; y < th; y++) { + for (int x = 0; x < tw; x++) { + float sx = (x + 0.5f) * inv_scale; + float sy = (y + 0.5f) * inv_scale; + + int ix = (int)sx; + int iy = (int)sy; + + if (sw > 1) { + ix = (ix < sw - 2) ? ix : sw - 2; + } else { + ix = 0; + } + if (sh > 1) { + iy = (iy < sh - 2) ? iy : sh - 2; + } else { + iy = 0; + } + + if (ix < 0) ix = 0; + if (iy < 0) iy = 0; + + float fx = sx - ix; + float fy = sy - iy; + + if (sw == 1) fx = 0.0f; + if (sh == 1) fy = 0.0f; + + for (int c = 0; c < sc; c++) { + uint8_t p00 = src[(iy * sw + ix) * sc + c]; + uint8_t p01 = (sw > 1) ? src[(iy * sw + ix + 1) * sc + c] : p00; + uint8_t p10 = (sh > 1) ? src[((iy + 1) * sw + ix) * sc + c] : p00; + uint8_t p11 = (sw > 1 && sh > 1) ? src[((iy + 1) * sw + ix + 1) * sc + c] : p00; + + float interp = p00 * (1 - fx) * (1 - fy) + + p01 * fx * (1 - fy) + + p10 * (1 - fx) * fy + + p11 * fx * fy; + + dst[(y * tw + x) * sc + c] = (uint8_t)(interp + 0.5f); + } + } + } + + *dw = tw; + *dh = th; + return dst; +} + +double get_time_ms(void) { + struct timeval tv; + gettimeofday(&tv, NULL); + return tv.tv_sec * 1000.0 + tv.tv_usec / 1000.0; +} + +double run_benchmark(double (*fn)(void), int iterations) { + double warmup = fn(); + (void)warmup; + + double total = 0.0; + for (int i = 0; i < iterations; i++) { + total += fn(); + } + return total / iterations; +} + +int compare_images(uint8_t *a, uint8_t *b, int w, int h, int ch, float threshold) { + int max_diff = 0; + for (int i = 0; i < w * h * ch; i++) { + int diff = abs((int)a[i] - (int)b[i]); + if (diff > max_diff) max_diff = diff; + if (diff > (int)(threshold * 255.0f)) { + return 0; + } + } + return 1; +} + +float compute_psnr(uint8_t *a, uint8_t *b, int w, int h, int ch) { + double mse = 0.0; + int total = w * h * ch; + + for (int i = 0; i < total; i++) { + double diff = (double)a[i] - (double)b[i]; + mse += diff * diff; + } + + mse /= total; + + if (mse < 1e-10) return 99.99f; + + return (float)(10.0 * log10(255.0 * 255.0 / mse)); +} + +uint8_t *create_gradient_image(int w, int h) { + uint8_t *img = malloc(w * h * 4); + if (!img) return NULL; + + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + int idx = (y * w + x) * 4; + img[idx + 0] = (uint8_t)((float)x / w * 255); + img[idx + 1] = (uint8_t)((float)y / h * 255); + img[idx + 2] = 128; + img[idx + 3] = 255; + } + } + return img; +} + +uint8_t *create_noise_image(int w, int h, unsigned int seed) { + uint8_t *img = malloc(w * h * 4); + if (!img) return NULL; + + srand(seed); + for (int i = 0; i < w * h * 4; i++) { + img[i] = (uint8_t)(rand() % 256); + } + return img; +} + +uint8_t *create_uniform_image(int w, int h, uint8_t r, uint8_t g, uint8_t b) { + uint8_t *img = malloc(w * h * 4); + if (!img) return NULL; + + for (int i = 0; i < w * h; i++) { + img[i * 4 + 0] = r; + img[i * 4 + 1] = g; + img[i * 4 + 2] = b; + img[i * 4 + 3] = 255; + } + return img; +} + +uint8_t *create_checkerboard(int w, int h, int check_size) { + uint8_t *img = malloc(w * h * 4); + if (!img) return NULL; + + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + int idx = (y * w + x) * 4; + int cx = x / check_size; + int cy = y / check_size; + if ((cx + cy) % 2 == 0) { + img[idx + 0] = 255; + img[idx + 1] = 255; + img[idx + 2] = 255; + } else { + img[idx + 0] = 0; + img[idx + 1] = 0; + img[idx + 2] = 0; + } + img[idx + 3] = 255; + } + } + return img; +} From 4a84ed7a212298f4a3d00773a633bf6d55357ae6 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 31 Jan 2026 15:22:35 +0300 Subject: [PATCH 06/10] scripts: visualise benchmark results via Python script Signed-off-by: NotAShelf Change-Id: If48e0a1c4b265946c009b3abd9a249a96a6a6964 --- scripts/generate_report.py | 378 +++++++++++++++++++++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 scripts/generate_report.py diff --git a/scripts/generate_report.py b/scripts/generate_report.py new file mode 100644 index 0000000..d089214 --- /dev/null +++ b/scripts/generate_report.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 +import csv +import os +import sys +from datetime import datetime +from pathlib import Path + +try: + import matplotlib.pyplot as plt + import numpy as np + + HAS_MATPLOTLIB = True +except ImportError: + HAS_MATPLOTLIB = False + +CSV_DIR = Path("/tmp") +OUTPUT_DIR = Path("/tmp") + +RESOLUTIONS = ["1080p", "1440p", "4K", "5K", "6K", "8K"] +SCENARIOS = [ + ("No_Downsampling", "No Downsampling"), + ("1080p_Target", "1080p Target"), + ("1440p_Target", "1440p Target"), + ("4K_Target", "4K Target"), +] + + +def load_csv_data(filename: str) -> list[dict]: + """Load data from CSV file.""" + data = [] + with open(filename, "r") as f: + reader = csv.DictReader(f) + for row in reader: + data.append(row) + return data + + +def extract_value(csv_file: str, resolution: str, column: str) -> str | None: + """Extract a value from CSV for a given resolution and column.""" + if not os.path.exists(csv_file): + return None + data = load_csv_data(csv_file) + for row in data: + if row.get("Resolution") == resolution: + return row.get(column) + return None + + +def generate_text_report() -> str: + """Generate a text-based report.""" + lines = [] + lines.append("Chroma Memory Impact Analysis Report") + lines.append("=" * 44) + lines.append(f"Generated on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + lines.append("") + + lines.append("=== Memory Usage Summary ===") + lines.append("") + lines.append( + f"{'Input':<8} {'Original':<12} {'Downsampled':<12} {'Savings':<10} {'Downsampled?':<12}" + ) + lines.append(f"{'Res':<8} {'Size (MB)':<12} {'Size (MB)':<12} {'(%)':<10} {'':12}") + lines.append("-" * 56) + + for res in RESOLUTIONS: + original = extract_value( + str(CSV_DIR / "chroma_memory_No_Downsampling.csv"), res, "OriginalSizeMB" + ) + downsampled = extract_value( + str(CSV_DIR / "chroma_memory_4K_Target.csv"), res, "DownsampledSizeMB" + ) + savings = extract_value( + str(CSV_DIR / "chroma_memory_4K_Target.csv"), res, "MemorySavingsPercent" + ) + + if original: + orig_mb = float(original) + down_mb = float(downsampled) if downsampled else orig_mb + sav_pct = float(savings) if savings else 0.0 + downsampled_yes = "Yes" if sav_pct > 0 else "No" + lines.append( + f"{res:<8} {orig_mb:<12.2f} {down_mb:<12.2f} {sav_pct:<10.1f} {downsampled_yes:<12}" + ) + + lines.append("") + lines.append("=== Key Findings ===") + lines.append("") + lines.append("Memory Savings by Scenario (4K images):") + lines.append("") + + for name, display_name in SCENARIOS[1:]: + csv_path = CSV_DIR / f"chroma_memory_{name}.csv" + savings = extract_value(str(csv_path), "4K", "MemorySavingsPercent") + if savings: + lines.append(f" {display_name:<20}: {float(savings):>6.1f}%") + + lines.append("") + lines.append("=== Impact on Typical Usage ===") + lines.append("") + lines.append("Scenario: User with 5 wallpapers, mixed resolutions") + lines.append("") + lines.append("Without downsampling: 5 × 31.6 MB = 158.2 MB") + lines.append("With 4K target: 5 × 7.9 MB = 39.6 MB") + lines.append("Memory saved: 118.6 MB (75.0%)") + lines.append("") + lines.append("=== Recommendations ===") + lines.append("") + lines.append("1. Enable downsampling for systems with < 8GB RAM") + lines.append("2. Use 4K target for most users (good balance)") + lines.append("3. Use 1080p target for low-memory systems") + lines.append("4. Disable downsampling only for systems with > 16GB RAM") + lines.append("5. Adjust min_scale_factor to preserve detail when needed") + lines.append("") + lines.append("=== Configuration Examples ===") + lines.append("") + lines.append("# Maximum Performance (low memory)") + lines.append("enable_downsampling = true") + lines.append("max_output_width = 1920") + lines.append("max_output_height = 1080") + lines.append("min_scale_factor = 0.5") + lines.append("") + lines.append("# Balanced (default)") + lines.append("enable_downsampling = true") + lines.append("max_output_width = 3840") + lines.append("max_output_height = 2160") + lines.append("min_scale_factor = 0.25") + lines.append("") + lines.append("# Maximum Quality") + lines.append("enable_downsampling = false") + lines.append("") + lines.append("=== Raw Data ===") + lines.append("") + lines.append("CSV files available at:") + csv_files = list(CSV_DIR.glob("chroma_memory_*.csv")) + if csv_files: + for f in sorted(csv_files): + lines.append(f" {f}") + else: + lines.append(" No CSV files found") + lines.append("") + lines.append("Run 'make profile-memory' to regenerate data.") + + return "\n".join(lines) + + +def create_memory_comparison_graph(): + """Create memory comparison graph for all scenarios.""" + if not HAS_MATPLOTLIB: + raise ImportError("matplotlib not available") + + plt.figure(figsize=(12, 8)) + + colors = ["#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4"] + patterns = ["/", "\\", "|", "-"] + + x = np.arange(len(RESOLUTIONS)) + width = 0.2 + + for i, (scenario_key, scenario_name) in enumerate(SCENARIOS): + csv_path = CSV_DIR / f"chroma_memory_{scenario_key}.csv" + if csv_path.exists(): + data = load_csv_data(str(csv_path)) + original_sizes = [] + downsampled_sizes = [] + + for res in RESOLUTIONS: + row = next((r for r in data if r.get("Resolution") == res), None) + if row: + original_sizes.append(float(row.get("OriginalSizeMB", 0))) + downsampled_sizes.append(float(row.get("DownsampledSizeMB", 0))) + else: + original_sizes.append(0) + downsampled_sizes.append(0) + + offset = i * width + plt.bar( + x + offset, + original_sizes, + width, + label=f"{scenario_name} - Original", + color=colors[i], + alpha=0.7, + ) + plt.bar( + x + offset, + downsampled_sizes, + width, + label=f"{scenario_name} - Downsampled", + color=colors[i], + alpha=0.9, + hatch=patterns[i], + ) + + plt.xlabel("Input Resolution") + plt.ylabel("Memory Usage (MB)") + plt.title("Chroma Memory Usage: Original vs Downsampled") + plt.xticks(x + width * 1.5, RESOLUTIONS) + plt.legend(bbox_to_anchor=(1.05, 1), loc="upper left") + plt.grid(True, alpha=0.3) + plt.tight_layout() + plt.savefig( + OUTPUT_DIR / "chroma_memory_comparison.png", dpi=300, bbox_inches="tight" + ) + plt.close() + + +def create_savings_graph(): + """Create memory savings percentage graph.""" + if not HAS_MATPLOTLIB: + raise ImportError("matplotlib not available") + + plt.figure(figsize=(10, 6)) + + colors = ["#FF6B6B", "#4ECDC4", "#45B7D1"] + markers = ["o", "s", "^"] + + for i, (scenario_key, scenario_name) in enumerate(SCENARIOS[1:]): + csv_path = CSV_DIR / f"chroma_memory_{scenario_key}.csv" + if csv_path.exists(): + data = load_csv_data(str(csv_path)) + resolutions = [] + savings = [] + + for row in data: + pct = row.get("MemorySavingsPercent", "0") + try: + if float(pct) > 0: + resolutions.append(row.get("Resolution", "")) + savings.append(float(pct)) + except ValueError: + continue + + plt.plot( + resolutions, + savings, + marker=markers[i], + color=colors[i], + linewidth=2, + markersize=8, + label=scenario_name, + ) + + plt.xlabel("Input Resolution") + plt.ylabel("Memory Savings (%)") + plt.title("Memory Savings by Input Resolution and Target") + plt.grid(True, alpha=0.3) + plt.legend() + plt.tight_layout() + plt.savefig(OUTPUT_DIR / "chroma_savings.png", dpi=300, bbox_inches="tight") + plt.close() + + +def create_summary_table(): + """Create a summary table image.""" + if not HAS_MATPLOTLIB: + raise ImportError("matplotlib not available") + + fig, ax = plt.subplots(figsize=(10, 6)) + ax.axis("tight") + ax.axis("off") + + scenario_names = [name for _, name in SCENARIOS] + + table_data = [] + for res in RESOLUTIONS: + row = [res] + for scenario_key, _ in SCENARIOS: + csv_path = CSV_DIR / f"chroma_memory_{scenario_key}.csv" + if csv_path.exists(): + data = load_csv_data(str(csv_path)) + data_row = next((r for r in data if r.get("Resolution") == res), None) + if data_row: + savings = data_row.get("MemorySavingsPercent", "0") + try: + pct = float(savings) + row.append(f"{pct:.1f}%" if pct > 0 else "No change") + except ValueError: + row.append("N/A") + else: + row.append("N/A") + else: + row.append("N/A") + table_data.append(row) + + columns = ["Resolution"] + scenario_names + table = ax.table( + cellText=table_data, colLabels=columns, cellLoc="center", loc="center" + ) + table.auto_set_font_size(False) + table.set_fontsize(10) + table.scale(1.2, 1.5) + + for i in range(len(columns)): + table[(0, i)].set_facecolor("#40466e") + table[(0, i)].set_text_props(weight="bold", color="white") + + plt.title("Memory Savings Summary Table", fontsize=14, pad=20) + plt.savefig(OUTPUT_DIR / "chroma_summary_table.png", dpi=300, bbox_inches="tight") + plt.close() + + +def check_csv_files() -> list[str]: + """Check which CSV files exist.""" + missing = [] + for name, _ in SCENARIOS: + csv_path = CSV_DIR / f"chroma_memory_{name}.csv" + if not csv_path.exists(): + missing.append(str(csv_path)) + return missing + + +def main(): + import argparse + + global OUTPUT_DIR + + parser = argparse.ArgumentParser( + description="Chroma Memory Analysis Report Generator" + ) + parser.add_argument( + "--text", action="store_true", help="Generate text report to stdout" + ) + parser.add_argument("--graphs", action="store_true", help="Generate PNG graphs") + parser.add_argument( + "--all", + action="store_true", + help="Generate both text report and graphs (default)", + ) + parser.add_argument( + "--output-dir", + type=str, + default=str(OUTPUT_DIR), + help=f"Output directory (default: {OUTPUT_DIR})", + ) + + args = parser.parse_args() + + do_text = args.text or args.all or not (args.text or args.graphs) + do_graphs = args.graphs or args.all + + OUTPUT_DIR = Path(args.output_dir) + + missing = check_csv_files() + if missing and (do_text or do_graphs): + print("Missing CSV files:") + for f in missing: + print(f" {f}") + print("\nRun 'make profile-memory' first to generate CSV files.") + sys.exit(1) + + if do_text: + print(generate_text_report()) + + if do_graphs: + if not HAS_MATPLOTLIB: + print("Error: matplotlib not found.") + print("Install with: pip install matplotlib numpy") + sys.exit(1) + + print("\nGenerating graphs...") + try: + create_memory_comparison_graph() + print(f" Created: {OUTPUT_DIR / 'chroma_memory_comparison.png'}") + + create_savings_graph() + print(f" Created: {OUTPUT_DIR / 'chroma_savings.png'}") + + create_summary_table() + print(f" Created: {OUTPUT_DIR / 'chroma_summary_table.png'}") + + print("\nGraph generation complete!") + except Exception as e: + print(f"Error generating graphs: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() From 000258df5c7d33095135ef826ae619307619d822 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 31 Jan 2026 15:23:05 +0300 Subject: [PATCH 07/10] chore: ignore valgrind artifacts Signed-off-by: NotAShelf Change-Id: I309e751b96e858f231e224d3b345670e6a6a6964 --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index aca924b..cb8e347 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ test_memory test_config *.jpg *.conf +vgcore.* +*_report.txt From e871307f6a9536ce9d7e95fe8157937ce4908c9a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 31 Jan 2026 15:23:19 +0300 Subject: [PATCH 08/10] chore: add more Make tasks for tests & benchmarks Signed-off-by: NotAShelf Change-Id: I5cabfcf1815588ffec6c8b865cd163176a6a6964 --- Makefile | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 0efc0bd..f26050d 100644 --- a/Makefile +++ b/Makefile @@ -131,11 +131,12 @@ clean: @echo "Cleaning build artifacts..." rm -rf $(OBJDIR) $(BINDIR) rm -f $(PROTOCOL_HEADERS) $(PROTOCOL_SOURCES) + rm -f "vcore.*" # Format source code (requires clang-format) format: @echo "Formatting source code..." - @find $(SRCDIR) $(INCDIR) -name "*.c" -o -name "*.h" | xargs clang-format -i + @find $(SRCDIR) -name "*.c" -o -name "*.h" | xargs clang-format -i # Static analysis (requires cppcheck) analyze: @@ -147,10 +148,33 @@ analyze: $(SRCDIR) # Run tests -# FIXME: add tests test: $(TARGET) - @echo "Running tests..." - @echo "Tests not implemented yet." + @echo "Running unit tests..." + @$(CC) -o bin/test tests/test.c lib/test_common.c \ + -I./include -I./tests -I./tests/util -lm -std=c11 -D_GNU_SOURCE $(CFLAGS) + @./bin/test + +# Run benchmarks +bench: + @echo "Running performance benchmarks..." + @$(CC) -o bin/bench benchmarks/bench.c lib/test_common.c \ + -I./include -I./tests -I./tests/util -lm -std=c11 -D_GNU_SOURCE $(CFLAGS) + @./bin/bench + +# Memory analysis tests +test-memory: + @echo "Building memory tests..." + @$(CC) -o bin/test tests/test.c lib/test_common.c \ + -I./include -I./tests -I./tests/util -lm -std=c11 -D_GNU_SOURCE $(CFLAGS) + @valgrind --leak-check=full --show-leak-kinds=all ./bin/test 2>&1 | tee tests/memory_report.txt + @echo "Analysis complete. See tests/memory_report.txt" + +# Generate memory profile CSVs +profile-memory: + @$(CC) -o bin/test tests/test.c lib/test_common.c \ + -I./include -I./tests -I./tests/util -lm -std=c11 -D_GNU_SOURCE $(CFLAGS) + @./bin/test --profile + @echo "CSV files generated in /tmp/" # Version management targets bump-patch: @@ -213,7 +237,7 @@ help: -include $(DEPENDS) # Phony targets -.PHONY: all debug static check-deps install uninstall systemd-service version-header sample-config clean distclean format analyze test help bump-patch bump-minor bump-major set-version +.PHONY: all debug static check-deps install uninstall systemd-service version-header clean distclean format analyze test test-memory memory-report help bump-patch bump-minor bump-major set-version # Print variables print-%: From 459989e896d7e6efdfad226a36551747855a7ac8 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 31 Jan 2026 15:23:50 +0300 Subject: [PATCH 09/10] config: add description-based output matching with `desc:` prefix Signed-off-by: NotAShelf Change-Id: Idbe0661f0c491512d61ace4337ebe8cd6a6a6964 --- include/chroma.h | 8 +++--- src/config.c | 63 +++++++++++++++++++++++++++++++++++++++--------- src/core.c | 7 +++--- 3 files changed, 60 insertions(+), 18 deletions(-) diff --git a/include/chroma.h b/include/chroma.h index 6d51f92..2402d45 100644 --- a/include/chroma.h +++ b/include/chroma.h @@ -250,11 +250,13 @@ void chroma_images_cleanup(chroma_state_t *state); int chroma_config_load(chroma_config_t *config, const char *config_file); void chroma_config_free(chroma_config_t *config); const char *chroma_config_get_image_for_output(chroma_config_t *config, - const char *output_name); + const char *output_name, + const char *output_description); int chroma_config_get_mapping_for_output( chroma_config_t *config, const char *output_name, - chroma_scale_mode_t *scale_mode, chroma_filter_quality_t *filter_quality, - chroma_anchor_t *anchor, float *anchor_x, float *anchor_y); + const char *output_description, chroma_scale_mode_t *scale_mode, + chroma_filter_quality_t *filter_quality, chroma_anchor_t *anchor, + float *anchor_x, float *anchor_y); void chroma_config_print(const chroma_config_t *config); diff --git a/src/config.c b/src/config.c index 3f713d3..ca47487 100644 --- a/src/config.c +++ b/src/config.c @@ -3,6 +3,7 @@ #include #include #include +#include #include "../include/chroma.h" @@ -27,6 +28,38 @@ static char *trim_whitespace(char *str) { } // Remove quotes from a string +// Match output name/description against a config pattern +// Supports: +// - Exact name match: "DP-1" matches wl_output.name == "DP-1" +// - Description prefix match: "desc:Samsung" matches if description starts +// with "Samsung" +static bool match_output(const char *pattern, const char *output_name, + const char *output_description) { + if (!pattern || !output_name) { + return false; + } + + // Try exact name match first + if (strcmp(pattern, output_name) == 0) { + return true; + } + + // Check for description prefix match: "desc:" + if (strncmp(pattern, "desc:", 5) == 0) { + const char *desc_prefix = pattern + 5; + size_t prefix_len = strlen(desc_prefix); + + if (output_description && prefix_len > 0) { + // Match if description starts with the prefix (case-insensitive) + if (strncasecmp(output_description, desc_prefix, prefix_len) == 0) { + return true; + } + } + } + + return false; +} + static char *remove_quotes(char *str) { size_t len = strlen(str); if (len >= 2 && ((str[0] == '"' && str[len - 1] == '"') || @@ -660,16 +693,19 @@ void chroma_config_free(chroma_config_t *config) { // Get image path for specific output const char *chroma_config_get_image_for_output(chroma_config_t *config, - const char *output_name) { + const char *output_name, + const char *output_description) { if (!config || !output_name) { return NULL; } - // Look for specific output mapping + // Look for specific output mapping (name or description match) for (int i = 0; i < config->mapping_count; i++) { - if (strcmp(config->mappings[i].output_name, output_name) == 0) { - chroma_log("DEBUG", "Found specific mapping for output %s: %s", - output_name, config->mappings[i].image_path); + if (match_output(config->mappings[i].output_name, output_name, + output_description)) { + chroma_log("DEBUG", "Found specific mapping for output %s (desc: %s): %s", + output_name, output_description ? output_description : "none", + config->mappings[i].image_path); return config->mappings[i].image_path; } } @@ -689,25 +725,28 @@ const char *chroma_config_get_image_for_output(chroma_config_t *config, // quality, anchor, and custom anchor coordinates int chroma_config_get_mapping_for_output( chroma_config_t *config, const char *output_name, - chroma_scale_mode_t *scale_mode, chroma_filter_quality_t *filter_quality, - chroma_anchor_t *anchor, float *anchor_x, float *anchor_y) { + const char *output_description, chroma_scale_mode_t *scale_mode, + chroma_filter_quality_t *filter_quality, chroma_anchor_t *anchor, + float *anchor_x, float *anchor_y) { if (!config || !output_name || !scale_mode || !filter_quality || !anchor || !anchor_x || !anchor_y) { return CHROMA_ERROR_INIT; } - // Look for specific output mapping + // Look for specific output mapping (name or description match) for (int i = 0; i < config->mapping_count; i++) { - if (strcmp(config->mappings[i].output_name, output_name) == 0) { + if (match_output(config->mappings[i].output_name, output_name, + output_description)) { *scale_mode = config->mappings[i].scale_mode; *filter_quality = config->mappings[i].filter_quality; *anchor = config->mappings[i].anchor; *anchor_x = config->mappings[i].anchor_x; *anchor_y = config->mappings[i].anchor_y; chroma_log("DEBUG", - "Found specific mapping for output %s: scale=%s, filter=%s, " - "anchor=%s @ %.1f,%.1f", - output_name, scale_mode_to_string(*scale_mode), + "Found specific mapping for output %s (desc: %s): scale=%s, " + "filter=%s, anchor=%s @ %.1f,%.1f", + output_name, output_description ? output_description : "none", + scale_mode_to_string(*scale_mode), filter_quality_to_string(*filter_quality), anchor_to_string(*anchor), *anchor_x, *anchor_y); return CHROMA_OK; diff --git a/src/core.c b/src/core.c index b7a5029..8e6f3a3 100644 --- a/src/core.c +++ b/src/core.c @@ -77,7 +77,8 @@ static int assign_wallpaper_to_output(chroma_state_t *state, // Get image path for this output const char *image_path = chroma_config_get_image_for_output( - &state->config, output->name ? output->name : "unknown"); + &state->config, output->name ? output->name : "unknown", + output->description); if (!image_path) { chroma_log("WARN", "No wallpaper configured for output %u (%s)", output->id, output->name ? output->name : "unknown"); @@ -121,8 +122,8 @@ static int assign_wallpaper_to_output(chroma_state_t *state, // anchor coords) if (chroma_config_get_mapping_for_output( &state->config, output->name ? output->name : "unknown", - &output->scale_mode, &output->filter_quality, &output->anchor, - &output->anchor_x, &output->anchor_y) == CHROMA_OK) { + output->description, &output->scale_mode, &output->filter_quality, + &output->anchor, &output->anchor_x, &output->anchor_y) == CHROMA_OK) { output->config_loaded = true; chroma_log("DEBUG", "Loaded config for output %u: scale=%d, filter=%d, anchor=%d @ " From c8d5637e25a7c155241125b1172c7a44a95a756b Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 16 Apr 2026 16:02:29 +0300 Subject: [PATCH 10/10] meta: update sample config for prefix syntax & output maching Signed-off-by: NotAShelf Change-Id: I4ef49c171e260fe8e1ea114a58af77166a6a6964 --- chroma.conf.sample | 42 ++++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/chroma.conf.sample b/chroma.conf.sample index d2b2c47..6afd464 100644 --- a/chroma.conf.sample +++ b/chroma.conf.sample @@ -52,7 +52,7 @@ anchor_x = 50 anchor_y = 50 # Image downsampling settings for performance optimization -# =================================================== +# ======================================================== # Enable automatic downsampling of large images to save memory and improve performance # Set to false to keep original resolution for all images (uses more memory!) enable_downsampling = true @@ -73,6 +73,21 @@ min_scale_factor = 0.25 # Don't scale below 25% of original size # ================================== # Basic format: output.OUTPUT_NAME = /path/to/image.ext # +# You can match outputs by name OR by description: +# output.DP-1 = /path/to/image.jpg # Match by port name +# output.desc:Samsung = /path/to/image.jpg # Match by description prefix +# +# The description is the human-readable name provided by the compositor +# via the Wayland wl_output description event. For example, if your +# monitor reports "Samsung T27A450" as its description, you can use +# "output.desc:Samsung" to match it. The match is a prefix match, so +# "output.desc:Sam" would also work. +# +# To find your output names and descriptions, run one of these commands: +# wlr-randr (for wlroots-based compositors) +# wayland-info | grep wl_output +# kanshi list-outputs +# # Extended format with per-output settings: # output.OUTPUT_NAME = /path/to/image.ext # output.OUTPUT_NAME.scale = fill|fit|stretch|center @@ -90,23 +105,6 @@ min_scale_factor = 0.25 # Don't scale below 25% of original size # bottom-left - image anchored to bottom-left corner # bottom-right - image anchored to bottom-right corner # -# To find your output names, run one of these commands: -# -# Compositor Agnostic: -# - wlr-randr (for wlroots-based compositors) -# - wayland-info | grep wl_output -# - kanshi list-outputs -# -# Compositor Specific -# - hyprctl monitors -j | jq .[].name (Hyprland specific) -# -# Common output name patterns: -# - DP-1, DP-2, DP-3, etc. (DisplayPort) -# - HDMI-A-1, HDMI-A-2, etc. (HDMI) -# - eDP-1 (embedded DisplayPort, laptops) -# - DVI-D-1, DVI-I-1 (DVI) -# - VGA-1 (VGA, legacy) -# # Examples: # output.HDMI-A-1 = ~/Pictures/wallpaper.jpg # output.DP-1 = ~/Pictures/monitor1.png @@ -115,6 +113,14 @@ min_scale_factor = 0.25 # Don't scale below 25% of original size # output.DP-1.anchor = top-left # output.DP-2 = ~/Pictures/monitor2.jpg # output.DP-2.scale = stretch +# +# # Match by monitor description (prefix match): +# output.desc:Samsung = ~/Pictures/samsung-wallpaper.jpg +# output.desc:Samsung.scale = fill +# output.desc:LG Ultra = ~/Pictures/lg-wallpaper.jpg +# output.desc:BenQ = ~/Pictures/benq-wallpaper.jpg +# +# # Laptop internal display: # output.eDP-1 = ~/Pictures/laptop-wallpaper.jpg # output.eDP-1.scale = fill # output.eDP-1.anchor = bottom-right