//! zmen: app-launcher using XCB and Cairo with ghost text completion
//! Dependencies: libc, xcb, cairo, X11 keysym
//! install dependencies with:
//! sudo apt-get install libxcb1-dev libcairo2-dev libxcb-keysyms1-dev
//! build and run with:
//! zig build-exe main.zig -lc -lxcb -lcairo -lxcb-keysyms -lX11 && ./zmen

const std = @import("std");
const c = @import("c.zig");
const config = @import("config.zig");

const Input = struct {
    text: [config.max_text_len]u8 = [_]u8{0} ** config.max_text_len,
    len: usize = 0,
    cursor: usize = 0,
    completion: ?[]const u8 = null,

    fn slice(self: *const Input) []const u8 {
        return self.text[0..self.len];
    }

    fn insert(self: *Input, char: u8) bool {
        if (self.len >= config.max_text_len - 1) {
            return false;
        }

        std.mem.copyBackwards(
            u8,
            self.text[self.cursor + 1 .. self.len + 1],
            self.text[self.cursor..self.len],
        );

        self.text[self.cursor] = char;
        self.len += 1;
        self.cursor += 1;
        self.text[self.len] = 0;
        return true;
    }

    fn append_completion(self: *Input) bool {
        const completion = self.completion orelse return false;
        if (completion.len <= self.len) {
            return false;
        }

        const suffix = completion[self.len..];
        const to_copy = @min(suffix.len, config.max_text_len - 1 - self.len);
        if (to_copy == 0) {
            return false;
        }

        @memcpy(self.text[self.len .. self.len + to_copy], suffix[0..to_copy]);
        self.len += to_copy;
        self.cursor = self.len;
        self.text[self.len] = 0;
        return true;
    }

    fn accept_completion_char(self: *Input) bool {
        const completion = self.completion orelse return false;
        if (completion.len <= self.len) {
            return false;
        }

        return self.insert(completion[self.len]);
    }

    fn delete_before_cursor(self: *Input) bool {
        if (self.cursor == 0) {
            return false;
        }

        std.mem.copyForwards(
            u8,
            self.text[self.cursor - 1 .. self.len - 1],
            self.text[self.cursor..self.len],
        );

        self.len -= 1;
        self.cursor -= 1;
        self.text[self.len] = 0;
        return true;
    }

    fn delete_at_cursor(self: *Input) bool {
        if (self.cursor >= self.len) {
            return false;
        }

        std.mem.copyForwards(
            u8,
            self.text[self.cursor .. self.len - 1],
            self.text[self.cursor + 1 .. self.len],
        );

        self.len -= 1;
        self.text[self.len] = 0;
        return true;
    }

    fn clear(self: *Input) void {
        self.len = 0;
        self.cursor = 0;
        self.completion = null;
        self.text[0] = 0;
    }
};

const Commands = struct {
    const Entry = struct {
        off: usize,
        len: usize,
    };

    names: [config.command_buffer_size]u8 = undefined,
    names_len: usize = 0,
    items: [config.max_commands]Entry = undefined,
    len: usize = 0,

    fn init() !Commands {
        var commands = Commands{};
        try commands.load();
        return commands;
    }

    fn deinit(_: *Commands) void {}

    fn complete(self: *const Commands, input: []const u8) ?[]const u8 {
        if (input.len == 0) {
            return null;
        }

        for (self.items[0..self.len]) |entry| {
            const cmd = self.entry_name(entry);
            if (cmd.len < input.len) {
                continue;
            }

            var i: usize = 0;
            while (i < input.len) : (i += 1) {
                if (to_lower(input[i]) != to_lower(cmd[i])) {
                    break;
                }
            }

            if (i == input.len) {
                return cmd;
            }
        }

        return null;
    }

    fn load(self: *Commands) !void {
        const default_path: [:0]const u8 = "/bin:/usr/bin";
        const path_z: [:0]const u8 = std.posix.getenvZ("PATH") orelse default_path;
        const path: []const u8 = path_z[0..path_z.len];

        var it = std.mem.splitScalar(u8, path, ':');
        while (it.next()) |dir_path| {
            if (dir_path.len == 0) continue;
            try self.scan_dir(dir_path);
        }

        std.sort.heap(Entry, self.items[0..self.len], self, less_than);
    }

    fn scan_dir(self: *Commands, dir_path: []const u8) !void {
        var dirz: [std.posix.PATH_MAX:0]u8 = undefined;
        if (dir_path.len >= dirz.len) return;
        @memcpy(dirz[0..dir_path.len], dir_path);
        dirz[dir_path.len] = 0;

        const dir = c.opendir(&dirz);
        if (dir == null) return;
        defer _ = c.closedir(dir);

        var full: [std.posix.PATH_MAX:0]u8 = undefined;

        while (true) {
            const entry = c.readdir(dir);
            if (entry == null) break;

            const filename = std.mem.sliceTo(&entry.*.d_name, 0);
            if (filename.len == 0 or filename[0] == '.') continue;

            switch (entry.*.d_type) {
                c.DT_DIR => continue,
                c.DT_REG, c.DT_LNK, c.DT_UNKNOWN => {},
                else => continue,
            }

            const need = dir_path.len + 1 + filename.len;
            if (need >= full.len) continue;

            @memcpy(full[0..dir_path.len], dir_path);
            full[dir_path.len] = '/';
            @memcpy(full[dir_path.len + 1 .. need], filename);
            full[need] = 0;

            const fullz: [:0]const u8 = full[0..need :0];
            std.posix.access(fullz, std.posix.X_OK) catch continue;

            self.append(filename);
        }
    }

    fn append(self: *Commands, name: []const u8) void {
        if (self.len >= self.items.len) {
            return;
        }

        const need = name.len + 1;
        if (self.names_len + need > self.names.len) {
            return;
        }

        const off = self.names_len;
        @memcpy(self.names[off .. off + name.len], name);
        self.names[off + name.len] = 0;
        self.names_len += need;

        self.items[self.len] = .{ .off = off, .len = name.len };
        self.len += 1;
    }

    fn entry_name(self: *const Commands, entry: Entry) []const u8 {
        return self.names[entry.off .. entry.off + entry.len];
    }

    fn less_than(self: *Commands, a: Entry, b: Entry) bool {
        return std.mem.lessThan(u8, self.entry_name(a), self.entry_name(b));
    }
};

const Ui = struct {
    conn: *c.xcb_connection_t,
    window: c.xcb_window_t,
    surface: *c.cairo_surface_t,
    cr: *c.cairo_t,
    key_symbols: *c.xcb_key_symbols_t,
    width: u16,
    height: u16,
    space_width: f64 = 0.0,

    fn init() !Ui {
        var screen_num: c_int = undefined;
        const conn = c.xcb_connect(null, &screen_num);
        if (conn == null or c.xcb_connection_has_error(conn) != 0) {
            return error.ConnectionFailed;
        }

        const screen = get_screen(conn.?, screen_num);
        const width: u16 = screen.*.width_in_pixels / 4;
        const height: u16 = config.bh;
        const x: i16 = @intCast((screen.*.width_in_pixels - width) / 2);
        const y: i16 = @intCast((screen.*.height_in_pixels - height) / 2);

        const window = create_window(conn.?, screen, x, y, width, height);
        try set_window_properties(conn.?, screen, window);

        const visual = get_visual(screen, screen.*.root_visual) orelse return error.VisualNotFound;
        const surface = c.cairo_xcb_surface_create(conn, window, visual, width, height);
        if (surface == null) {
            return error.CairoSurfaceCreationFailed;
        }

        const cr = c.cairo_create(surface);
        if (cr == null) {
            return error.CairoContextCreationFailed;
        }

        const key_symbols = c.xcb_key_symbols_alloc(conn);
        if (key_symbols == null) {
            return error.KeySymbolsAllocationFailed;
        }

        _ = c.xcb_map_window(conn, window);
        _ = c.xcb_set_input_focus(conn, c.XCB_INPUT_FOCUS_POINTER_ROOT, window, c.XCB_CURRENT_TIME);
        _ = c.xcb_flush(conn);

        var ui = Ui{
            .conn = conn.?,
            .window = window,
            .surface = surface.?,
            .cr = cr.?,
            .key_symbols = key_symbols.?,
            .width = width,
            .height = height,
        };
        ui.measure_space_width();
        return ui;
    }

    fn deinit(self: *Ui) void {
        c.cairo_destroy(self.cr);
        c.cairo_surface_destroy(self.surface);
        c.xcb_key_symbols_free(self.key_symbols);
        c.xcb_disconnect(self.conn);
    }

    fn draw(self: *Ui, input: *const Input) void {
        self.set_font();

        c.cairo_set_source_rgb(self.cr, config.colors.background[0], config.colors.background[1], config.colors.background[2]);
        c.cairo_paint(self.cr);

        var prompt_extents: c.cairo_text_extents_t = undefined;
        c.cairo_text_extents(self.cr, config.prompt, &prompt_extents);

        var font_extents: c.cairo_font_extents_t = undefined;
        c.cairo_font_extents(self.cr, &font_extents);

        const bar_height = @as(f64, @floatFromInt(self.height));
        const y_pos = (bar_height / 2.0) + (font_extents.ascent - font_extents.descent) / 2.0;
        const prompt_x = 8.0;
        const prompt_width = prompt_extents.width + 16.0;
        const input_slice = input.slice();
        const input_width = self.text_width(input_slice);

        c.cairo_set_source_rgb(self.cr, config.colors.selected[0], config.colors.selected[1], config.colors.selected[2]);
        c.cairo_move_to(self.cr, prompt_x, y_pos);
        c.cairo_show_text(self.cr, config.prompt);

        c.cairo_set_source_rgb(self.cr, config.colors.foreground[0], config.colors.foreground[1], config.colors.foreground[2]);
        c.cairo_move_to(self.cr, prompt_width, y_pos);
        c.cairo_show_text(self.cr, @ptrCast(input_slice));

        if (input.completion) |completion| {
            if (completion.len > input.len) {
                const ghost_text = completion[input.len..];
                c.cairo_set_source_rgb(self.cr, config.colors.ghost[0], config.colors.ghost[1], config.colors.ghost[2]);
                c.cairo_move_to(self.cr, prompt_width + input_width, y_pos);
                c.cairo_show_text(self.cr, @ptrCast(ghost_text));
            }
        }

        const cursor_x = prompt_width + self.text_width(input.text[0..input.cursor]);
        c.cairo_set_source_rgb(self.cr, config.colors.foreground[0], config.colors.foreground[1], config.colors.foreground[2]);
        c.cairo_rectangle(self.cr, cursor_x, (bar_height - font_extents.height) / 2.0, 2, font_extents.height);
        c.cairo_fill(self.cr);

        c.cairo_surface_flush(self.surface);
        _ = c.xcb_flush(self.conn);
    }

    fn keysym(self: *const Ui, keycode: c.xcb_keycode_t) c.xcb_keysym_t {
        return c.xcb_key_symbols_get_keysym(self.key_symbols, keycode, 0);
    }

    fn set_font(self: *Ui) void {
        c.cairo_select_font_face(self.cr, config.font, c.CAIRO_FONT_SLANT_NORMAL, c.CAIRO_FONT_WEIGHT_NORMAL);
        c.cairo_set_font_size(self.cr, config.font_size);
    }

    fn measure_space_width(self: *Ui) void {
        self.set_font();

        var space_extents: c.cairo_text_extents_t = undefined;
        c.cairo_text_extents(self.cr, " ", &space_extents);
        self.space_width = space_extents.x_advance;

        if (self.space_width < 1.0) {
            var char_extents: c.cairo_text_extents_t = undefined;
            c.cairo_text_extents(self.cr, "n", &char_extents);
            self.space_width = char_extents.x_advance * 0.8;
        }
    }

    fn text_width(self: *Ui, text: []const u8) f64 {
        var spaces: usize = 0;
        for (text) |char| {
            if (char == ' ') spaces += 1;
        }

        if (spaces == 0) {
            var extents: c.cairo_text_extents_t = undefined;
            c.cairo_text_extents(self.cr, @ptrCast(text), &extents);
            return extents.width;
        }

        var temp: [config.max_text_len]u8 = undefined;
        var len: usize = 0;
        for (text) |char| {
            if (char != ' ' and len < config.max_text_len - 1) {
                temp[len] = char;
                len += 1;
            }
        }
        temp[len] = 0;

        var extents: c.cairo_text_extents_t = undefined;
        c.cairo_text_extents(self.cr, @ptrCast(&temp), &extents);
        return extents.width + @as(f64, @floatFromInt(spaces)) * self.space_width;
    }
};

const Zmen = struct {
    ui: Ui,
    input: Input = .{},
    commands: Commands,

    fn init() !Zmen {
        return Zmen{
            .ui = try Ui.init(),
            .commands = try Commands.init(),
        };
    }

    fn deinit(self: *Zmen) void {
        self.commands.deinit();
        self.ui.deinit();
    }

    fn draw(self: *Zmen) void {
        self.ui.draw(&self.input);
    }

    fn handle_key_press(self: *Zmen, keycode: c.xcb_keycode_t) void {
        const keysym = self.ui.keysym(keycode);
        var changed = false;

        switch (keysym) {
            c.XK_space => changed = self.input.insert(' '),
            c.XK_Tab => changed = self.input.append_completion(),
            c.XK_BackSpace => changed = self.input.delete_before_cursor(),
            c.XK_Delete => changed = self.input.delete_at_cursor(),
            c.XK_Left => {
                if (self.input.cursor > 0) {
                    self.input.cursor -= 1;
                }
            },
            c.XK_Right => {
                if (self.input.cursor < self.input.len) {
                    self.input.cursor += 1;
                } else {
                    changed = self.input.accept_completion_char();
                }
            },
            c.XK_Home => self.input.cursor = 0,
            c.XK_End => self.input.cursor = self.input.len,
            c.XK_Return => self.run(),
            c.XK_Escape => {
                if (self.input.len == 0) {
                    std.process.exit(0);
                }
                self.input.clear();
            },
            else => {
                const char = keysym_to_char(keysym);
                if (char != 0) {
                    changed = self.input.insert(char);
                }
            },
        }

        if (changed) {
            self.input.completion = self.commands.complete(self.input.slice());
        }
    }

    fn run(self: *Zmen) void {
        if (is_blank(self.input.slice())) {
            return;
        }

        var argv: [64:null]?[*:0]const u8 = undefined;
        const argc = split_argv(self.input.text[0..self.input.len], &argv);
        if (argc == 0) {
            return;
        }

        const pid = std.posix.fork() catch std.process.exit(1);
        if (pid != 0) std.process.exit(0);

        execvp(argv[0].?, &argv);
    }
};

fn get_screen(conn: *c.xcb_connection_t, screen_num: c_int) *c.xcb_screen_t {
    const setup = c.xcb_get_setup(conn);
    var iter = c.xcb_setup_roots_iterator(setup);
    var i: c_int = 0;
    while (i < screen_num) : (i += 1) {
        c.xcb_screen_next(&iter);
    }
    return iter.data;
}

fn create_window(conn: *c.xcb_connection_t, screen: *c.xcb_screen_t, x: i16, y: i16, width: u16, height: u16) c.xcb_window_t {
    const window = c.xcb_generate_id(conn);
    const mask = c.XCB_CW_BACK_PIXEL | c.XCB_CW_EVENT_MASK;
    const values = [_]u32{
        screen.*.black_pixel,
        c.XCB_EVENT_MASK_EXPOSURE | c.XCB_EVENT_MASK_KEY_PRESS | c.XCB_EVENT_MASK_KEY_RELEASE,
    };

    _ = c.xcb_create_window(
        conn,
        c.XCB_COPY_FROM_PARENT,
        window,
        screen.*.root,
        x,
        y,
        width,
        height,
        0,
        c.XCB_WINDOW_CLASS_INPUT_OUTPUT,
        screen.*.root_visual,
        mask,
        &values,
    );

    return window;
}

fn set_window_properties(conn: *c.xcb_connection_t, screen: *c.xcb_screen_t, window: c.xcb_window_t) !void {
    const atom_window_type = try intern_atom(conn, "_NET_WM_WINDOW_TYPE");
    const atom_window_type_dialog = try intern_atom(conn, "_NET_WM_WINDOW_TYPE_DIALOG");
    const atom_window_type_dock = try intern_atom(conn, "_NET_WM_WINDOW_TYPE_DOCK");

    const window_types = [_]c.xcb_atom_t{ atom_window_type_dialog, atom_window_type_dock };
    _ = c.xcb_change_property(conn, c.XCB_PROP_MODE_REPLACE, window, atom_window_type, c.XCB_ATOM_ATOM, 32, 2, &window_types);
    _ = c.xcb_change_property(conn, c.XCB_PROP_MODE_REPLACE, window, c.XCB_ATOM_WM_TRANSIENT_FOR, c.XCB_ATOM_WINDOW, 32, 1, &screen.*.root);

    const title = "zmen";
    _ = c.xcb_change_property(
        conn,
        c.XCB_PROP_MODE_REPLACE,
        window,
        c.XCB_ATOM_WM_NAME,
        c.XCB_ATOM_STRING,
        8,
        title.len,
        title,
    );
}

fn intern_atom(conn: *c.xcb_connection_t, name: [:0]const u8) !c.xcb_atom_t {
    const cookie = c.xcb_intern_atom(conn, 0, @intCast(name.len), name.ptr);
    const reply = c.xcb_intern_atom_reply(conn, cookie, null) orelse return error.AtomLookupFailed;
    defer c.free(reply);
    return reply.*.atom;
}

fn split_argv(input: []u8, argv: *[64:null]?[*:0]const u8) usize {
    var argc: usize = 0;
    var i: usize = 0;

    while (i < input.len) {
        while (i < input.len and input[i] == ' ') : (i += 1) {}
        if (i >= input.len) break;
        if (argc + 1 >= argv.len) break;

        argv[argc] = @ptrCast(&input[i]);
        argc += 1;

        while (i < input.len and input[i] != ' ') : (i += 1) {}
        if (i < input.len) {
            input[i] = 0;
            i += 1;
        }
    }

    argv[argc] = null;
    return argc;
}

fn is_blank(text: []const u8) bool {
    for (text) |char| {
        if (char != ' ') {
            return false;
        }
    }
    return true;
}

pub fn execvp(file: [*:0]const u8, argv: *const [64:null]?[*:0]const u8) noreturn {
    const name = std.mem.span(file);

    if (std.mem.indexOfScalar(u8, name, '/')) |_| {
        std.posix.execveZ(file, argv, std.c.environ) catch std.process.exit(1);
        unreachable;
    }

    const path_z: [:0]const u8 = std.posix.getenvZ("PATH") orelse "/bin:/usr/bin";
    const path: []const u8 = path_z[0..path_z.len];

    var it = std.mem.splitScalar(u8, path, ':');
    var buf: [std.posix.PATH_MAX:0]u8 = undefined;

    while (it.next()) |dir| {
        if (dir.len == 0) continue;

        const need = dir.len + 1 + name.len;
        if (need >= buf.len) continue;

        @memcpy(buf[0..dir.len], dir);
        buf[dir.len] = '/';
        @memcpy(buf[dir.len + 1 .. need], name);
        buf[need] = 0;

        std.posix.accessZ(&buf, std.posix.X_OK) catch |err| {
            if (err == error.FileNotFound) continue;
            std.process.exit(1);
        };

        switch (std.posix.execveZ(&buf, argv, std.c.environ)) {
            error.FileNotFound, error.AccessDenied => continue,
            else => std.process.exit(1),
        }
    }

    std.process.exit(1);
}

fn to_lower(char: u8) u8 {
    if (char >= 'A' and char <= 'Z') {
        return char + ('a' - 'A');
    }
    return char;
}

fn keysym_to_char(keysym: c.xcb_keysym_t) u8 {
    if (keysym >= 32 and keysym <= 126) {
        return @intCast(keysym);
    }
    return 0;
}

fn get_visual(screen: *c.xcb_screen_t, visual_id: c.xcb_visualid_t) ?*c.xcb_visualtype_t {
    var depth_iter = c.xcb_screen_allowed_depths_iterator(screen);
    while (depth_iter.rem != 0) : (c.xcb_depth_next(&depth_iter)) {
        var visual_iter = c.xcb_depth_visuals_iterator(depth_iter.data);
        while (visual_iter.rem != 0) : (c.xcb_visualtype_next(&visual_iter)) {
            const visual = @as(*c.xcb_visualtype_t, @ptrCast(visual_iter.data));
            if (visual.visual_id == visual_id) {
                return visual;
            }
        }
    }
    return null;
}

pub fn main() !void {
    var app = try Zmen.init();
    defer app.deinit();

    app.input.completion = app.commands.complete(app.input.slice());
    app.draw();

    while (true) {
        const event = c.xcb_wait_for_event(app.ui.conn);
        if (event == null) {
            continue;
        }
        defer c.free(event);

        switch (event.*.response_type & ~@as(u8, 0x80)) {
            c.XCB_EXPOSE => app.draw(),
            c.XCB_KEY_PRESS => {
                const key_event = @as(*c.xcb_key_press_event_t, @ptrCast(event));
                app.handle_key_press(key_event.detail);
                app.draw();
            },
            else => {},
        }
    }
}
