Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions examples/file_browser.zig
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ const Model = struct {
pub fn init(self: *Model, ctx: *zz.Context) zz.Cmd(Msg) {
self.file_picker = zz.components.FilePicker.init(ctx.persistent_allocator);
self.file_picker.height = ctx.height -| 10;
self.file_picker.setHomePath(ctx.home_dir);

// Start at home directory
self.file_picker.navigateHome(ctx.io, ctx.environ_map) catch {
self.file_picker.navigateHome(ctx.io) catch {
self.file_picker.navigate(ctx.io, "/") catch {};
};

Expand All @@ -34,15 +35,15 @@ const Model = struct {
.char => |c| switch (c) {
'q' => return .quit,
else => {
const selected = self.file_picker.handleKey(ctx.io, ctx.environ_map, k) catch false;
const selected = self.file_picker.handleKey(ctx.io, k) catch false;
if (selected) {
self.loadPreview(ctx.io);
}
},
},
.escape => return .quit,
else => {
const selected = self.file_picker.handleKey(ctx.io, ctx.environ_map, k) catch false;
const selected = self.file_picker.handleKey(ctx.io, k) catch false;
if (selected) {
self.loadPreview(ctx.io);
}
Expand Down
2 changes: 1 addition & 1 deletion examples/theming.zig
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const Model = struct {
};

pub fn init(self: *Model, ctx: *zz.Context) zz.Cmd(Msg) {
self.tm = zz.ThemeManager.init(ctx.environ_map);
self.tm = zz.ThemeManager.init(ctx.is_dark_background);
self.progress_val = 35;
// Also set the theme on the context so components can read it
ctx.setTheme(self.tm.current.palette);
Expand Down
25 changes: 14 additions & 11 deletions src/components/file_picker.zig
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub const FilePicker = struct {
dir_only: bool,
file_only: bool,
allowed_extensions: ?[]const []const u8,
home_path: []const u8,

// Styling
dir_style: style_mod.Style,
Expand Down Expand Up @@ -76,6 +77,7 @@ pub const FilePicker = struct {
.dir_only = false,
.file_only = false,
.allowed_extensions = null,
.home_path = defaultHomePath(),
.dir_style = blk: {
var s = style_mod.Style{};
s = s.bold(true);
Expand Down Expand Up @@ -225,15 +227,13 @@ pub const FilePicker = struct {
self.y_offset = 0;
}

/// Navigate to home directory using the supplied environment.
pub fn navigateHome(self: *FilePicker, io: std.Io, environ_map: *const std.process.Environ.Map) !void {
if (comptime builtin.os.tag == .windows) {
const home = environ_map.get("USERPROFILE") orelse "C:\\";
try self.navigate(io, home);
} else {
const home = environ_map.get("HOME") orelse "/";
try self.navigate(io, home);
}
pub fn setHomePath(self: *FilePicker, home_path: []const u8) void {
self.home_path = if (home_path.len > 0) home_path else defaultHomePath();
}

/// Navigate to the configured home directory.
pub fn navigateHome(self: *FilePicker, io: std.Io) !void {
try self.navigate(io, self.home_path);
}

/// Move cursor up
Expand Down Expand Up @@ -295,7 +295,6 @@ pub const FilePicker = struct {
pub fn handleKey(
self: *FilePicker,
io: std.Io,
environ_map: *const std.process.Environ.Map,
key: keys.KeyEvent,
) !bool {
if (!self.focused) return false;
Expand All @@ -314,7 +313,7 @@ pub const FilePicker = struct {
'j' => self.cursorDown(),
'k' => self.cursorUp(),
'h' => self.showHidden(io),
'~' => try self.navigateHome(io, environ_map),
'~' => try self.navigateHome(io),
else => {},
}
},
Expand All @@ -330,6 +329,10 @@ pub const FilePicker = struct {
self.navigate(io, path_copy) catch {};
}

fn defaultHomePath() []const u8 {
return if (comptime builtin.os.tag == .windows) "C:\\" else "/";
}

fn ensureVisible(self: *FilePicker) void {
if (self.cursor < self.y_offset) {
self.y_offset = self.cursor;
Expand Down
13 changes: 7 additions & 6 deletions src/core/context.zig
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const color_mod = @import("../style/color.zig");
const unicode_mod = @import("../unicode.zig");
const Logger = @import("log.zig").Logger;
const theme_mod = @import("../style/theme.zig");
const Environment = @import("environment.zig").Environment;

/// Runtime context passed to init, update, and view functions
pub const Context = struct {
Expand All @@ -18,8 +19,8 @@ pub const Context = struct {
/// Persistent allocator for model state (not reset between frames)
persistent_allocator: std.mem.Allocator,

/// Process environment for env-driven detection (color, multiplexer, locale).
environ_map: *const std.process.Environ.Map,
/// Home directory captured from the process environment at startup.
home_dir: []const u8,

/// Asynchronous I/O facilities (file, network, time, sleep).
io: std.Io,
Expand Down Expand Up @@ -80,14 +81,14 @@ pub const Context = struct {
allocator: std.mem.Allocator,
persistent_allocator: std.mem.Allocator,
io: std.Io,
environ_map: *const std.process.Environ.Map,
environment: *const Environment,
) Context {
const profile = color_mod.ColorProfile.detect(environ_map);
const profile = environment.color_profile;
return .{
.allocator = allocator,
.persistent_allocator = persistent_allocator,
.io = io,
.environ_map = environ_map,
.home_dir = environment.home_dir,
.width = 80,
.height = 24,
.frame = 0,
Expand All @@ -96,7 +97,7 @@ pub const Context = struct {
.true_color = profile.supportsTrueColor(),
.color_256 = profile.supports256(),
.color_profile = profile,
.is_dark_background = color_mod.hasDarkBackground(environ_map),
.is_dark_background = environment.is_dark_background,
.unicode_width_strategy = unicode_mod.getWidthStrategy(),
.terminal_mode_2027 = false,
.kitty_text_sizing = false,
Expand Down
122 changes: 122 additions & 0 deletions src/core/environment.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
//! Process environment values captured at program startup.

const std = @import("std");
const builtin = @import("builtin");
const color_mod = @import("../style/color.zig");
const unicode_mod = @import("../unicode.zig");

pub const Environment = struct {
term: []const u8 = "",
term_program: []const u8 = "",
lc_terminal: []const u8 = "",
color_term: []const u8 = "",
color_fg_bg: []const u8 = "",
term_features: []const u8 = "",
home_dir: []const u8 = defaultHomeDir(),
no_color: bool = false,
has_tmux: bool = false,
has_zellij: bool = false,
has_kitty_window: bool = false,
color_profile: color_mod.ColorProfile = .ansi,
is_dark_background: bool = true,
unicode_width_override: ?unicode_mod.WidthStrategy = null,

pub fn fromEnvMap(environ_map: *const std.process.Environ.Map) Environment {
const term = environ_map.get("TERM") orelse "";
const term_program = environ_map.get("TERM_PROGRAM") orelse "";
const lc_terminal = environ_map.get("LC_TERMINAL") orelse "";
const color_term = environ_map.get("COLORTERM") orelse "";
const color_fg_bg = environ_map.get("COLORFGBG") orelse "";
const term_features = environ_map.get("TERM_FEATURES") orelse "";
const home_dir = homeDirFromEnvMap(environ_map);
const no_color = environ_map.get("NO_COLOR") != null;

return .{
.term = term,
.term_program = term_program,
.lc_terminal = lc_terminal,
.color_term = color_term,
.color_fg_bg = color_fg_bg,
.term_features = term_features,
.home_dir = home_dir,
.no_color = no_color,
.has_tmux = envValuePresent(environ_map, "TMUX"),
.has_zellij = envValuePresent(environ_map, "ZELLIJ"),
.has_kitty_window = envValuePresent(environ_map, "KITTY_WINDOW_ID"),
.color_profile = color_mod.ColorProfile.detect(.{
.no_color = no_color,
.color_term = color_term,
.term = term,
}),
.is_dark_background = color_mod.hasDarkBackground(color_fg_bg),
.unicode_width_override = parseUnicodeWidthOverride(environ_map.get("ZZ_UNICODE_WIDTH") orelse ""),
};
}

pub fn isInsideMultiplexer(self: *const Environment) bool {
return self.has_tmux or self.has_zellij or self.termContains("screen");
}

pub fn isKnownUnicodeWidthTerminal(self: *const Environment) bool {
return self.termProgramEquals("WezTerm") or
self.termProgramEquals("iTerm.app") or
self.termContains("wezterm") or
self.termContains("ghostty");
}

pub fn looksLikeKittyTerminal(self: *const Environment) bool {
return self.has_kitty_window or self.termContains("kitty");
}

pub fn looksLikeIterm2Terminal(self: *const Environment) bool {
return self.termProgramEquals("iTerm.app") or self.lcTerminalEquals("iTerm2");
}

pub fn looksLikeSixelTerminal(self: *const Environment) bool {
return self.termContains("sixel") or
self.termContains("mlterm") or
self.termContains("yaft") or
self.termContains("contour");
}

pub fn termContains(self: *const Environment, needle: []const u8) bool {
return std.mem.indexOf(u8, self.term, needle) != null;
}

pub fn termProgramEquals(self: *const Environment, expected: []const u8) bool {
return std.ascii.eqlIgnoreCase(self.term_program, expected);
}

pub fn lcTerminalEquals(self: *const Environment, expected: []const u8) bool {
return std.ascii.eqlIgnoreCase(self.lc_terminal, expected);
}

fn homeDirFromEnvMap(environ_map: *const std.process.Environ.Map) []const u8 {
if (comptime builtin.os.tag == .windows) {
if (environ_map.get("USERPROFILE")) |home| {
if (home.len > 0) return home;
}
} else {
if (environ_map.get("HOME")) |home| {
if (home.len > 0) return home;
}
}
return defaultHomeDir();
}

fn defaultHomeDir() []const u8 {
return if (comptime builtin.os.tag == .windows) "C:\\" else "/";
}

fn envValuePresent(environ_map: *const std.process.Environ.Map, name: []const u8) bool {
const value = environ_map.get(name) orelse return false;
return value.len > 0;
}

fn parseUnicodeWidthOverride(raw: []const u8) ?unicode_mod.WidthStrategy {
if (std.ascii.eqlIgnoreCase(raw, "unicode")) return .unicode;
if (std.ascii.eqlIgnoreCase(raw, "legacy")) return .legacy_wcwidth;
if (std.ascii.eqlIgnoreCase(raw, "auto")) return null;
return null;
}
};
31 changes: 13 additions & 18 deletions src/core/program.zig
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const message = @import("message.zig");
const command = @import("command.zig");
const Logger = @import("log.zig").Logger;
const unicode = @import("../unicode.zig");
const Environment = @import("environment.zig").Environment;

pub const Cmd = command.Cmd;
pub const Msg = message;
Expand Down Expand Up @@ -47,7 +48,7 @@ pub fn Program(comptime Model: type) type {
return struct {
allocator: std.mem.Allocator,
io: std.Io,
environ_map: *const std.process.Environ.Map,
environment: Environment,
arena: std.heap.ArenaAllocator,
model: Model,
terminal: ?Terminal,
Expand Down Expand Up @@ -78,7 +79,7 @@ pub fn Program(comptime Model: type) type {

const Self = @This();

/// Initialize the program
/// Initialize the program.
pub fn init(
allocator: std.mem.Allocator,
io: std.Io,
Expand All @@ -87,7 +88,7 @@ pub fn Program(comptime Model: type) type {
return initWithOptions(allocator, io, environ_map, .{});
}

/// Initialize with custom options
/// Initialize with custom options.
pub fn initWithOptions(
allocator: std.mem.Allocator,
io: std.Io,
Expand All @@ -96,16 +97,14 @@ pub fn Program(comptime Model: type) type {
) Self {
const arena = std.heap.ArenaAllocator.init(allocator);
const clock_epoch = std.Io.Clock.Timestamp.now(io, .boot);
const self = Self{
var self = Self{
.allocator = allocator,
.io = io,
.environ_map = environ_map,
.environment = .fromEnvMap(environ_map),
.arena = arena,
.model = undefined,
.terminal = null,
// `self` is returned by value, so don't capture an arena allocator here.
// It would point at this function's stack copy and dangle after return.
.context = Context.init(allocator, allocator, io, environ_map),
.context = undefined,
.options = options,
.running = false,
.clock_epoch = clock_epoch,
Expand All @@ -122,6 +121,10 @@ pub fn Program(comptime Model: type) type {
.filter = null,
};

// `self` is returned by value, so don't capture an arena allocator here.
// It would point at this function's stack copy and dangle after return.
self.context = Context.init(allocator, allocator, io, &self.environment);

return self;
}

Expand Down Expand Up @@ -179,7 +182,7 @@ pub fn Program(comptime Model: type) type {
}

// Initialize terminal
self.terminal = try Terminal.init(self.io, self.environ_map, .{
self.terminal = try Terminal.init(self.io, &self.environment, .{
.alt_screen = self.options.alt_screen,
.hide_cursor = !self.options.cursor,
.mouse = self.options.mouse,
Expand Down Expand Up @@ -394,20 +397,12 @@ pub fn Program(comptime Model: type) type {
if (self.options.unicode_width_strategy) |forced| {
return forced;
}
if (envUnicodeWidthOverride(self.environ_map)) |from_env| {
if (self.environment.unicode_width_override) |from_env| {
return from_env;
}
return detected;
}

fn envUnicodeWidthOverride(environ_map: *const std.process.Environ.Map) ?unicode.WidthStrategy {
const raw = environ_map.get("ZZ_UNICODE_WIDTH") orelse return null;
if (std.ascii.eqlIgnoreCase(raw, "unicode")) return .unicode;
if (std.ascii.eqlIgnoreCase(raw, "legacy")) return .legacy_wcwidth;
if (std.ascii.eqlIgnoreCase(raw, "auto")) return null;
return null;
}

fn processMouseEvent(self: *Self, mouse_event: keyboard.MouseEvent) ?UserCmd {
if (@hasField(UserMsg, "mouse")) {
const user_msg = UserMsg{ .mouse = mouse_event };
Expand Down
1 change: 1 addition & 0 deletions src/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ pub const program = @import("core/program.zig");
pub const Program = program.Program;
pub const Cmd = program.Cmd;
pub const command = @import("core/command.zig");
pub const Environment = @import("core/environment.zig").Environment;
pub const async_task = @import("core/async_task.zig");
pub const AsyncRunner = async_task.AsyncRunner;
pub const SubProgram = @import("core/sub_program.zig").SubProgram;
Expand Down
Loading
Loading