Skip to content
Open
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
62 changes: 57 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -53,16 +54,25 @@ 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

### 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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:**
Expand Down
11 changes: 11 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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(.{
Expand All @@ -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);

Expand All @@ -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"),
Expand All @@ -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);

Expand Down
4 changes: 4 additions & 0 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
223 changes: 222 additions & 1 deletion lesson-learned.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
**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.
Loading