commit 4def485a6ddcccce652cc6f563092468be209c92 Author: Alessandro Mauri Date: Sun Oct 26 00:03:45 2025 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e151054 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +build +resources/shaders/compiled/** diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..5d493ad --- /dev/null +++ b/.gitmodules @@ -0,0 +1,12 @@ +[submodule "lib/ugui.c3l"] + path = lib/ugui.c3l + url = https://git.alemauri.eu/alema/ugui.c3l.git +[submodule "lib/schrift.c3l"] + path = lib/schrift.c3l + url = https://git.alemauri.eu/alema/schrift.c3l.git +[submodule "lib/ugui_sdl.c3l"] + path = lib/ugui_sdl.c3l + url = https://git.alemauri.eu/alema/ugui_sdl.c3l.git +[submodule "lib/sdl3.c3l"] + path = lib/sdl3.c3l + url = https://git.alemauri.eu/alema/sdl3.c3l.git diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..aaad554 --- /dev/null +++ b/Makefile @@ -0,0 +1,3 @@ +all: + make -C resources/shaders + c3c build -g \ No newline at end of file diff --git a/example-vm.json b/example-vm.json new file mode 100644 index 0000000..0781e5b --- /dev/null +++ b/example-vm.json @@ -0,0 +1,16 @@ +{ + "name" : "Alpine Linux", + "disk" : "$HOME/Documents/alpine.qcow2", + "memory" : "1G", + "processors" : "2", + "qemu" : "qemu-system-x86_64", + "parameters" : [ + "enable-kvm", + [ "net", "nic" ], + [ "net", "user" ], + [ "display", "sdl" ], + [ "vga", "qxl" ], + [ "monitor", "stdio" ], + [ "virtfs", "local,path=/home/ale/Documents/Projects/toughpad-alpine,mount_tag=shared,security_model=mapped-xattr,id=shared" ], + ] +} diff --git a/lib/schrift.c3l b/lib/schrift.c3l new file mode 160000 index 0000000..64d168f --- /dev/null +++ b/lib/schrift.c3l @@ -0,0 +1 @@ +Subproject commit 64d168f53b0fe70bda77e2ed7bec35f3f0f1af78 diff --git a/lib/sdl3.c3l b/lib/sdl3.c3l new file mode 160000 index 0000000..e7356df --- /dev/null +++ b/lib/sdl3.c3l @@ -0,0 +1 @@ +Subproject commit e7356df5d1d0c22a6bba822bda6994062b0b75d7 diff --git a/lib/ugui.c3l b/lib/ugui.c3l new file mode 160000 index 0000000..4f7fa7d --- /dev/null +++ b/lib/ugui.c3l @@ -0,0 +1 @@ +Subproject commit 4f7fa7d50c16db3acac020cdd7204e28d42efdf2 diff --git a/lib/ugui_sdl.c3l b/lib/ugui_sdl.c3l new file mode 160000 index 0000000..050624f --- /dev/null +++ b/lib/ugui_sdl.c3l @@ -0,0 +1 @@ +Subproject commit 050624fd67c2d80190114c7774ccfa64b0f449b9 diff --git a/project.json b/project.json new file mode 100644 index 0000000..73282da --- /dev/null +++ b/project.json @@ -0,0 +1,20 @@ +{ + "langrev": "1", + "warnings": ["no-unused"], + "dependency-search-paths": ["lib"], + "dependencies": ["ugui", "ugui_sdl3"], + "features": ["DEBUG_POINTER"], + "authors": ["Alessandro Mauri "], + "version": "0.1.0", + "sources": ["src/**"], + "output": "build", + "target": "linux-x64", + "targets": { + "ugui": { + "type": "executable" + } + }, + "safe": true, + "opt": "O1", + "debug-info": "full" +} diff --git a/resources/hack-nerd.ttf b/resources/hack-nerd.ttf new file mode 100644 index 0000000..f397f20 Binary files /dev/null and b/resources/hack-nerd.ttf differ diff --git a/resources/shaders/Makefile b/resources/shaders/Makefile new file mode 100644 index 0000000..cdc4a01 --- /dev/null +++ b/resources/shaders/Makefile @@ -0,0 +1,33 @@ +SOURCE_DIR := ./source +COMPILED_DIR := ./compiled + +SOURCE_FILES := $(wildcard $(SOURCE_DIR)/*.glsl) + +COMPILED_FILES := $(patsubst $(SOURCE_DIR)/%.glsl,$(COMPILED_DIR)/%.spv,$(SOURCE_FILES)) + +all: $(COMPILED_FILES) + @echo "Compiling shaders from $(SOURCE_DIR) -> $(COMPILED_DIR)" + +$(COMPILED_DIR)/%.spv: $(SOURCE_DIR)/%.glsl + @mkdir -p $(COMPILED_DIR) + @stage=$$(basename $< .glsl | cut -d. -f2); \ + if [ "$$stage" = "frag" ] || [ "$$stage" = "vert" ]; then \ + echo "$$stage $(notdir $<) > $(notdir $@)"; \ + glslc -O0 -g -fshader-stage=$$stage $< -o $@; \ + else \ + echo "Skipping $<: unsupported stage $$stage"; \ + fi + +$(COMPILED_DIR): + mkdir -p $(COMPILED_DIR) + +.PHONY: clean +clean: + rm -rf $(COMPILED_DIR) + +.PHONY: tree +tree: + tree $(COMPILED_DIR) + +.PHONY: compile_all +compile_all: clean all tree diff --git a/resources/shaders/source/ugui.frag.glsl b/resources/shaders/source/ugui.frag.glsl new file mode 100644 index 0000000..2618c2e --- /dev/null +++ b/resources/shaders/source/ugui.frag.glsl @@ -0,0 +1,118 @@ +#version 450 + +/* Combined fragment shader to render UGUI commands */ + +// type values, these are the same as in renderer.c3 +const uint TYPE_RECT = 0; +const uint TYPE_FONT = 1; +const uint TYPE_SPRITE = 2; +const uint TYPE_MSDF = 3; + +// viewport size +layout(set = 3, binding = 0) uniform Viewport { + ivec2 view; +}; + +// textures +layout(set = 2, binding = 0) uniform sampler2D font_atlas; +layout(set = 2, binding = 1) uniform sampler2D sprite_atlas; + +// inputs +layout(location = 0) in vec4 in_color; +layout(location = 1) in vec2 in_uv; +layout(location = 2) in vec4 in_quad_size; +layout(location = 3) in float in_radius; +layout(location = 4) flat in uint in_type; + +// outputs +layout(location = 0) out vec4 fragColor; + + +// SDF for a rounded rectangle given the centerpoint, half size and radius, all in pixels +float sdf_rr(vec2 p, vec2 half_size, float radius) { + vec2 q = abs(p) - half_size + radius; + return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius; +} + + +const float PX_RANGE = 4.0f; +float screen_px_range(vec2 uv, sampler2D tx) { + vec2 unit_range = vec2(PX_RANGE)/vec2(textureSize(tx, 0)); + vec2 texel_size = vec2(1.0)/fwidth(uv); + return max(0.5*dot(unit_range, texel_size), 1.0); +} + + +float median(float r, float g, float b) { + return max(min(r, g), min(max(r, g), b)); +} + + +// main for TYPE_RECT, draw a rouded rectangle with a SDF +void rect_main() +{ + vec2 centerpoint = in_quad_size.xy + in_quad_size.zw * 0.5; + vec2 half_size = in_quad_size.zw * 0.5; + float distance = sdf_rr(vec2(gl_FragCoord) - centerpoint, half_size, in_radius); + float alpha = 1.0 - smoothstep(0.0, 1.5, distance); + fragColor = vec4(in_color.rgb, in_color.a * alpha); +} + + +// main for TYPE_SPRITE, draws a sprite sampled from an atlas +void sprite_main() +{ + ivec2 ts = textureSize(sprite_atlas, 0); + vec2 fts = vec2(ts); + vec2 real_uv = in_uv.xy / fts; + fragColor = texture(sprite_atlas, real_uv); +} + + +// main for TYPE_FONT, draws a character sampled from an atlas that contains only the alpha channel +void font_main() +{ + ivec2 ts = textureSize(font_atlas, 0); + vec2 fts = vec2(ts); + vec2 real_uv = in_uv.xy / fts; + + vec4 opacity = texture(font_atlas, real_uv); + fragColor = vec4(in_color.rgb, in_color.a*opacity.r); +} + + +// main for TYPE_MSDF, draws a sprite that is stored as a multi-channel SDF +void msdf_main() { + ivec2 ts = textureSize(sprite_atlas, 0); + vec2 fts = vec2(ts); + vec2 real_uv = in_uv.xy / fts; + + vec3 msd = texture(sprite_atlas, real_uv).rgb; + float sd = median(msd.r, msd.g, msd.b); + float distance = screen_px_range(real_uv, sprite_atlas)*(sd - 0.5); + float opacity = clamp(distance + 0.5, 0.0, 1.0); + fragColor = in_color * opacity; +} + + +// shader main +void main() +{ + switch (in_type) { + case TYPE_RECT: + rect_main(); + break; + case TYPE_FONT: + font_main(); + break; + case TYPE_SPRITE: + sprite_main(); + break; + case TYPE_MSDF: + msdf_main(); + break; + default: + // ERROR, invalid type, return magenta + fragColor = vec4(1.0, 0.0, 1.0, 1.0); + } +} \ No newline at end of file diff --git a/resources/shaders/source/ugui.vert.glsl b/resources/shaders/source/ugui.vert.glsl new file mode 100644 index 0000000..ee29724 --- /dev/null +++ b/resources/shaders/source/ugui.vert.glsl @@ -0,0 +1,47 @@ +#version 450 + +/* Combined vertex shader to render UGUI commands */ + +// Viewport size in pixels +layout(set = 1, binding = 0) uniform Viewport { + ivec2 view; +}; + +// inputs +layout(location = 0) in ivec2 in_position; +layout(location = 1) in ivec4 in_attr; // quad x,y,w,h +layout(location = 2) in ivec4 in_uv; +layout(location = 3) in uvec4 in_color; +layout(location = 4) in uint in_type; + +// outputs +layout(location = 0) out vec4 out_color; +layout(location = 1) out vec2 out_uv; +layout(location = 2) out vec4 out_quad_size; +layout(location = 3) out float out_radius; +layout(location = 4) out uint out_type; + + +void main() +{ + // vertex position + ivec2 px_pos = in_attr.xy + in_position.xy * in_attr.zw; + vec2 clip_pos; + clip_pos.x = float(px_pos.x)*2.0 / view.x - 1.0; + clip_pos.y = -(float(px_pos.y)*2.0 / view.y - 1.0); + gl_Position = vec4(clip_pos, 0.0, 1.0); + + // color output + out_color = vec4(in_color) / 255.0; + + // uv output. only useful if the type is SPRITE + vec2 px_uv = in_uv.xy + in_position.xy * in_uv.zw; + out_uv = vec2(px_uv); + + // quad size and radius output, only useful if type is RECT + out_quad_size = vec4(in_attr); + out_radius = float(abs(in_uv.x)); + + // type output + out_type = in_type; +} \ No newline at end of file diff --git a/resources/style.css b/resources/style.css new file mode 100644 index 0000000..37e98d8 --- /dev/null +++ b/resources/style.css @@ -0,0 +1,88 @@ +div { + bg: #282828; + fg: #fbf1c7ff; + primary: #cc241dff; + secondary: #6c19ca8f; + accent: #fabd2fff; +} + +separator { + bg: #fbf1c7ff; + size: 1; + padding: 0 5 0 5; +} + +button { + margin: 2; + border: 2; + padding: 2; + radius: 10; + size: 32; + + bg: #3c3836ff; + fg: #fbf1c7ff; + primary: #cc241dff; + secondary: #458588ff; + accent: #504945ff; +} + +checkbox { + margin: 2; + border: 2; + padding: 1; + radius: 10; + size: 20; + bg: #3c3836ff; + fg: #fbf1c7ff; + primary: #cc241dff; + secondary: #458588ff; + accent: #fabd2fff; +} + +toggle { + margin: 2; + border: 2; + padding: 1; + radius: 10; + size: 20; + bg: #3c3836ff; + fg: #fbf1c7ff; + primary: #cc241dff; + secondary: #458588ff; + accent: #fabd2fff; +} + +slider { + margin: 2; + padding: 2; + border: 1; + radius: 4; + size: 8; + bg: #3c3836ff; + fg: #fbf1c7ff; + primary: #cc241dff; + secondary: #458588ff; + accent: #fabd2fff; +} + +scrollbar { + padding: 2; + size: 8; + bg: #45858842; + fg: #fbf1c7ff; + primary: #cc241dff; + secondary: #458588ff; + accent: #fabd2fff; +} + + +text-box { + bg: #4a4543ff; + fg: #fbf1c7ff; + primary: #cc241dff; + secondary: #458588ff; + accent: #8f3f61a8; + border: 1; + padding: 4; + margin: 2; +} diff --git a/resources/tick_sdf.qoi b/resources/tick_sdf.qoi new file mode 100644 index 0000000..51a8f81 Binary files /dev/null and b/resources/tick_sdf.qoi differ diff --git a/src/conf.c3 b/src/conf.c3 new file mode 100644 index 0000000..7e609f3 --- /dev/null +++ b/src/conf.c3 @@ -0,0 +1,85 @@ +import std::io; +import std::io::file; +import std::io::path; +import std::os::env; +import std::core::mem::allocator; +import std::collections::object; +import std::collections::list; + + +alias StrList = list::List{String}; + +fn Path? String.to_expanded_path(&str, Allocator allocator) +{ + @pool() { + StrList l; + l.tinit(); + Path str_path = str.to_tpath()!; + + l.push(str_path.basename()); + Path? p = str_path.parent(); + while (true) { + Path? x; + if (try p) { + String n = p.basename(); + + // env variable + if (n.starts_with("$")) { + l.push(env::tget_var(n[1..]))!; + } else { + l.push(n); + } + + x = p.parent(); + } else { + break; + } + p = x; + } + + Path path = path::temp("")!; + foreach_r (idx, s: l) { + path = path.tappend(s)!; + } + + return path::new(allocator, path.str_view(), path.env); + }; +} + +fn StrList get_qemu_cmdline(Allocator allocator, Object* conf) +{ + // parse the disk path + String disk = conf.get_string("disk")!!; + Path disk_path = disk.to_expanded_path(mem)!!; + defer disk_path.free(); + + // compose the command line for the vm + StrList cmd; + cmd.init(allocator); + + // first the executable + cmd.push(conf.get_string("qemu"))!!; + // then the drive + cmd.push("-drive"); + cmd.push(string::format(allocator, "file=%s", disk_path.str_view())); + //cmd.push(string::format(allocator, "file=%s,format=%s", disk_path.str_view(), disk_path.extension()))!!; + // memory + cmd.push("-m"); + cmd.push(conf.get_string("memory"))!!; + // processors + cmd.push("-smp"); + cmd.push(conf.get_string("processors") ?? "1"); + // then all the parameters + Object* parameters = conf.get("parameters")!!; + for (usz i = 0; i < parameters.get_len(); i++) { + Object* p = parameters.get_at(i); + if (p.is_string()) { + cmd.push(string::format(allocator, "-%s", p.s)); + } else if (p.is_indexable()) { + cmd.push(string::format(allocator, "-%s", p.get_string_at(0)))!!; + cmd.push(p.get_string_at(1))!!; + } + } + + return cmd; +} diff --git a/src/main.c3 b/src/main.c3 new file mode 100644 index 0000000..e13b210 --- /dev/null +++ b/src/main.c3 @@ -0,0 +1,146 @@ +import std::io; +import std::io::file; +import std::collections::object; +import std::os::process; +import std::encoding::json; +import std::time; + +import conf; +import ugui; +import ugui::sdl::ren; + + +const char[*] VS_PATH = "resources/shaders/compiled/ugui.vert.spv"; +const char[*] FS_PATH = "resources/shaders/compiled/ugui.frag.spv"; + +const char[*] STYLESHEET_PATH = "resources/style.css"; + +const bool LIMIT_FPS = true; +const bool VSYNC = true; + + +fn int main(String[] args) +{ + String path = args[1]; + File file = file::open(path, "r")!!; + + // load configuration in memory as an object + Object* conf = json::parse(mem, &file)!!; + defer conf.free(); + + // UI initialization + ArenaAllocator arena; + char[] arena_mem = mem::new_array(char, 1024*1024); + defer (void)mem::free(arena_mem); + arena.init(arena_mem); + + ugui::Ctx ui; + ui.init(&arena)!!; + defer ui.free(); + + ren::Renderer ren; + ren.init("Qemu Manager", 800, 600, VSYNC); + defer ren.free(); + ui.input_window_size(800, 600)!!; + + ui.load_font(&arena, "font1", "resources/hack-nerd.ttf", 16)!!; + ren.font_atlas_id = ui.get_font_id("font1"); + Atlas* font_atlas = ui.get_font_atlas("font1")!!; + ren.new_texture("font1", JUST_ALPHA, font_atlas.buffer, font_atlas.width, font_atlas.height); + + ui.sprite_atlas_create("icons", AtlasType.ATLAS_R8G8B8A8, 512, 512)!!; + ui.import_sprite_file_qoi("tick", "resources/tick_sdf.qoi", SpriteType.SPRITE_MSDF)!!; + ren.sprite_atlas_id = ui.get_sprite_atlas_id("icons"); + Atlas* sprite_atlas = &(ui.sprite_atlas.atlas); + ren.new_texture("icons", FULL_COLOR, sprite_atlas.buffer, sprite_atlas.width, sprite_atlas.height); + + ren.load_spirv_shader_from_file("UGUI_PIPELINE", VS_PATH, FS_PATH, 2, 0); + ren.create_pipeline("UGUI_PIPELINE", RECT); + + ui.import_style_from_file(STYLESHEET_PATH); + ren::pre(ren.win); + // End UI initialization + + StrList cmd = conf::get_qemu_cmdline(mem, conf); + defer cmd.free(); + String vm_name = conf.get_string("name")!!; + String vm_disk = conf.get_string("disk").to_expanded_path(tmem).str_view()!!; + String vm_disk_size = bytes_to_human_readable(tmem, file::get_size(vm_disk))!!; + + bool vm_on = false; + SubProcess vm_proc; + defer (void)vm_proc.join(); + + bool quit; + Clock sleep_clock; + while (!quit) { + sleep_clock.mark(); + quit = ui.handle_events()!!; + vm_on = vm_proc.is_running()!!; + + /* Start UI Handling */ + ui.frame_begin()!!; + + if (ui.check_key_combo(ugui::KMOD_CTRL, "q")) quit = true; + + ui.@div(ugui::@grow(), ugui::@grow(), COLUMN) { + ui.@div(ugui::@grow(), ugui::@fit(20)) { + ui.text("Machine:")!!; + ui.separator(ugui::@grow(), ugui::@grow())!!; + ui.text(vm_name)!!; + }!!; + ui.hor_line()!!; + ui.@div(ugui::@grow(), ugui::@grow(), COLUMN, scroll_y: true) { + ui.text(string::tformat("disk: %s (%s)", vm_disk, vm_disk_size))!!; + ui.text(string::tformat("runner: %s", conf.get_string("qemu")))!!; + ui.text(string::tformat("memory: %s", conf.get_string("memory")))!!; + ui.text(string::tformat("processors: %s", conf.get_string("processors")))!!; + }!!; + ui.hor_line()!!; + ui.@div(ugui::@grow(), ugui::@fit(), ROW) { + if (!vm_on) { + if (ui.button("START")!!.mouse_release) { + vm_proc = process::create(cmd.array_view(), {.inherit_stdio=true, .inherit_environment=true})!!; + vm_on = true; + } + } else { + if (ui.button("STOP")!!.mouse_release) { + vm_proc.terminate()!!; + vm_on = false; + } + } + }!!; + }!!; + + ui.frame_end()!!; + /* End UI Handling */ + + /* Start UI Drawing */ + ren.begin_render(true); + ren.render_ugui(&ui.cmd_queue); + ren.end_render(); + /* End Drawing */ + + // wait for the next event, timeout after 100ms + int timeout = LIMIT_FPS ? (int)(100.0-sleep_clock.mark().to_ms()-0.5) : 0; + if (ui.skip_frame) timeout = 0; + ren::wait_events(timeout); + } + + return 0; +} + + +fn String bytes_to_human_readable(Allocator allocator, usz bytes, int dp = 1) { + static usz thresh = 1024; + static String[] units = {"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"}; + + double b = bytes; + int u = 0; + for (; b > thresh && u < units.len; u++) { + b /= thresh; + } + + return string::format(allocator, "%.*f %s", dp, b, units[u]); +} +