diff --git a/README.md b/README.md index addebc9..84b1298 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A cross-platform dmenu-like application launcher built with Zig and SDL3. - **Case-insensitive filtering** - Search without worrying about caps (ASCII only) - **UTF-8 safe** - Handles multi-byte characters correctly in input and items - **Multi-line display** - See up to 10 items at once with scrolling +- **File preview pane** - Preview text files side-by-side (toggle with Ctrl+P) - **Keyboard-driven navigation** with vim-style keybindings - **Cross-platform** - Runs on Linux, Windows, and macOS without code changes - **Zero configuration** - Works out of the box with sensible defaults @@ -53,6 +54,13 @@ cd "$selected" - `Ctrl+U` - Clear entire input - `Ctrl+W` - Delete last word +**Preview Pane:** +- `Ctrl+P` - Toggle file preview pane on/off +- `Alt+↑` - Scroll preview up one line +- `Alt+↓` - Scroll preview down one line +- `Alt+Page Up` - Scroll preview up one page +- `Alt+Page Down` - Scroll preview down one page + **Actions:** - `Enter` - Select current item and output to stdout - `Escape` / `Ctrl+C` - Cancel without selection @@ -60,9 +68,11 @@ cd "$selected" ### Display - Top left: Input prompt with your query -- Top right: Filtered count / Total items -- Bottom right (if needed): Scroll indicator showing visible range +- Top right: Filtered count / Total items (shows "Preview: ON" when enabled) +- Bottom right (if needed): Scroll indicator showing visible range for items - Selected item has `>` prefix and highlighted color +- Right pane (when enabled): File preview with syntax-highlighted content +- Preview top-right: Scroll indicator [start-end/total] when preview has more lines than visible ## Themes @@ -101,6 +111,49 @@ If `ZMENU_THEME` is not set or contains an invalid name, zmenu defaults to **moc Theme names are case-insensitive (`NORD`, `nord`, and `NoRd` all work). +## File Preview + +zmenu includes an optional file preview pane that displays text file contents side-by-side with the item list. Press **Ctrl+P** to toggle the preview on or off. + +### Preview Features + +**Automatic text file detection:** +- Recognizes 50+ text file extensions (.zig, .c, .py, .js, .md, .json, etc.) +- Detects binary files by extension and null-byte scanning +- Safely handles UTF-8 encoded files + +**Smart content display:** +- Shows all file content (scrollable with Alt+Arrow keys) +- Limits file size to 1MB for performance +- Syntax highlighting using tree-sitter +- Graceful error handling for missing/large/binary files + +**Layout:** +- Preview pane takes 70% of window width +- Items list takes 30% of window width +- Vertical divider line separates the panes +- Preview updates automatically when navigating items + +### Preview States + +The preview pane can display the following states: + +- **Text preview** - Shows file contents line by line +- **"(no preview available)"** - Item is not a file path +- **"Binary file (no preview)"** - File is a known binary format (.pdf, .png, .exe, etc.) +- **"File not found"** - Path doesn't exist +- **"Permission denied"** - Cannot read the file +- **"File too large"** - File exceeds 1MB limit + +### Usage Example + +```bash +# Preview files while searching +find . -type f | zmenu # Press Ctrl+P to see file contents +``` + +**Important:** Preview requires **full file paths** from the current directory. Commands like `find`, `rg -l`, and `fd` output full paths by default. + ## Development - [mise](https://mise.jdx.dev/) for version management @@ -215,15 +268,14 @@ zig build -Dtarget=x86_64-linux - No configuration file support yet - No history/frecency tracking -- Window size is fixed (800x300) +- Window size is adaptive but bounded (600-1600px width, 150-800px height) +- Preview limited to 1MB files ## Future Enhancements **Planned:** - [ ] Configuration file support (`~/.config/zmenu/config.toml`) - [ ] History tracking with frecency scoring -- [ ] Multi-column layout option -- [ ] Preview pane for file paths - [ ] Custom keybinding support **Maybe:** diff --git a/build.zig b/build.zig index 94dd763..49f8f53 100644 --- a/build.zig +++ b/build.zig @@ -11,6 +11,12 @@ pub fn build(b: *std.Build) void { .ext_ttf = true, }); + // Add flow-syntax dependency + const flow_syntax = b.dependency("flow_syntax", .{ + .target = target, + .optimize = optimize, + }); + const exe = b.addExecutable(.{ .name = "zmenu", .root_module = b.createModule(.{ @@ -22,6 +28,8 @@ pub fn build(b: *std.Build) void { // Import SDL3 module exe.root_module.addImport("sdl3", sdl3.module("sdl3")); + // Import flow-syntax module + exe.root_module.addImport("syntax", flow_syntax.module("syntax")); b.installArtifact(exe); @@ -35,6 +43,7 @@ pub fn build(b: *std.Build) void { const run_step = b.step("run", "Run the app"); run_step.dependOn(&run_cmd.step); + // Main tests (embedded in main.zig) const unit_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("src/main.zig"), @@ -45,6 +54,8 @@ pub fn build(b: *std.Build) void { // Import SDL3 module for tests unit_tests.root_module.addImport("sdl3", sdl3.module("sdl3")); + // Import flow-syntax module for tests + unit_tests.root_module.addImport("syntax", flow_syntax.module("syntax")); const run_unit_tests = b.addRunArtifact(unit_tests); diff --git a/build.zig.zon b/build.zig.zon index b272a2c..ac72829 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -6,6 +6,10 @@ .url = "git+https://github.com/Gota7/zig-sdl3?ref=v0.1.5#014b7bcb2899f3ed9c945c4abfcfe1b25d75bfeb", .hash = "sdl3-0.1.5-NmT1QxARJgAH1Wp0cMBJDAc9vD7weufTkIwVa5rehA2q", }, + .flow_syntax = .{ + .url = "git+https://github.com/neurocyte/flow-syntax?ref=master#10b92330cf0ecaa39a52d3a8d190f7fb384b7b09", + .hash = "flow_syntax-0.1.0-X8jOoU8VAQCOYNTiuB7y2aIBP1V3OXXHa8WvE3eXtpDK", + }, }, .paths = .{""}, .fingerprint = 0x2901eafceebebb0a, diff --git a/lesson-learned.md b/lesson-learned.md index 227cdd9..654d120 100644 --- a/lesson-learned.md +++ b/lesson-learned.md @@ -171,4 +171,225 @@ const unit_tests = b.addTest(.{ ... }); unit_tests.root_module.addImport("sdl3", sdl3.module("sdl3")); ``` -**Lesson**: Test target needs explicit module imports even though it compiles the same source file. \ No newline at end of file +**Lesson**: Test target needs explicit module imports even though it compiles the same source file. + +--- + +### Syntax Highlighting Integration (flow-syntax/tree-sitter) + +**Challenge**: Integrating tree-sitter via flow-syntax library for syntax highlighting in preview pane. + +**Key Decisions**: + +1. **Span-based rendering**: Collect highlight spans with byte ranges and scopes from tree-sitter, then render line segments with appropriate colors. + +2. **Error handling**: Syntax parsing failures shouldn't crash the app - catch and clear stale spans: + ```zig + self.state.highlight_spans.clearRetainingCapacity(); + self.applySyntaxHighlighting(file_path, content) catch |err| { + self.state.highlight_spans.clearRetainingCapacity(); // Clear on error + std.debug.print("Warning: {}\n", .{err}); + }; + ``` + +3. **Callback signature**: Tree-sitter callback must return `error{Stop}!void` not generic error union: + ```zig + fn callback(ctx: *Context, range: Range, scope: []const u8, ...) error{Stop}!void { + ctx.spans.append(allocator, .{ ... }) catch return error.Stop; + } + ``` + +**Pitfall**: Stale highlight data from previous file can render on new file with wrong byte offsets. Always clear spans before and after syntax parsing. + +--- + +### Byte Offset Calculation for Multi-line Highlighting + +**Problem**: When rendering preview line-by-line, byte offsets must match the full content for highlight spans to align correctly. + +**Critical Bug Found**: Using `splitScalar` iterator and adding `+1` for every line, even when file doesn't end with newline: +```zig +// WRONG - adds extra byte at end +while (iter.next()) |line| { + byte_offset += line.len + 1; // Assumes newline always exists +} +``` + +**Correct Approach**: Track whether newline actually exists: +```zig +var iter = std.mem.splitScalar(u8, content, '\n'); +while (iter.next()) |line| { + const has_newline = (byte_offset + line.len < content.len); + + // ... render line ... + + byte_offset += line.len; + if (has_newline) byte_offset += 1; // Only count actual newlines +} +``` + +**Lesson**: Never assume text ends with newline. Files without trailing newlines are common and valid. + +--- + +### Line Counting Consistency + +**Problem**: Multiple locations counted lines differently, causing scroll bounds mismatch. + +**Original Issues**: +1. Byte iteration counting newlines (used in scrolling) +2. `splitScalar` iterator counting elements (used in height calculation) +3. Redundant condition: `items.len > 0 and (items.len == 0 or ...)` + +**Solution**: Single helper function with clear logic: +```zig +fn countLines(content: []const u8) usize { + if (content.len == 0) return 0; + + var count: usize = 0; + for (content) |byte| { + if (byte == '\n') count += 1; + } + + // Add 1 if content doesn't end with newline + if (content[content.len - 1] != '\n') { + count += 1; + } + + return count; +} +``` + +**Impact**: Used in scrolling, scroll indicators, and height calculations for consistency. + +**Lesson**: When the same calculation appears in multiple places, extract it to a helper function. This prevents subtle differences that cause bugs. + +--- + +### Scroll Bounds Edge Cases + +**Problem**: Allowing `scroll_offset` to equal `line_count` resulted in blank preview screen. + +**Why**: If file has 10 lines (indices 0-9) and `max_visible = 30`, setting offset to 10 means "start rendering at line 10" which doesn't exist. + +**Correct Logic**: +```zig +const max_offset = if (line_count > max_visible) + line_count - max_visible // Last page starts here +else + 0; // All lines fit on one page + +// Only allow scrolling up to max_offset +if (scroll_offset < max_offset) { + scroll_offset += 1; +} +``` + +**Lesson**: For scrollable views, maximum offset is `total_items - visible_items`, not `total_items`. + +--- + +### Keybinding Order in else-if Chains + +**Critical Bug**: Alt+Arrow keys weren't working because regular arrow key checks came first: + +```zig +// WRONG - Alt+Up never reached +} else if (key == .up or key == .k) { + navigate(-1); // Matches FIRST, ignores Alt modifier +} else if (key == .up and (event.mod.left_alt or event.mod.right_alt)) { + scrollPreviewUp(); // Never reached +} +``` + +**Fix**: Put more specific checks (with modifiers) BEFORE general checks: +```zig +// CORRECT - Check Alt+Up before regular Up +} else if (key == .up and (event.mod.left_alt or event.mod.right_alt)) { + scrollPreviewUp(); // Checks modifier FIRST +} else if (key == .up or key == .k) { + navigate(-1); // Catches unmodified keys +} +``` + +**Lesson**: In else-if chains, order matters. More specific conditions must come before general ones. + +--- + +### Performance: Repeated Line Counting + +**Problem**: For 1MB file with 10K+ lines, counting lines 2-4 times per scroll operation: +- `scrollPreviewDown()` - counts lines +- `scrollPreviewDownPage()` - counts lines +- Scroll indicator rendering - counts lines +- Height calculation - counts lines + +**Current Approach**: Accepts O(n) line counting but consolidated to single method for consistency. + +**Future Optimization**: Cache line count in `AppState` and update when preview content changes: +```zig +// Add to AppState: +preview_line_count: usize = 0, + +// Update in loadPreview(): +self.state.preview_line_count = countLines(content); + +// Use cached value in all locations +``` + +**Lesson**: Profile before optimizing. Current approach is "good enough" for <1MB files, but caching would help with very large files. + +--- + +### UI Overlay Conflicts + +**Problem**: Preview scroll indicator `[1-30/187]` overlapped with items count `7/7 Preview: ON` in top-right corner. + +**Solution**: Position indicators on different Y coordinates: +```zig +// Items count at prompt line +const count_y = self.config.layout.prompt_y * scale; + +// Preview scroll one line below +const scroll_y = (self.config.layout.prompt_y + self.config.layout.item_line_height) * scale; +``` + +**Lesson**: When adding new UI elements, check for positional conflicts with existing elements. Vertical stacking prevents overlap. + +--- + +### Height Calculation with Scrollable Content + +**Problem**: Window height calculated for ALL lines in preview (e.g., 187 lines) but only 30 lines displayed, creating huge bottom gap. + +**Fix**: Limit height calculation to visible lines: +```zig +const line_count = countLines(preview_content); +const visible_lines = @min(line_count, max_visible_items); +const height = base_height + (visible_lines * line_height); +``` + +**Lesson**: When implementing scrolling, window/viewport size should be based on visible items, not total items. + +--- + +### Test Coverage Gaps + +**Discovered Issues** (from code review): + +1. **Tests don't compile**: Missing `query_cache` field in App initialization +2. **No tests for**: + - Line counting edge cases (with/without trailing newline) + - Scroll bounds validation + - Byte offset calculation + - Syntax highlighting span collection + - Preview state transitions + +**Action Items**: +- Add `query_cache` to all test App structs +- Write tests for `countLines()` with various inputs +- Test scroll bounds at edges (0 lines, 1 line, exactly max_visible, more than max_visible) +- Test byte offset tracking through multi-line content +- Mock tree-sitter callbacks to test span collection + +**Lesson**: When adding complex features, write tests incrementally. Don't wait until "it's working" - test as you build. \ No newline at end of file diff --git a/src/font.zig b/src/font.zig new file mode 100644 index 0000000..edfa190 --- /dev/null +++ b/src/font.zig @@ -0,0 +1,133 @@ +const std = @import("std"); +const sdl = @import("sdl3"); +const builtin = @import("builtin"); + +/// Platform-specific default font paths (tried in order) +pub const default_font_paths = switch (builtin.os.tag) { + .linux => [_][:0]const u8{ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", // Arch Linux + "/usr/share/fonts/dejavu/DejaVuSans.ttf", // Some distros + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + }, + .macos => [_][:0]const u8{ + "/System/Library/Fonts/Helvetica.ttc", + "/System/Library/Fonts/SFNSText.ttf", + "/Library/Fonts/Arial.ttf", + }, + .windows => [_][:0]const u8{ + "C:\\Windows\\Fonts\\arial.ttf", + "C:\\Windows\\Fonts\\segoeui.ttf", + "C:\\Windows\\Fonts\\calibri.ttf", + }, + else => [_][:0]const u8{ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + }, +}; + +pub const FontConfig = struct { + path: ?[:0]const u8 = null, // null = use platform defaults + size: f32 = 22, +}; + +/// Text texture cache entry for performance optimization +pub const TextureCache = struct { + texture: ?sdl.render.Texture, + last_text: []u8, + last_color: sdl.pixels.Color, + + pub fn init() TextureCache { + return .{ + .texture = null, + .last_text = &[_]u8{}, + .last_color = sdl.pixels.Color{ .r = 0, .g = 0, .b = 0, .a = 255 }, + }; + } + + pub fn deinit(self: *TextureCache, allocator: std.mem.Allocator) void { + if (self.texture) |tex| tex.deinit(); + if (self.last_text.len > 0) allocator.free(self.last_text); + } +}; + +/// Load a TTF font with the given configuration +/// Tries platform-specific default paths if config.path is null +pub fn loadFont(config: FontConfig, allocator: std.mem.Allocator) !struct { font: sdl.ttf.Font, path: []const u8 } { + if (config.path) |custom_path| { + // User provided custom font path + const font = sdl.ttf.Font.init(custom_path, config.size) catch |err| { + std.debug.print("Failed to load custom font from {s}: {}\n", .{ custom_path, err }); + return err; + }; + const path_copy = try allocator.dupe(u8, custom_path); + return .{ .font = font, .path = path_copy }; + } + + // Try platform-specific default paths + for (default_font_paths) |font_path| { + if (sdl.ttf.Font.init(font_path, config.size)) |font| { + const path_copy = try allocator.dupe(u8, font_path); + return .{ .font = font, .path = path_copy }; + } else |_| { + // Try next path + continue; + } + } + + std.debug.print("Failed to load any font. Tried paths:\n", .{}); + for (default_font_paths) |font_path| { + std.debug.print(" - {s}\n", .{font_path}); + } + return error.NoFontAvailable; +} + +// ============================================================================ +// Tests +// ============================================================================ + +const testing = std.testing; + +test "FontConfig - defaults" { + const config = FontConfig{}; + try testing.expect(config.path == null); + try testing.expectEqual(@as(f32, 22), config.size); +} + +test "FontConfig - custom values" { + const custom_path: [:0]const u8 = "/custom/font.ttf"; + const config = FontConfig{ .path = custom_path, .size = 16 }; + try testing.expectEqualStrings(custom_path, config.path.?); + try testing.expectEqual(@as(f32, 16), config.size); +} + +test "TextureCache - init" { + const cache = TextureCache.init(); + try testing.expect(cache.texture == null); + try testing.expectEqual(@as(usize, 0), cache.last_text.len); +} + +test "TextureCache - deinit without allocation" { + var cache = TextureCache.init(); + cache.deinit(testing.allocator); // Should not crash +} + +test "default_font_paths - not empty" { + try testing.expect(default_font_paths.len > 0); +} + +test "default_font_paths - all absolute paths" { + for (default_font_paths) |path| { + try testing.expect(path.len > 0); + const is_absolute = path[0] == '/' or (path.len >= 3 and path[1] == ':'); + try testing.expect(is_absolute); + } +} + +test "default_font_paths - all font files" { + for (default_font_paths) |path| { + const has_ext = std.mem.endsWith(u8, path, ".ttf") or + std.mem.endsWith(u8, path, ".ttc") or + std.mem.endsWith(u8, path, ".otf"); + try testing.expect(has_ext); + } +} diff --git a/src/main.zig b/src/main.zig index acfdbcd..4ad1a7e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -2,29 +2,10 @@ const std = @import("std"); const sdl = @import("sdl3"); const builtin = @import("builtin"); const theme = @import("theme.zig"); - -// Platform-specific default font paths (tried in order) -const default_font_paths = switch (builtin.os.tag) { - .linux => [_][:0]const u8{ - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - "/usr/share/fonts/TTF/DejaVuSans.ttf", // Arch Linux - "/usr/share/fonts/dejavu/DejaVuSans.ttf", // Some distros - "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", - }, - .macos => [_][:0]const u8{ - "/System/Library/Fonts/Helvetica.ttc", - "/System/Library/Fonts/SFNSText.ttf", - "/Library/Fonts/Arial.ttf", - }, - .windows => [_][:0]const u8{ - "C:\\Windows\\Fonts\\arial.ttf", - "C:\\Windows\\Fonts\\segoeui.ttf", - "C:\\Windows\\Fonts\\calibri.ttf", - }, - else => [_][:0]const u8{ - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - }, -}; +const syntax_mod = @import("syntax"); +const font_mod = @import("font.zig"); +const preview_mod = @import("preview.zig"); +const syntax_highlight = @import("syntax_highlight.zig"); const WindowConfig = struct { initial_width: u32 = 800, @@ -69,30 +50,15 @@ const Layout = struct { sample_scroll_text: [:0]const u8 = "[999-999]", // Longest possible scroll indicator }; -const FontConfig = struct { - path: ?[:0]const u8 = null, // null = use platform defaults - size: f32 = 22, -}; - const Config = struct { window: WindowConfig = .{}, colors: ColorScheme = .{}, limits: Limits = .{}, layout: Layout = .{}, - font: FontConfig = .{}, + font: font_mod.FontConfig = .{}, + preview: preview_mod.PreviewConfig = .{}, }; -// Text texture cache entry -const TextureCache = struct { - texture: ?sdl.render.Texture, - last_text: []u8, - last_color: sdl.pixels.Color, - - fn deinit(self: *TextureCache, allocator: std.mem.Allocator) void { - if (self.texture) |tex| tex.deinit(); - allocator.free(self.last_text); - } -}; const SdlContext = struct { window: sdl.video.Window, @@ -101,6 +67,7 @@ const SdlContext = struct { loaded_font_path: []const u8, // Track which font was actually loaded }; + const AppState = struct { input_buffer: std.ArrayList(u8), items: std.ArrayList([]const u8), @@ -108,6 +75,13 @@ const AppState = struct { selected_index: usize, scroll_offset: usize, needs_render: bool, + // Preview state + preview_enabled: bool, // Current toggle state + preview_content: std.ArrayList(u8), // Preview text content + preview_state: preview_mod.PreviewState, // Current preview state + last_previewed_item: ?[]const u8, // Track which item we last previewed + highlight_spans: std.ArrayList(syntax_highlight.HighlightSpan), // Syntax highlight information + preview_scroll_offset: usize, // Scroll position in preview (line number) }; const RenderContext = struct { @@ -116,10 +90,10 @@ const RenderContext = struct { item_buffer: []u8, count_buffer: []u8, scroll_buffer: []u8, - // Texture caching for text rendering performance - prompt_cache: TextureCache, - count_cache: TextureCache, - no_match_cache: TextureCache, + // Texture caching for text rendering performance (using font module) + prompt_cache: font_mod.TextureCache, + count_cache: font_mod.TextureCache, + no_match_cache: font_mod.TextureCache, // High DPI state display_scale: f32, // Combined scale factor (pixel density × content scale) pixel_width: u32, // Actual pixel dimensions @@ -135,36 +109,14 @@ const App = struct { render_ctx: RenderContext, config: Config, allocator: std.mem.Allocator, + query_cache: *syntax_mod.QueryCache, - fn tryLoadFont(config: Config) !struct { font: sdl.ttf.Font, path: []const u8 } { - // If user specified a custom font path, try only that - if (config.font.path) |custom_path| { - const font = sdl.ttf.Font.init(custom_path, config.font.size) catch |err| { - std.debug.print("Error: Failed to load font '{s}': {}\n", .{ custom_path, err }); - return err; - }; - return .{ .font = font, .path = custom_path }; - } - - // Try platform-specific default fonts in order - for (default_font_paths) |font_path| { - if (sdl.ttf.Font.init(font_path, config.font.size)) |font| { - std.debug.print("Successfully loaded font: {s}\n", .{font_path}); - return .{ .font = font, .path = font_path }; - } else |_| { - // Silently continue to next font - } - } - - // No fonts found - std.debug.print("Error: Could not find any suitable font. Tried:\n", .{}); - for (default_font_paths) |font_path| { - std.debug.print(" - {s}\n", .{font_path}); - } - return error.NoFontFound; - } fn init(allocator: std.mem.Allocator) !App { + // Initialize QueryCache for syntax highlighting + const query_cache = try syntax_mod.QueryCache.create(allocator, .{}); + errdefer query_cache.deinit(); + // Initialize SDL const init_flags = sdl.InitFlags{ .video = true, .events = true }; try sdl.init(init_flags); @@ -231,8 +183,9 @@ const App = struct { errdefer allocator.free(scroll_buffer); // Load font with platform-specific fallback - const font_result = try tryLoadFont(config); + const font_result = try font_mod.loadFont(config.font, allocator); errdefer font_result.font.deinit(); + errdefer allocator.free(font_result.path); var app = App{ .sdl = .{ @@ -248,15 +201,21 @@ const App = struct { .selected_index = 0, .scroll_offset = 0, .needs_render = true, + .preview_enabled = config.preview.enable_preview, + .preview_content = std.ArrayList(u8).empty, + .preview_state = .none, + .last_previewed_item = null, + .highlight_spans = std.ArrayList(syntax_highlight.HighlightSpan).empty, + .preview_scroll_offset = 0, }, .render_ctx = .{ .prompt_buffer = prompt_buffer, .item_buffer = item_buffer, .count_buffer = count_buffer, .scroll_buffer = scroll_buffer, - .prompt_cache = .{ .texture = null, .last_text = &[_]u8{}, .last_color = .{ .r = 0, .g = 0, .b = 0, .a = 0 } }, - .count_cache = .{ .texture = null, .last_text = &[_]u8{}, .last_color = .{ .r = 0, .g = 0, .b = 0, .a = 0 } }, - .no_match_cache = .{ .texture = null, .last_text = &[_]u8{}, .last_color = .{ .r = 0, .g = 0, .b = 0, .a = 0 } }, + .prompt_cache = font_mod.TextureCache.init(), + .count_cache = font_mod.TextureCache.init(), + .no_match_cache = font_mod.TextureCache.init(), .display_scale = display_scale, .pixel_width = @intCast(pixel_width), .pixel_height = @intCast(pixel_height), @@ -265,6 +224,7 @@ const App = struct { }, .config = config, .allocator = allocator, + .query_cache = query_cache, }; // Load items from stdin @@ -296,6 +256,8 @@ const App = struct { std.debug.print("Warning: Failed to stop text input: {}\n", .{err}); }; self.state.input_buffer.deinit(self.allocator); + self.state.preview_content.deinit(self.allocator); + self.state.highlight_spans.deinit(self.allocator); for (self.state.items.items) |item| { self.allocator.free(item); } @@ -310,11 +272,14 @@ const App = struct { self.render_ctx.count_cache.deinit(self.allocator); self.render_ctx.no_match_cache.deinit(self.allocator); self.sdl.font.deinit(); + self.allocator.free(self.sdl.loaded_font_path); // Free font path copy self.sdl.renderer.deinit(); self.sdl.window.deinit(); sdl.ttf.quit(); const quit_flags = sdl.InitFlags{ .video = true, .events = true }; sdl.quit(quit_flags); + // Clean up query cache last + self.query_cache.deinit(); } fn findUtf8Boundary(text: []const u8, max_len: usize) usize { @@ -351,6 +316,215 @@ const App = struct { } } + fn scrollPreviewDown(self: *App) void { + const line_count = preview_mod.countLines(self.state.preview_content.items); + const max_visible = self.config.limits.max_visible_items; + + // Calculate maximum scroll offset (can't scroll past last page) + const max_offset = if (line_count > max_visible) + line_count - max_visible + else + 0; + + if (self.state.preview_scroll_offset < max_offset) { + self.state.preview_scroll_offset += 1; + } + } + + fn scrollPreviewDownPage(self: *App) void { + const line_count = preview_mod.countLines(self.state.preview_content.items); + const max_visible = self.config.limits.max_visible_items; + + const max_offset = if (line_count > max_visible) + line_count - max_visible + else + 0; + + // Scroll down by page size + self.state.preview_scroll_offset = @min( + self.state.preview_scroll_offset + max_visible, + max_offset, + ); + } + + fn loadPreview(self: *App, item_path: []const u8) void { + // Check if preview is enabled + if (!self.state.preview_enabled) { + self.state.preview_state = .none; + return; + } + + // Clear previous preview content and reset scroll + self.state.preview_content.clearRetainingCapacity(); + self.state.preview_scroll_offset = 0; + self.state.preview_state = .loading; + + // Check if it's a known binary file + if (preview_mod.isBinaryFile(item_path)) { + self.state.preview_state = .binary; + self.state.last_previewed_item = item_path; + return; + } + + // Try to open and read the file + const file = std.fs.cwd().openFile(item_path, .{}) catch |err| { + self.state.preview_state = switch (err) { + error.FileNotFound => .not_found, + error.AccessDenied => .permission_denied, + else => .not_found, + }; + self.state.last_previewed_item = item_path; + return; + }; + defer file.close(); + + // Check file size + const file_size = file.getEndPos() catch { + self.state.preview_state = .not_found; + self.state.last_previewed_item = item_path; + return; + }; + + if (file_size > self.config.preview.max_preview_bytes) { + self.state.preview_state = .too_large; + self.state.last_previewed_item = item_path; + return; + } + + // Read file content + const content = file.readToEndAlloc(self.allocator, self.config.preview.max_preview_bytes) catch { + self.state.preview_state = .permission_denied; + self.state.last_previewed_item = item_path; + return; + }; + defer self.allocator.free(content); + + // Check if content is text (no null bytes in first 512 bytes) + const check_len = @min(content.len, 512); + if (!preview_mod.isTextFile(item_path) and preview_mod.containsNullByte(content[0..check_len])) { + self.state.preview_state = .binary; + self.state.last_previewed_item = item_path; + return; + } + + // Copy content to preview buffer (no line limit, only byte limit enforced by file read) + self.state.preview_content.appendSlice(self.allocator, content) catch { + self.state.preview_state = .too_large; + self.state.last_previewed_item = item_path; + return; + }; + + // Apply syntax highlighting + self.state.highlight_spans.clearRetainingCapacity(); + + const colors = syntax_highlight.ColorScheme{ + .background = self.config.colors.background, + .foreground = self.config.colors.foreground, + .selected = self.config.colors.selected, + .prompt = self.config.colors.prompt, + }; + var highlighter = syntax_highlight.SyntaxHighlighter.init(self.allocator, self.query_cache, colors); + highlighter.highlight(item_path, self.state.preview_content.items, &self.state.highlight_spans) catch |err| { + // If highlighting fails, clear spans to prevent stale data + self.state.highlight_spans.clearRetainingCapacity(); + std.debug.print("Warning: Syntax highlighting failed for {s}: {}\n", .{ item_path, err }); + }; + + self.state.preview_state = .text; + self.state.last_previewed_item = item_path; + } + + fn renderHighlightedLine(self: *App, x: f32, y: f32, line: []const u8, line_start_byte: usize) !void { + // If no highlights or line is empty, render normally + if (self.state.highlight_spans.items.len == 0 or line.len == 0) { + if (line.len > 0) { + var line_buffer: [4096]u8 = undefined; + const line_z = std.fmt.bufPrintZ(&line_buffer, "{s}", .{line}) catch return; + self.renderText(x, y, line_z, self.config.colors.foreground) catch {}; + } + return; + } + + const line_end_byte = line_start_byte + line.len; + var current_x = x; + + // Simple approach: find highlight spans and render segments + var pos: usize = 0; + const default_color = self.config.colors.foreground; + + // Create highlighter for color mapping + const colors = syntax_highlight.ColorScheme{ + .background = self.config.colors.background, + .foreground = self.config.colors.foreground, + .selected = self.config.colors.selected, + .prompt = self.config.colors.prompt, + }; + var highlighter = syntax_highlight.SyntaxHighlighter.init(self.allocator, self.query_cache, colors); + + while (pos < line.len) { + // Find if current position is in any highlight span + var found_span: ?syntax_highlight.HighlightSpan = null; + for (self.state.highlight_spans.items) |span| { + const byte_pos = line_start_byte + pos; + if (byte_pos >= span.start_byte and byte_pos < span.end_byte) { + found_span = span; + break; + } + } + + if (found_span) |span| { + // Calculate segment within this span + const start_pos = pos; + const span_relative_end = span.end_byte - line_start_byte; + const end_pos = @min(span_relative_end, line.len); + + // Render highlighted segment + const segment = line[start_pos..end_pos]; + if (segment.len > 0) { + var buffer: [4096]u8 = undefined; + const text_z = std.fmt.bufPrintZ(&buffer, "{s}", .{segment}) catch { + pos = end_pos; + continue; + }; + const color = highlighter.getScopeColor(span.scope); + const w, _ = self.sdl.font.getStringSize(text_z) catch { + pos = end_pos; + continue; + }; + self.renderText(current_x, y, text_z, color) catch {}; + current_x += @floatFromInt(w); + } + pos = end_pos; + } else { + // Find next highlight start or end of line + var next_pos = line.len; + for (self.state.highlight_spans.items) |span| { + if (span.start_byte > line_start_byte + pos and span.start_byte < line_end_byte) { + const span_start_in_line = span.start_byte - line_start_byte; + next_pos = @min(next_pos, span_start_in_line); + } + } + + // Render unhighlighted segment + const segment = line[pos..next_pos]; + if (segment.len > 0) { + var buffer: [4096]u8 = undefined; + const text_z = std.fmt.bufPrintZ(&buffer, "{s}", .{segment}) catch { + pos = next_pos; + continue; + }; + const w, _ = self.sdl.font.getStringSize(text_z) catch { + pos = next_pos; + continue; + }; + self.renderText(current_x, y, text_z, default_color) catch {}; + current_x += @floatFromInt(w); + } + pos = next_pos; + } + } + } + fn updateFilter(self: *App) !void { const prev_filtered_count = self.state.filtered_items.items.len; @@ -423,6 +597,43 @@ const App = struct { } } + fn updatePreview(self: *App) void { + if (!self.state.preview_enabled) return; + if (self.state.filtered_items.items.len == 0) { + self.state.preview_state = .none; + return; + } + + const item_index = self.state.filtered_items.items[self.state.selected_index]; + const item = self.state.items.items[item_index]; + + // Skip if already previewed this item + if (self.state.last_previewed_item) |last| { + if (std.mem.eql(u8, last, item)) return; + } + + const old_line_count = blk: { + var count: usize = 0; + var iter = std.mem.splitScalar(u8, self.state.preview_content.items, '\n'); + while (iter.next()) |_| count += 1; + break :blk count; + }; + + self.loadPreview(item); + + // If preview content line count changed, update window size + const new_line_count = blk: { + var count: usize = 0; + var iter = std.mem.splitScalar(u8, self.state.preview_content.items, '\n'); + while (iter.next()) |_| count += 1; + break :blk count; + }; + + if (old_line_count != new_line_count) { + self.updateWindowSize() catch {}; + } + } + fn navigate(self: *App, delta: isize) void { if (self.state.filtered_items.items.len == 0) return; @@ -432,6 +643,7 @@ const App = struct { if (new_idx >= 0 and new_idx < @as(isize, @intCast(self.state.filtered_items.items.len))) { self.state.selected_index = @intCast(new_idx); self.adjustScroll(); + self.updatePreview(); self.state.needs_render = true; } } @@ -440,6 +652,7 @@ const App = struct { if (self.state.filtered_items.items.len > 0) { self.state.selected_index = 0; self.adjustScroll(); + self.updatePreview(); self.state.needs_render = true; } } @@ -448,6 +661,7 @@ const App = struct { if (self.state.filtered_items.items.len > 0) { self.state.selected_index = self.state.filtered_items.items.len - 1; self.adjustScroll(); + self.updatePreview(); self.state.needs_render = true; } } @@ -539,6 +753,36 @@ const App = struct { } else if (key == .w and (event.mod.left_control or event.mod.right_control)) { // Ctrl+W: Delete last word try self.deleteWord(); + } else if (key == .up and (event.mod.left_alt or event.mod.right_alt)) { + // Alt+Up: Scroll preview up (check this BEFORE regular up) + if (self.state.preview_enabled and self.state.preview_state == .text) { + if (self.state.preview_scroll_offset > 0) { + self.state.preview_scroll_offset -= 1; + self.state.needs_render = true; + } + } + } else if (key == .down and (event.mod.left_alt or event.mod.right_alt)) { + // Alt+Down: Scroll preview down (check this BEFORE regular down) + if (self.state.preview_enabled and self.state.preview_state == .text) { + self.scrollPreviewDown(); + self.state.needs_render = true; + } + } else if (key == .page_up and (event.mod.left_alt or event.mod.right_alt)) { + // Alt+PageUp: Scroll preview up by page (check this BEFORE regular page up) + if (self.state.preview_enabled and self.state.preview_state == .text) { + if (self.state.preview_scroll_offset >= self.config.limits.max_visible_items) { + self.state.preview_scroll_offset -= self.config.limits.max_visible_items; + } else { + self.state.preview_scroll_offset = 0; + } + self.state.needs_render = true; + } + } else if (key == .page_down and (event.mod.left_alt or event.mod.right_alt)) { + // Alt+PageDown: Scroll preview down by page (check this BEFORE regular page down) + if (self.state.preview_enabled and self.state.preview_state == .text) { + self.scrollPreviewDownPage(); + self.state.needs_render = true; + } } else if (key == .up or key == .k) { self.navigate(-1); } else if (key == .down or key == .j) { @@ -560,6 +804,16 @@ const App = struct { self.navigatePage(-1); } else if (key == .page_down) { self.navigatePage(1); + } else if (key == .p and (event.mod.left_control or event.mod.right_control)) { + // Ctrl+P: Toggle preview pane + self.state.preview_enabled = !self.state.preview_enabled; + if (self.state.preview_enabled) { + // Load preview for current selection + self.updatePreview(); + } + // Recalculate window size to fit preview pane + try self.updateWindowSize(); + self.state.needs_render = true; } return false; @@ -576,6 +830,9 @@ const App = struct { } fn renderText(self: *App, x: f32, y: f32, text: [:0]const u8, color: sdl.pixels.Color) !void { + // SDL_ttf cannot render empty strings + if (text.len == 0) return; + // Convert sdl.pixels.Color to sdl.ttf.Color const ttf_color = sdl.ttf.Color{ .r = color.r, .g = color.g, .b = color.b, .a = color.a }; @@ -601,7 +858,7 @@ const App = struct { y: f32, text: [:0]const u8, color: sdl.pixels.Color, - cache: *TextureCache, + cache: *font_mod.TextureCache, ) !void { // Check if we can reuse cached texture const text_changed = !std.mem.eql(u8, cache.last_text, text); @@ -639,6 +896,13 @@ const App = struct { // Apply display scale to all coordinates const scale = self.render_ctx.display_scale; + // Calculate layout dimensions + const window_width = @as(f32, @floatFromInt(self.render_ctx.current_width)); + const items_pane_width = if (self.state.preview_enabled) + window_width * (100.0 - @as(f32, @floatFromInt(self.config.preview.preview_width_percent))) / 100.0 + else + window_width; + // Show prompt with input buffer const prompt_text = if (self.state.input_buffer.items.len > 0) blk: { // Truncate display if input is too long, showing last chars with ellipsis @@ -664,22 +928,40 @@ const App = struct { try self.renderCachedText(5.0 * scale, self.config.layout.prompt_y * scale, prompt_text, self.config.colors.prompt, &self.render_ctx.prompt_cache); - // Show filtered items count - const count_text = std.fmt.bufPrintZ( - self.render_ctx.count_buffer, - "{d}/{d}", - .{ self.state.filtered_items.items.len, self.state.items.items.len }, - ) catch "?/?"; + // Show filtered items count and preview status + const count_text = if (self.state.preview_enabled) + std.fmt.bufPrintZ( + self.render_ctx.count_buffer, + "{d}/{d} Preview: ON", + .{ self.state.filtered_items.items.len, self.state.items.items.len }, + ) catch "?/? Preview: ON" + else + std.fmt.bufPrintZ( + self.render_ctx.count_buffer, + "{d}/{d}", + .{ self.state.filtered_items.items.len, self.state.items.items.len }, + ) catch "?/?"; // Measure actual text width for right-alignment const count_text_w, _ = try self.sdl.font.getStringSize(count_text); - const count_x = (@as(f32, @floatFromInt(self.render_ctx.current_width)) - @as(f32, @floatFromInt(count_text_w)) - self.config.layout.width_padding) * scale; + const count_x = (window_width - @as(f32, @floatFromInt(count_text_w)) - self.config.layout.width_padding) * scale; try self.renderCachedText(count_x, self.config.layout.prompt_y * scale, count_text, self.config.colors.foreground, &self.render_ctx.count_cache); // Cache length to avoid race conditions const filtered_len = self.state.filtered_items.items.len; - // Show multiple items + // Set clipping rectangle for items pane if preview is enabled + if (self.state.preview_enabled) { + const clip_rect = sdl.rect.IRect{ + .x = 0, + .y = 0, + .w = @intFromFloat(items_pane_width * scale), + .h = @intCast(self.render_ctx.pixel_height), + }; + try self.sdl.renderer.setClipRect(clip_rect); + } + + // Show multiple items (in left pane if preview enabled) if (filtered_len > 0) { const visible_end = @min(self.state.scroll_offset + self.config.limits.max_visible_items, filtered_len); @@ -710,7 +992,7 @@ const App = struct { y_pos += self.config.layout.item_line_height * scale; } - // Show scroll indicator if needed + // Show scroll indicator if needed (only in items pane) if (filtered_len > self.config.limits.max_visible_items) { const scroll_text = std.fmt.bufPrintZ( self.render_ctx.scroll_buffer, @@ -718,15 +1000,124 @@ const App = struct { .{ self.state.scroll_offset + 1, visible_end }, ) catch "[?]"; - // Measure actual scroll text width for right-alignment + // Measure actual scroll text width for right-alignment within items pane const scroll_text_w, _ = try self.sdl.font.getStringSize(scroll_text); - const scroll_x = (@as(f32, @floatFromInt(self.render_ctx.current_width)) - @as(f32, @floatFromInt(scroll_text_w)) - self.config.layout.width_padding) * scale; + const scroll_x = (items_pane_width - @as(f32, @floatFromInt(scroll_text_w)) - self.config.layout.width_padding) * scale; try self.renderText(scroll_x, self.config.layout.items_start_y * scale, scroll_text, self.config.colors.foreground); } } else { try self.renderCachedText(5.0 * scale, self.config.layout.items_start_y * scale, "No matches", self.config.colors.foreground, &self.render_ctx.no_match_cache); } + // Clear clipping for preview pane + if (self.state.preview_enabled) { + try self.sdl.renderer.setClipRect(null); + } + + // Render preview pane if enabled + if (self.state.preview_enabled) { + const divider_x = items_pane_width * scale; + const preview_x = (items_pane_width + 10.0) * scale; // 10px padding + const preview_y = self.config.layout.items_start_y * scale; + + // Draw vertical divider line + try self.sdl.renderer.setDrawColor(self.config.colors.foreground); + const line_start = sdl.rect.FPoint{ .x = divider_x, .y = 0 }; + const line_end = sdl.rect.FPoint{ .x = divider_x, .y = @floatFromInt(self.render_ctx.pixel_height) }; + try self.sdl.renderer.renderLine(line_start, line_end); + + // Render preview content based on state + switch (self.state.preview_state) { + .none => { + try self.renderText(preview_x, preview_y, "(no preview available)", self.config.colors.foreground); + }, + .loading => { + try self.renderText(preview_x, preview_y, "Loading preview...", self.config.colors.foreground); + }, + .text => { + // Render preview text line by line with syntax highlighting and scrolling + var line_y = preview_y; + var byte_offset: usize = 0; + var line_index: usize = 0; + var visible_lines: usize = 0; + const max_visible = self.config.limits.max_visible_items; + + var iter = std.mem.splitScalar(u8, self.state.preview_content.items, '\n'); + while (iter.next()) |line| { + // Skip lines before scroll offset + if (line_index < self.state.preview_scroll_offset) { + byte_offset += line.len + 1; // +1 for newline + line_index += 1; + continue; + } + + // Stop if we've rendered max visible lines + if (visible_lines >= max_visible) break; + + // Skip rendering empty lines but still count them + if (line.len == 0) { + line_y += self.config.layout.item_line_height * scale; + byte_offset += 1; // Account for newline character + line_index += 1; + visible_lines += 1; + continue; + } + + // Try to render with syntax highlighting + self.renderHighlightedLine(preview_x, line_y, line, byte_offset) catch { + // If rendering fails, show a placeholder + self.renderText(preview_x, line_y, "[line cannot be displayed]", self.config.colors.foreground) catch {}; + }; + + line_y += self.config.layout.item_line_height * scale; + byte_offset += line.len + 1; // +1 for newline + line_index += 1; + visible_lines += 1; + } + }, + .binary => { + try self.renderText(preview_x, preview_y, "Binary file (no preview)", self.config.colors.foreground); + }, + .not_found => { + try self.renderText(preview_x, preview_y, "File not found", self.config.colors.foreground); + }, + .permission_denied => { + try self.renderText(preview_x, preview_y, "Permission denied", self.config.colors.foreground); + }, + .too_large => { + try self.renderText(preview_x, preview_y, "File too large", self.config.colors.foreground); + }, + } + + // Show scroll indicator for preview if needed + if (self.state.preview_state == .text) { + // Count total lines using consistent method + const total_lines = preview_mod.countLines(self.state.preview_content.items); + + // Show indicator if there are more lines than visible + if (total_lines > self.config.limits.max_visible_items) { + const visible_end = @min( + self.state.preview_scroll_offset + self.config.limits.max_visible_items, + total_lines, + ); + + var scroll_indicator_buffer: [64]u8 = undefined; + const scroll_text = std.fmt.bufPrintZ( + &scroll_indicator_buffer, + "[{d}-{d}/{d}]", + .{ self.state.preview_scroll_offset + 1, visible_end, total_lines }, + ) catch "[?]"; + + // Position in top-right of preview pane, below the prompt line to avoid overlap + const scroll_text_w, _ = try self.sdl.font.getStringSize(scroll_text); + const preview_pane_width = window_width - items_pane_width; + const scroll_x = ((items_pane_width + preview_pane_width - @as(f32, @floatFromInt(scroll_text_w)) / scale) - self.config.layout.width_padding) * scale; + const scroll_y = (self.config.layout.prompt_y + self.config.layout.item_line_height) * scale; + try self.renderText(scroll_x, scroll_y, scroll_text, self.config.colors.foreground); + } + } + } + try self.sdl.renderer.present(); } @@ -776,6 +1167,16 @@ const App = struct { if (total_item_width > max_width) max_width = total_item_width; } + // If preview is enabled, adjust width calculation + // The items pane takes (100 - preview_width_percent)% of the window + // So we need to scale up max_width to account for the preview pane + if (self.state.preview_enabled) { + const items_pane_percent = 100.0 - @as(f32, @floatFromInt(self.config.preview.preview_width_percent)); + // Scale up the required width: if items need X pixels and take Y% of window, + // then total window width = X / (Y / 100) + max_width = max_width * (100.0 / items_pane_percent); + } + // Apply min/max bounds with proper rounding const rounded_width = @as(u32, @intFromFloat(@ceil(max_width))); const final_width = @max(rounded_width, self.config.window.min_width); @@ -791,7 +1192,24 @@ const App = struct { const prompt_area_height = self.config.layout.items_start_y; // Includes prompt + spacing const items_height = @as(f32, @floatFromInt(visible_items)) * self.config.layout.item_line_height; - const total_height = prompt_area_height + items_height + self.config.layout.bottom_margin; + var total_height = prompt_area_height + items_height + self.config.layout.bottom_margin; + + // If preview is enabled and has text content, calculate preview height + if (self.state.preview_enabled and self.state.preview_state == .text) { + // Count lines using consistent method + const line_count = preview_mod.countLines(self.state.preview_content.items); + + // Limit visible preview lines to max_visible_items (for scrolling) + const visible_preview_lines = @min(line_count, self.config.limits.max_visible_items); + + // Calculate preview height based on visible lines only + const preview_height = prompt_area_height + + (@as(f32, @floatFromInt(visible_preview_lines)) * self.config.layout.item_line_height) + + self.config.layout.bottom_margin; + + // Use the maximum of items height and preview height + total_height = @max(total_height, preview_height); + } // Apply min/max bounds with proper rounding const rounded_height = @as(u32, @intFromFloat(@ceil(total_height))); @@ -983,6 +1401,12 @@ test "deleteLastCodepoint - ASCII" { .selected_index = 0, .scroll_offset = 0, .needs_render = false, + .preview_enabled = false, + .preview_content = std.ArrayList(u8).empty, + .preview_state = .none, + .last_previewed_item = null, + .highlight_spans = std.ArrayList(syntax_highlight.HighlightSpan).empty, + .preview_scroll_offset = 0, }, .render_ctx = .{ .prompt_buffer = undefined, @@ -1000,6 +1424,7 @@ test "deleteLastCodepoint - ASCII" { }, .config = Config{}, .allocator = allocator, + .query_cache = undefined, }; try app.state.input_buffer.appendSlice(allocator, "hello"); @@ -1025,6 +1450,12 @@ test "deleteLastCodepoint - UTF-8 multi-byte" { .selected_index = 0, .scroll_offset = 0, .needs_render = false, + .preview_enabled = false, + .preview_content = std.ArrayList(u8).empty, + .preview_state = .none, + .last_previewed_item = null, + .highlight_spans = std.ArrayList(syntax_highlight.HighlightSpan).empty, + .preview_scroll_offset = 0, }, .render_ctx = .{ .prompt_buffer = undefined, @@ -1042,6 +1473,7 @@ test "deleteLastCodepoint - UTF-8 multi-byte" { }, .config = Config{}, .allocator = allocator, + .query_cache = undefined, }; // "café" = c a f é(2 bytes) @@ -1071,6 +1503,12 @@ test "deleteLastCodepoint - UTF-8 three-byte character" { .selected_index = 0, .scroll_offset = 0, .needs_render = false, + .preview_enabled = false, + .preview_content = std.ArrayList(u8).empty, + .preview_state = .none, + .last_previewed_item = null, + .highlight_spans = std.ArrayList(syntax_highlight.HighlightSpan).empty, + .preview_scroll_offset = 0, }, .render_ctx = .{ .prompt_buffer = undefined, @@ -1088,6 +1526,7 @@ test "deleteLastCodepoint - UTF-8 three-byte character" { }, .config = Config{}, .allocator = allocator, + .query_cache = undefined, }; // "日" = 3 bytes @@ -1117,6 +1556,12 @@ test "deleteLastCodepoint - empty buffer" { .selected_index = 0, .scroll_offset = 0, .needs_render = false, + .preview_enabled = false, + .preview_content = std.ArrayList(u8).empty, + .preview_state = .none, + .last_previewed_item = null, + .highlight_spans = std.ArrayList(syntax_highlight.HighlightSpan).empty, + .preview_scroll_offset = 0, }, .render_ctx = .{ .prompt_buffer = undefined, @@ -1134,6 +1579,7 @@ test "deleteLastCodepoint - empty buffer" { }, .config = Config{}, .allocator = allocator, + .query_cache = undefined, }; app.deleteLastCodepoint(); // Should not crash diff --git a/src/preview.zig b/src/preview.zig new file mode 100644 index 0000000..69bd015 --- /dev/null +++ b/src/preview.zig @@ -0,0 +1,239 @@ +const std = @import("std"); + +/// Preview state indicates what kind of content is being shown +pub const PreviewState = enum { + none, // No preview available + loading, // Preview is being loaded + text, // Text preview loaded successfully + binary, // Binary file detected + not_found, // File not found + permission_denied, // Cannot read file + too_large, // File exceeds size limit +}; + +pub const PreviewConfig = struct { + enable_preview: bool = false, // Toggle with Ctrl+P + preview_width_percent: u8 = 70, // Percentage of window width for preview pane + max_preview_bytes: usize = 1024 * 1024, // 1MB max file size to preview +}; + +/// Check if a file path has a known text file extension +pub fn isTextFile(path: []const u8) bool { + // Check file extension for known text types + const text_extensions = [_][]const u8{ + ".txt", ".md", ".markdown", ".rst", ".log", ".csv", ".tsv", + ".zig", ".c", ".h", ".cpp", ".hpp", ".cc", ".cxx", + ".rs", ".go", ".py", ".rb", ".lua", ".sh", ".bash", + ".zsh", ".fish", ".js", ".ts", ".jsx", ".tsx", ".json", + ".xml", ".html", ".htm", ".css", ".scss", ".sass", ".less", + ".yaml", ".yml", ".toml", ".ini", ".conf", ".cfg", ".config", + ".java", ".kt", ".kts", ".scala", ".clj", ".ex", ".exs", + ".el", ".vim", ".vimrc", ".diff", ".patch", ".sql", ".pl", + ".pm", ".r", ".R", ".m", ".mm", ".swift", ".dart", + ".php", ".cs", ".fs", ".fsx", ".ml", ".mli", ".hs", + }; + + // Check if path ends with any text extension (case-insensitive) + for (text_extensions) |ext| { + if (path.len >= ext.len) { + const path_end = path[path.len - ext.len ..]; + if (std.ascii.eqlIgnoreCase(path_end, ext)) { + return true; + } + } + } + + // Special case: files without extension might be text (e.g., Makefile, Dockerfile) + // We'll check content in loadPreview + return false; +} + +/// Check if a file path has a known binary file extension +pub fn isBinaryFile(path: []const u8) bool { + // Check for known binary extensions + const binary_extensions = [_][]const u8{ + ".exe", ".dll", ".so", ".dylib", ".a", ".o", ".bin", + ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", + ".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar", + ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".svg", + ".mp3", ".mp4", ".avi", ".mkv", ".mov", ".wav", ".flac", + ".ttf", ".otf", ".woff", ".woff2", ".eot", + ".class", ".jar", ".pyc", ".pyo", ".wasm", + }; + + for (binary_extensions) |ext| { + if (path.len >= ext.len) { + const path_end = path[path.len - ext.len ..]; + if (std.ascii.eqlIgnoreCase(path_end, ext)) { + return true; + } + } + } + + return false; +} + +/// Check if data contains null bytes (indicating binary content) +pub fn containsNullByte(data: []const u8) bool { + for (data) |byte| { + if (byte == 0) return true; + } + return false; +} + +/// Count the number of lines in content +/// Handles files with and without trailing newlines correctly +pub fn countLines(content: []const u8) usize { + if (content.len == 0) return 0; + + var count: usize = 0; + for (content) |byte| { + if (byte == '\n') count += 1; + } + + // Add 1 if content doesn't end with newline + if (content[content.len - 1] != '\n') { + count += 1; + } + + return count; +} + +/// Read file contents up to max_bytes limit +pub fn readFileContent(allocator: std.mem.Allocator, path: []const u8, max_bytes: usize) ![]u8 { + const file = try std.fs.cwd().openFile(path, .{}); + defer file.close(); + + const stat = try file.stat(); + if (stat.size > max_bytes) { + return error.FileTooLarge; + } + + const content = try file.readToEndAlloc(allocator, max_bytes); + return content; +} + +// ============================================================================ +// Tests +// ============================================================================ + +const testing = std.testing; + +test "countLines - empty content" { + try testing.expectEqual(@as(usize, 0), countLines("")); +} + +test "countLines - single line without newline" { + try testing.expectEqual(@as(usize, 1), countLines("hello")); +} + +test "countLines - single line with newline" { + try testing.expectEqual(@as(usize, 1), countLines("hello\n")); +} + +test "countLines - multiple lines" { + try testing.expectEqual(@as(usize, 3), countLines("line1\nline2\nline3")); + try testing.expectEqual(@as(usize, 3), countLines("line1\nline2\nline3\n")); +} + +test "countLines - UTF-8 content" { + try testing.expectEqual(@as(usize, 3), countLines("日本語\nCafé\n🎉\n")); +} + +test "isTextFile - text extensions" { + try testing.expect(isTextFile("file.txt")); + try testing.expect(isTextFile("file.zig")); + try testing.expect(isTextFile("file.c")); + try testing.expect(isTextFile("file.py")); + try testing.expect(isTextFile("file.json")); +} + +test "isTextFile - case insensitive" { + try testing.expect(isTextFile("FILE.TXT")); + try testing.expect(isTextFile("File.Zig")); +} + +test "isTextFile - with paths" { + try testing.expect(isTextFile("/home/user/file.zig")); + try testing.expect(isTextFile("./src/main.c")); +} + +test "isTextFile - binary extensions" { + try testing.expect(!isTextFile("file.exe")); + try testing.expect(!isTextFile("file.png")); +} + +test "isBinaryFile - binary extensions" { + try testing.expect(isBinaryFile("file.exe")); + try testing.expect(isBinaryFile("file.pdf")); + try testing.expect(isBinaryFile("file.png")); + try testing.expect(isBinaryFile("file.zip")); +} + +test "isBinaryFile - case insensitive" { + try testing.expect(isBinaryFile("FILE.EXE")); + try testing.expect(isBinaryFile("Image.PNG")); +} + +test "isBinaryFile - text files" { + try testing.expect(!isBinaryFile("file.txt")); + try testing.expect(!isBinaryFile("file.zig")); +} + +test "containsNullByte - pure text" { + try testing.expect(!containsNullByte("Hello, World!")); +} + +test "containsNullByte - with null" { + const data = [_]u8{ 'h', 'e', 0, 'l', 'o' }; + try testing.expect(containsNullByte(&data)); +} + +test "containsNullByte - empty" { + try testing.expect(!containsNullByte("")); +} + +test "PreviewConfig - defaults" { + const config = PreviewConfig{}; + try testing.expect(!config.enable_preview); + try testing.expectEqual(@as(u8, 70), config.preview_width_percent); + try testing.expectEqual(@as(usize, 1024 * 1024), config.max_preview_bytes); +} + +test "readFileContent - file too large" { + const allocator = testing.allocator; + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + // Create a 2KB file + var buffer: [2000]u8 = undefined; + @memset(&buffer, 'x'); + const file = try tmp_dir.dir.createFile("large.txt", .{}); + defer file.close(); + try file.writeAll(&buffer); + + const path = try tmp_dir.dir.realpathAlloc(allocator, "large.txt"); + defer allocator.free(path); + + // Try to read with 1KB limit + try testing.expectError(error.FileTooLarge, readFileContent(allocator, path, 1000)); +} + +test "readFileContent - successful read" { + const allocator = testing.allocator; + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const test_content = "Hello, World!"; + const file = try tmp_dir.dir.createFile("test.txt", .{}); + defer file.close(); + try file.writeAll(test_content); + + const path = try tmp_dir.dir.realpathAlloc(allocator, "test.txt"); + defer allocator.free(path); + + const content = try readFileContent(allocator, path, 1024); + defer allocator.free(content); + + try testing.expectEqualStrings(test_content, content); +} diff --git a/src/syntax_highlight.zig b/src/syntax_highlight.zig new file mode 100644 index 0000000..abaf6ff --- /dev/null +++ b/src/syntax_highlight.zig @@ -0,0 +1,237 @@ +const std = @import("std"); +const sdl = @import("sdl3"); +const syntax_mod = @import("syntax"); + +/// Represents a highlighted region in the source code +pub const HighlightSpan = struct { + start_byte: usize, + end_byte: usize, + scope: []const u8, +}; + +/// Color scheme for syntax highlighting +pub const ColorScheme = struct { + background: sdl.pixels.Color, + foreground: sdl.pixels.Color, + selected: sdl.pixels.Color, + prompt: sdl.pixels.Color, +}; + +/// Syntax highlighter that uses tree-sitter via flow-syntax +pub const SyntaxHighlighter = struct { + allocator: std.mem.Allocator, + query_cache: *syntax_mod.QueryCache, + colors: ColorScheme, + + pub fn init(allocator: std.mem.Allocator, query_cache: *syntax_mod.QueryCache, colors: ColorScheme) SyntaxHighlighter { + return .{ + .allocator = allocator, + .query_cache = query_cache, + .colors = colors, + }; + } + + /// Apply syntax highlighting to content and collect highlight spans + pub fn highlight(self: *SyntaxHighlighter, file_path: []const u8, content: []const u8, spans: *std.ArrayList(HighlightSpan)) !void { + // Try to create syntax highlighter for this file + const syntax = syntax_mod.create_guess_file_type_static( + self.allocator, + content, + file_path, + self.query_cache, + ) catch return; // If we can't highlight, just return without error + defer syntax.destroy(self.query_cache); + + // Parse the content + syntax.refresh_full(content) catch return; + + // Collect highlight spans using a callback + const Context = struct { + span_list: *std.ArrayList(HighlightSpan), + alloc: std.mem.Allocator, + + fn callback( + ctx: *@This(), + range: syntax_mod.Range, + scope: []const u8, + id: u32, + capture_idx: usize, + node: *const syntax_mod.Node, + ) error{Stop}!void { + _ = id; + _ = capture_idx; + _ = node; + + ctx.span_list.append(ctx.alloc, .{ + .start_byte = range.start_byte, + .end_byte = range.end_byte, + .scope = scope, + }) catch return error.Stop; + } + }; + var ctx = Context{ + .span_list = spans, + .alloc = self.allocator, + }; + + syntax.render(&ctx, Context.callback, null) catch return; + } + + /// Map tree-sitter scope to a color from the color scheme + pub fn getScopeColor(self: *SyntaxHighlighter, scope: []const u8) sdl.pixels.Color { + // Keywords, control flow + if (std.mem.indexOf(u8, scope, "keyword") != null or + std.mem.indexOf(u8, scope, "conditional") != null or + std.mem.indexOf(u8, scope, "repeat") != null or + std.mem.indexOf(u8, scope, "include") != null) + { + return self.colors.selected; // Use selected color for keywords + } + + // Strings, characters + if (std.mem.indexOf(u8, scope, "string") != null or + std.mem.indexOf(u8, scope, "character") != null) + { + return self.colors.prompt; // Use prompt color for strings + } + + // Comments (create a muted version of foreground) + if (std.mem.indexOf(u8, scope, "comment") != null) { + const fg = self.colors.foreground; + return sdl.pixels.Color{ + .r = @intCast(@as(u16, fg.r) * 7 / 10), + .g = @intCast(@as(u16, fg.g) * 7 / 10), + .b = @intCast(@as(u16, fg.b) * 7 / 10), + .a = fg.a, + }; + } + + // Functions + if (std.mem.indexOf(u8, scope, "function") != null) { + return self.colors.prompt; // Use prompt color for functions + } + + // Types, constants, numbers + if (std.mem.indexOf(u8, scope, "type") != null or + std.mem.indexOf(u8, scope, "constant") != null or + std.mem.indexOf(u8, scope, "number") != null) + { + return self.colors.selected; // Use selected color for types + } + + // Default to foreground color + return self.colors.foreground; + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +const testing = std.testing; + +test "HighlightSpan - basic struct" { + const span = HighlightSpan{ + .start_byte = 0, + .end_byte = 10, + .scope = "keyword", + }; + try testing.expectEqual(@as(usize, 0), span.start_byte); + try testing.expectEqual(@as(usize, 10), span.end_byte); + try testing.expectEqualStrings("keyword", span.scope); +} + +test "ColorScheme - create scheme" { + const colors = ColorScheme{ + .background = sdl.pixels.Color{ .r = 0, .g = 0, .b = 0, .a = 255 }, + .foreground = sdl.pixels.Color{ .r = 255, .g = 255, .b = 255, .a = 255 }, + .selected = sdl.pixels.Color{ .r = 255, .g = 0, .b = 255, .a = 255 }, + .prompt = sdl.pixels.Color{ .r = 0, .g = 255, .b = 255, .a = 255 }, + }; + try testing.expectEqual(@as(u8, 0), colors.background.r); + try testing.expectEqual(@as(u8, 255), colors.foreground.r); +} + +test "SyntaxHighlighter - getScopeColor keyword" { + const allocator = testing.allocator; + const query_cache = try syntax_mod.QueryCache.create(allocator, .{}); + defer query_cache.deinit(); + + const colors = ColorScheme{ + .background = sdl.pixels.Color{ .r = 0, .g = 0, .b = 0, .a = 255 }, + .foreground = sdl.pixels.Color{ .r = 200, .g = 200, .b = 200, .a = 255 }, + .selected = sdl.pixels.Color{ .r = 255, .g = 0, .b = 255, .a = 255 }, + .prompt = sdl.pixels.Color{ .r = 0, .g = 255, .b = 255, .a = 255 }, + }; + + var highlighter = SyntaxHighlighter.init(allocator, query_cache, colors); + + // Keywords should use selected color + const keyword_color = highlighter.getScopeColor("keyword"); + try testing.expectEqual(colors.selected.r, keyword_color.r); + try testing.expectEqual(colors.selected.g, keyword_color.g); + try testing.expectEqual(colors.selected.b, keyword_color.b); +} + +test "SyntaxHighlighter - getScopeColor string" { + const allocator = testing.allocator; + const query_cache = try syntax_mod.QueryCache.create(allocator, .{}); + defer query_cache.deinit(); + + const colors = ColorScheme{ + .background = sdl.pixels.Color{ .r = 0, .g = 0, .b = 0, .a = 255 }, + .foreground = sdl.pixels.Color{ .r = 200, .g = 200, .b = 200, .a = 255 }, + .selected = sdl.pixels.Color{ .r = 255, .g = 0, .b = 255, .a = 255 }, + .prompt = sdl.pixels.Color{ .r = 0, .g = 255, .b = 255, .a = 255 }, + }; + + var highlighter = SyntaxHighlighter.init(allocator, query_cache, colors); + + // Strings should use prompt color + const string_color = highlighter.getScopeColor("string"); + try testing.expectEqual(colors.prompt.r, string_color.r); + try testing.expectEqual(colors.prompt.g, string_color.g); + try testing.expectEqual(colors.prompt.b, string_color.b); +} + +test "SyntaxHighlighter - getScopeColor comment" { + const allocator = testing.allocator; + const query_cache = try syntax_mod.QueryCache.create(allocator, .{}); + defer query_cache.deinit(); + + const colors = ColorScheme{ + .background = sdl.pixels.Color{ .r = 0, .g = 0, .b = 0, .a = 255 }, + .foreground = sdl.pixels.Color{ .r = 200, .g = 200, .b = 200, .a = 255 }, + .selected = sdl.pixels.Color{ .r = 255, .g = 0, .b = 255, .a = 255 }, + .prompt = sdl.pixels.Color{ .r = 0, .g = 255, .b = 255, .a = 255 }, + }; + + var highlighter = SyntaxHighlighter.init(allocator, query_cache, colors); + + // Comments should use dimmed foreground (70% brightness) + const comment_color = highlighter.getScopeColor("comment"); + try testing.expectEqual(@as(u8, 140), comment_color.r); // 200 * 7/10 = 140 + try testing.expectEqual(@as(u8, 140), comment_color.g); + try testing.expectEqual(@as(u8, 140), comment_color.b); +} + +test "SyntaxHighlighter - getScopeColor default" { + const allocator = testing.allocator; + const query_cache = try syntax_mod.QueryCache.create(allocator, .{}); + defer query_cache.deinit(); + + const colors = ColorScheme{ + .background = sdl.pixels.Color{ .r = 0, .g = 0, .b = 0, .a = 255 }, + .foreground = sdl.pixels.Color{ .r = 200, .g = 200, .b = 200, .a = 255 }, + .selected = sdl.pixels.Color{ .r = 255, .g = 0, .b = 255, .a = 255 }, + .prompt = sdl.pixels.Color{ .r = 0, .g = 255, .b = 255, .a = 255 }, + }; + + var highlighter = SyntaxHighlighter.init(allocator, query_cache, colors); + + // Unknown scopes should use foreground + const default_color = highlighter.getScopeColor("unknown_scope"); + try testing.expectEqual(colors.foreground.r, default_color.r); + try testing.expectEqual(colors.foreground.g, default_color.g); + try testing.expectEqual(colors.foreground.b, default_color.b); +}