From badd3b8961c7a65b689c46fd0a8749be808fa638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81d=C3=A1m=20Kov=C3=A1cs?= Date: Thu, 18 Sep 2025 09:22:52 +0200 Subject: [PATCH] feat: base project --- build.zig | 77 +++++++++++++++ build.zig.zon | 86 +++++++++++++++++ src/main.zig | 263 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 426 insertions(+) create mode 100644 build.zig create mode 100644 build.zig.zon create mode 100644 src/main.zig diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..868ca8f --- /dev/null +++ b/build.zig @@ -0,0 +1,77 @@ +const std = @import("std"); + +// Although this function looks imperative, note that its job is to +// declaratively construct a build graph that will be executed by an external +// runner. +pub fn build(b: *std.Build) void { + // Standard target options allows the person running `zig build` to choose + // what target to build for. Here we do not override the defaults, which + // means any target is allowed, and the default is native. Other options + // for restricting supported target set are available. + const target = b.standardTargetOptions(.{}); + + // Standard optimization options allow the person running `zig build` to select + // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not + // set a preferred release mode, allowing the user to decide how to optimize. + const optimize = b.standardOptimizeOption(.{}); + + + // We will also create a module for our other entry point, 'main.zig'. + const exe_mod = b.createModule(.{ + // `root_source_file` is the Zig "entry point" of the module. If a module + // only contains e.g. external object files, you can make this `null`. + // In this case the main source file is merely a path, however, in more + // complicated build scripts, this could be a generated file. + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + + // This creates another `std.Build.Step.Compile`, but this one builds an executable + // rather than a static library. + const exe = b.addExecutable(.{ + .name = "icon_searcher", + .root_module = exe_mod, + }); + + // This declares intent for the executable to be installed into the + // standard location when the user invokes the "install" step (the default + // step when running `zig build`). + b.installArtifact(exe); + + // This *creates* a Run step in the build graph, to be executed when another + // step is evaluated that depends on it. The next line below will establish + // such a dependency. + const run_cmd = b.addRunArtifact(exe); + + // By making the run step depend on the install step, it will be run from the + // installation directory rather than directly from within the cache directory. + // This is not necessary, however, if the application depends on other installed + // files, this ensures they will be present and in the expected location. + run_cmd.step.dependOn(b.getInstallStep()); + + // This allows the user to pass arguments to the application in the build + // command itself, like this: `zig build run -- arg1 arg2 etc` + if (b.args) |args| { + run_cmd.addArgs(args); + } + + // This creates a build step. It will be visible in the `zig build --help` menu, + // and can be selected like this: `zig build run` + // This will evaluate the `run` step rather than the default, which is "install". + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + const exe_unit_tests = b.addTest(.{ + .root_module = exe_mod, + }); + + const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); + + // Similar to creating the run step earlier, this exposes a `test` step to + // the `zig build --help` menu, providing a way for the user to request + // running the unit tests. + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_exe_unit_tests.step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..ce669d4 --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,86 @@ +.{ + // This is the default name used by packages depending on this one. For + // example, when a user runs `zig fetch --save `, this field is used + // as the key in the `dependencies` table. Although the user can choose a + // different name, most users will stick with this provided value. + // + // It is redundant to include "zig" in this name because it is already + // within the Zig package namespace. + .name = .icon_searcher, + + // This is a [Semantic Version](https://semver.org/). + // In a future version of Zig it will be used for package deduplication. + .version = "0.0.0", + + // Together with name, this represents a globally unique package + // identifier. This field is generated by the Zig toolchain when the + // package is first created, and then *never changes*. This allows + // unambiguous detection of one package being an updated version of + // another. + // + // When forking a Zig project, this id should be regenerated (delete the + // field and run `zig build`) if the upstream project is still maintained. + // Otherwise, the fork is *hostile*, attempting to take control over the + // original project's identity. Thus it is recommended to leave the comment + // on the following line intact, so that it shows up in code reviews that + // modify the field. + .fingerprint = 0xa0a27b01ba3dadd, // Changing this has security and trust implications. + + // Tracks the earliest Zig version that the package considers to be a + // supported use case. + .minimum_zig_version = "0.14.0", + + // This field is optional. + // Each dependency must either provide a `url` and `hash`, or a `path`. + // `zig build --fetch` can be used to fetch all dependencies of a package, recursively. + // Once all dependencies are fetched, `zig build` no longer requires + // internet connectivity. + .dependencies = .{ + // See `zig fetch --save ` for a command-line interface for adding dependencies. + //.example = .{ + // // When updating this field to a new URL, be sure to delete the corresponding + // // `hash`, otherwise you are communicating that you expect to find the old hash at + // // the new URL. If the contents of a URL change this will result in a hash mismatch + // // which will prevent zig from using it. + // .url = "https://example.com/foo.tar.gz", + // + // // This is computed from the file contents of the directory of files that is + // // obtained after fetching `url` and applying the inclusion rules given by + // // `paths`. + // // + // // This field is the source of truth; packages do not come from a `url`; they + // // come from a `hash`. `url` is just one of many possible mirrors for how to + // // obtain a package matching this `hash`. + // // + // // Uses the [multihash](https://multiformats.io/multihash/) format. + // .hash = "...", + // + // // When this is provided, the package is found in a directory relative to the + // // build root. In this case the package's hash is irrelevant and therefore not + // // computed. This field and `url` are mutually exclusive. + // .path = "foo", + // + // // When this is set to `true`, a package is declared to be lazily + // // fetched. This makes the dependency only get fetched if it is + // // actually used. + // .lazy = false, + //}, + }, + + // Specifies the set of files and directories that are included in this package. + // Only files and directories listed here are included in the `hash` that + // is computed for this package. Only files listed here will remain on disk + // when using the zig package manager. As a rule of thumb, one should list + // files required for compilation plus any license(s). + // Paths are relative to the build root. Use the empty string (`""`) to refer to + // the build root itself. + // A directory listed here means that all files within, recursively, are included. + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + // For example... + //"LICENSE", + //"README.md", + }, +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..26a262c --- /dev/null +++ b/src/main.zig @@ -0,0 +1,263 @@ +const FindAppIconContext = struct { + allocator: std.mem.Allocator, + apps_with_possible_icons: []AppWithPossiblyIcons, + apps_with_found_icons: std.ArrayList(AppWithFoundIcon), +}; +const AppWithPossiblyIcons = struct { + app_id: []const u8, + possible_icons: []PossibleIcon, +}; + +const AppWithFoundIcon = struct { + appId: []const u8, + icon: []const u8, +}; + +const PossibleIcon = struct { + icon: []const u8, + possible_filenames: std.ArrayList([]const u8), +}; + +pub fn main() !u8 { + var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; + const allocator = gpa.allocator(); + + const niri_window_result = blk: { + const niri_windows_proc = try std.process.Child.run(.{ + .argv = &.{ "niri", "msg", "--json", "windows" }, + .allocator = allocator, + }); + defer allocator.free(niri_windows_proc.stdout); + defer allocator.free(niri_windows_proc.stderr); + + const niri_window_result = try std.json.parseFromSlice([]NiriWindowsResult, allocator, niri_windows_proc.stdout, .{}); + break :blk niri_window_result; + }; + + const selected_index = blk: { + var fuzzel_proc = std.process.Child.init( + &.{ "fuzzel", "--counter", "--dmenu", "--index" }, + allocator, + ); + + fuzzel_proc.stdin_behavior = .Pipe; + fuzzel_proc.stdout_behavior = .Pipe; + try fuzzel_proc.spawn(); + + var possible_icons: std.ArrayList(AppWithPossiblyIcons) = .empty; + window_loop: for (niri_window_result.value) |niri_window| { + for (possible_icons.items) |i| { + if (std.mem.eql(u8, i.app_id, niri_window.app_id)) continue :window_loop; + } + + var possible_icons_for_app = std.ArrayList(PossibleIcon).empty; + + var iter = std.mem.splitBackwardsScalar(u8, niri_window.app_id, '.'); + + var possible_icon = iter.next() orelse continue; + possible_icon = try allocator.dupe(u8, possible_icon); + try possible_icons_for_app.insert(allocator, 0, .{ + .icon = possible_icon, + .possible_filenames = try getFileNamesForIcon(possible_icon, allocator), + }); + while (iter.next()) |s| { + possible_icon = try std.fmt.allocPrint(allocator, "{s}.{s}", .{ s, possible_icon }); + + try possible_icons_for_app.insert(allocator, 0, .{ + .icon = possible_icon, + .possible_filenames = try getFileNamesForIcon(possible_icon, allocator), + }); + } + + try possible_icons.append(allocator, .{ + .app_id = niri_window.app_id, + .possible_icons = try possible_icons_for_app.toOwnedSlice(allocator), + }); + } + + const icon_for_appIds = try findIcons(possible_icons, allocator); + + for (niri_window_result.value) |niri_window| { + const icon = icon: for (icon_for_appIds) |icon_for_appid| { + if (std.mem.eql(u8, icon_for_appid.appId, niri_window.app_id)) break :icon icon_for_appid.icon; + } else niri_window.app_id; + + const txt = std.fmt.allocPrint( + allocator, + "{s} \x00icon\x1f{s}\n", + .{ niri_window.title, icon }, + ) catch continue; + defer allocator.free(txt); + + _ = fuzzel_proc.stdin.?.write(txt) catch continue; + } + + fuzzel_proc.stdin.?.close(); + fuzzel_proc.stdin = null; + + var fuzzel_read_buffer: [10]u8 = undefined; + const size = try fuzzel_proc.stdout.?.read(&fuzzel_read_buffer); + const trimmed_selected_index_string = std.mem.trimRight(u8, fuzzel_read_buffer[0..size], &.{'\n'}); + const selected_index = std.fmt.parseInt(u8, trimmed_selected_index_string, 10) catch |e| { + std.log.err("could not parse the selected index {}", .{e}); + return 1; + }; + + _ = try fuzzel_proc.wait(); + break :blk selected_index; + }; + + const selected_app = niri_window_result.value[selected_index]; + + var stdout_buffer: [1024]u8 = undefined; + var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); + const stdout = &stdout_writer.interface; + _ = try stdout.write(try std.fmt.allocPrint(allocator, "{d}\n", .{selected_app.id})); + + return 0; +} + +fn getFileNamesForIcon(icon: []const u8, allocator: std.mem.Allocator) !std.ArrayList([]const u8) { + var file_names: std.ArrayList([]const u8) = .empty; + try file_names.append(allocator, try std.fmt.allocPrint(allocator, "{s}.svg", .{icon})); + try file_names.append(allocator, try std.fmt.allocPrint(allocator, "{s}.png", .{icon})); + + return file_names; +} + +fn findIcons(possible_icons_for_apps: std.ArrayList(AppWithPossiblyIcons), allocator: std.mem.Allocator) ![]AppWithFoundIcon { + const icon_dirs: std.ArrayList([]const u8) = try init_icon_dirs(allocator); + + var context: FindAppIconContext = .{ + .allocator = allocator, + .apps_with_possible_icons = possible_icons_for_apps.items, + .apps_with_found_icons = .empty, + }; + + for (icon_dirs.items) |icon_dir| { + const icons_dir = try std.fmt.allocPrint(allocator, "{s}/icons", .{icon_dir}); + var dir = std.fs.cwd().openDir(icons_dir, .{ .iterate = true }) catch continue; + defer dir.close(); + + try findIconsInner( + allocator, + dir, + icons_dir, + &context, + ); + } + + return context.apps_with_found_icons.toOwnedSlice(allocator); +} + +fn findIconsInner( + allocator: std.mem.Allocator, + dir: std.fs.Dir, + icon_dir: []const u8, + context: *FindAppIconContext, +) !void { + var dir_iter = dir.iterate(); + while (try dir_iter.next()) |item| { + // We found all icons, no need to travers further + if (context.apps_with_found_icons.items.len == context.apps_with_possible_icons.len) return; + + if (item.kind == .directory) { + const inner_path = try std.fmt.allocPrint(context.allocator, "{s}/{s}", .{ icon_dir, item.name }); + var inner_dir = std.fs.cwd().openDir(inner_path, .{ .iterate = true }) catch continue; + defer inner_dir.close(); + try findIconsInner(allocator, inner_dir, inner_path, context); + } else { + app_loop: for (context.apps_with_possible_icons) |app_with_possible_icons| { + for (context.apps_with_found_icons.items) |found_app| { + if (std.mem.eql(u8, found_app.appId, app_with_possible_icons.app_id)) continue :app_loop; + } + + for (app_with_possible_icons.possible_icons) |possible_icon| { + for (possible_icon.possible_filenames.items) |file_name| { + if (std.mem.eql(u8, item.name, file_name)) { + try context.apps_with_found_icons.append(allocator, .{ + .appId = app_with_possible_icons.app_id, + .icon = possible_icon.icon, + }); + continue :app_loop; + } + } + } + } + } + } +} + +fn init_icon_dirs(allocator: std.mem.Allocator) !std.ArrayList([]const u8) { + // BASES ON FUZZEL'S ICON DIRECTORY FINDING + var dirs: std.ArrayList([]const u8) = .empty; + + var env_map = try std.process.getEnvMap(allocator); + defer env_map.deinit(); + + { + const xdg_data_home: ?[]const u8 = env_map.get("XDG_DATA_HOME") orelse home: { + const home = env_map.get("HOME"); + if (home) |h| { + break :home try std.fmt.allocPrint(allocator, "{s}/.local/share", .{h}); + } + break :home null; + }; + if (xdg_data_home) |h| { + try appendIfNotExist(allocator, &dirs, h); + } + } + + { + const xdg_data_dirs = env_map.get("XDG_DATA_DIRS"); + if (xdg_data_dirs) |d| { + var data_dirs_iter = std.mem.splitScalar(u8, d, ':'); + while (data_dirs_iter.next()) |data_dir| { + try appendIfNotExist(allocator, &dirs, try allocator.dupe(u8, data_dir)); + } + } else { + try appendIfNotExist(allocator, &dirs, "/usr/local/share"); + try appendIfNotExist(allocator, &dirs, "/usr/share"); + } + } + + { + const home = env_map.get("HOME"); + if (home) |h| { + try appendIfNotExist(allocator, &dirs, try std.fmt.allocPrint(allocator, "{s}/.icons", .{h})); + } + } + + { + try appendIfNotExist(allocator, &dirs, "/usr/share/pixmaps"); + try appendIfNotExist(allocator, &dirs, "/usr/local/share/pixmaps"); + } + + return dirs; +} + +fn appendIfNotExist(allocator: std.mem.Allocator, list: *std.ArrayList([]const u8), item: []const u8) !void { + if (item.len == 0) return; + + for (list.items) |i| { + if (std.mem.eql(u8, item, i)) return; + } + + var dir = std.fs.cwd().openDir(item, .{}) catch return; + dir.close(); + + try list.append(allocator, item); +} + +const NiriWindowsResult = struct { + id: u32, + title: []u8, + app_id: []u8, + pid: u32, + workspace_id: u32, + is_focused: bool, + is_floating: bool, + is_urgent: bool, +}; + +const std = @import("std");