tenseleyflow/fackr / d92e9d9

Browse files

feat(render): add fuss header, git indicators, and multi-pane rendering

- Add fuss mode header with repo:branch (cyan:yellow coloring)
- Render git status indicators: ↑ staged, ✗ unstaged, ? untracked, ↓ incoming
- Gitignored files rendered in dark gray
- Add render_panes for multi-pane layout with separators
- Add render_with_offset for fuss sidebar support
- Add tab bar rendering with modified indicators
- Update fuss hints to show git operation keybindings
Authored by espadonne
SHA
d92e9d9588b65f9e1b036cffbce22b8f2a2afa6d
Parents
beba869
Tree
9748921

2 changed files

StatusFile+-
M src/render/mod.rs 1 1
M src/render/screen.rs 768 30
src/render/mod.rsmodified
@@ -1,3 +1,3 @@
11
 mod screen;
22
 
3
-pub use screen::Screen;
3
+pub use screen::{PaneBounds, PaneInfo, Screen, TabInfo};
src/render/screen.rsmodified
@@ -23,6 +23,50 @@ const CURRENT_LINE_NUM_COLOR: Color = Color::Yellow; // Yellow for active li
2323
 const BRACKET_MATCH_BG: Color = Color::AnsiValue(240);   // Highlight for matching brackets
2424
 // Secondary cursors use Color::Magenta for visibility
2525
 
26
+// Tab bar colors
27
+const TAB_BAR_BG: Color = Color::AnsiValue(235);         // Slightly lighter than editor bg
28
+const TAB_ACTIVE_BG: Color = Color::AnsiValue(238);      // Active tab background
29
+const TAB_INACTIVE_FG: Color = Color::AnsiValue(245);    // Inactive tab text
30
+const TAB_ACTIVE_FG: Color = Color::White;               // Active tab text
31
+const TAB_MODIFIED_FG: Color = Color::Yellow;            // Modified indicator
32
+
33
+/// Tab information for rendering
34
+pub struct TabInfo {
35
+    pub name: String,
36
+    pub is_active: bool,
37
+    pub is_modified: bool,
38
+    pub index: usize,
39
+}
40
+
41
+/// Pane information for rendering
42
+pub struct PaneInfo<'a> {
43
+    pub buffer: &'a Buffer,
44
+    pub cursors: &'a Cursors,
45
+    pub viewport_line: usize,
46
+    pub bounds: PaneBounds,
47
+    pub is_active: bool,
48
+    pub bracket_match: Option<(usize, usize)>,
49
+    pub is_modified: bool,
50
+}
51
+
52
+/// Normalized pane bounds (0.0 to 1.0)
53
+#[derive(Debug, Clone)]
54
+pub struct PaneBounds {
55
+    pub x_start: f32,
56
+    pub y_start: f32,
57
+    pub x_end: f32,
58
+    pub y_end: f32,
59
+}
60
+
61
+// Pane colors
62
+const PANE_SEPARATOR_FG: Color = Color::AnsiValue(240);
63
+const PANE_ACTIVE_SEPARATOR_FG: Color = Color::AnsiValue(250);
64
+// Inactive pane uses darker colors
65
+const INACTIVE_BG_COLOR: Color = Color::AnsiValue(233);        // Darker than active
66
+const INACTIVE_CURRENT_LINE_BG: Color = Color::AnsiValue(234); // Dimmed current line
67
+const INACTIVE_LINE_NUM_COLOR: Color = Color::AnsiValue(240);  // Dimmed line numbers
68
+const INACTIVE_TEXT_COLOR: Color = Color::AnsiValue(245);      // Dimmed text
69
+
2670
 /// Terminal screen renderer
2771
 pub struct Screen {
2872
     stdout: Stdout,
@@ -85,7 +129,373 @@ impl Screen {
85129
         Ok(())
86130
     }
87131
 
88
-    /// Render the editor view
132
+    /// Render the tab bar
133
+    /// Returns the height of the tab bar (1 if rendered, 0 if only one tab)
134
+    pub fn render_tab_bar(&mut self, tabs: &[TabInfo], left_offset: u16) -> Result<u16> {
135
+        // Only show tab bar if there's more than one tab
136
+        if tabs.len() <= 1 {
137
+            return Ok(0);
138
+        }
139
+
140
+        execute!(self.stdout, MoveTo(left_offset, 0))?;
141
+
142
+        // Fill the tab bar background
143
+        let available_width = self.cols.saturating_sub(left_offset) as usize;
144
+        execute!(
145
+            self.stdout,
146
+            SetBackgroundColor(TAB_BAR_BG),
147
+            SetForegroundColor(TAB_INACTIVE_FG),
148
+        )?;
149
+
150
+        // Calculate max width per tab
151
+        let tab_count = tabs.len();
152
+        let separators = tab_count.saturating_sub(1);
153
+        let available_for_tabs = available_width.saturating_sub(separators);
154
+        let max_tab_width = (available_for_tabs / tab_count).max(3); // At least 3 chars per tab
155
+
156
+        let mut current_col = left_offset as usize;
157
+
158
+        for (i, tab) in tabs.iter().enumerate() {
159
+            // Build tab label: [index] name [*]
160
+            let index_str = if tab.index < 9 {
161
+                format!("{}", tab.index + 1)
162
+            } else {
163
+                String::new()
164
+            };
165
+
166
+            let modified_str = if tab.is_modified { "*" } else { "" };
167
+
168
+            // Calculate available space for name
169
+            let prefix_len = if index_str.is_empty() { 0 } else { index_str.len() + 1 }; // "1 "
170
+            let suffix_len = modified_str.len();
171
+            let name_max = max_tab_width.saturating_sub(prefix_len + suffix_len);
172
+
173
+            // Truncate name if needed
174
+            let display_name: String = if tab.name.len() > name_max {
175
+                tab.name.chars().take(name_max.saturating_sub(1)).collect::<String>() + "…"
176
+            } else {
177
+                tab.name.clone()
178
+            };
179
+
180
+            // Set colors based on active state
181
+            let (bg, fg) = if tab.is_active {
182
+                (TAB_ACTIVE_BG, TAB_ACTIVE_FG)
183
+            } else {
184
+                (TAB_BAR_BG, TAB_INACTIVE_FG)
185
+            };
186
+
187
+            execute!(
188
+                self.stdout,
189
+                MoveTo(current_col as u16, 0),
190
+                SetBackgroundColor(bg),
191
+            )?;
192
+
193
+            // Print index number (for Alt+N shortcut hint)
194
+            if !index_str.is_empty() {
195
+                execute!(
196
+                    self.stdout,
197
+                    SetForegroundColor(LINE_NUM_COLOR),
198
+                    Print(&index_str),
199
+                    Print(" "),
200
+                )?;
201
+            }
202
+
203
+            // Print tab name
204
+            execute!(
205
+                self.stdout,
206
+                SetForegroundColor(fg),
207
+                Print(&display_name),
208
+            )?;
209
+
210
+            // Print modified indicator
211
+            if tab.is_modified {
212
+                execute!(
213
+                    self.stdout,
214
+                    SetForegroundColor(TAB_MODIFIED_FG),
215
+                    Print(modified_str),
216
+                )?;
217
+            }
218
+
219
+            current_col += prefix_len + display_name.len() + suffix_len;
220
+
221
+            // Add separator between tabs
222
+            if i + 1 < tab_count {
223
+                execute!(
224
+                    self.stdout,
225
+                    SetBackgroundColor(TAB_BAR_BG),
226
+                    SetForegroundColor(LINE_NUM_COLOR),
227
+                    Print("│"),
228
+                )?;
229
+                current_col += 1;
230
+            }
231
+        }
232
+
233
+        // Fill the rest of the line
234
+        execute!(
235
+            self.stdout,
236
+            SetBackgroundColor(TAB_BAR_BG),
237
+            Clear(ClearType::UntilNewLine),
238
+            ResetColor,
239
+        )?;
240
+
241
+        Ok(1)
242
+    }
243
+
244
+    /// Render multiple panes with their separators
245
+    /// Returns the position of the hardware cursor (for the active pane)
246
+    pub fn render_panes(
247
+        &mut self,
248
+        panes: &[PaneInfo],
249
+        filename: Option<&str>,
250
+        message: Option<&str>,
251
+        left_offset: u16,
252
+        top_offset: u16,
253
+    ) -> Result<()> {
254
+        execute!(self.stdout, Hide)?;
255
+
256
+        // Calculate available screen area
257
+        let available_width = self.cols.saturating_sub(left_offset) as f32;
258
+        let available_height = self.rows.saturating_sub(1 + top_offset) as f32; // -1 for status bar
259
+
260
+        // Track where to place the hardware cursor (active pane's primary cursor)
261
+        let mut cursor_screen_pos: Option<(u16, u16)> = None;
262
+
263
+        for pane in panes {
264
+            // Convert normalized bounds to screen coordinates
265
+            let pane_x = left_offset + (pane.bounds.x_start * available_width) as u16;
266
+            let pane_y = top_offset + (pane.bounds.y_start * available_height) as u16;
267
+            let pane_width = ((pane.bounds.x_end - pane.bounds.x_start) * available_width) as u16;
268
+            let pane_height = ((pane.bounds.y_end - pane.bounds.y_start) * available_height) as u16;
269
+
270
+            // Render this pane
271
+            let cursor_pos = self.render_single_pane(
272
+                pane,
273
+                pane_x,
274
+                pane_y,
275
+                pane_width,
276
+                pane_height,
277
+            )?;
278
+
279
+            // Track active pane's cursor position
280
+            if pane.is_active {
281
+                cursor_screen_pos = cursor_pos;
282
+            }
283
+
284
+            // Draw separator on the left edge if not at left boundary
285
+            if pane.bounds.x_start > 0.01 {
286
+                let sep_x = pane_x.saturating_sub(1);
287
+                let sep_color = if pane.is_active { PANE_ACTIVE_SEPARATOR_FG } else { PANE_SEPARATOR_FG };
288
+                for row in 0..pane_height {
289
+                    execute!(
290
+                        self.stdout,
291
+                        MoveTo(sep_x, pane_y + row),
292
+                        SetBackgroundColor(BG_COLOR),
293
+                        SetForegroundColor(sep_color),
294
+                        Print("│"),
295
+                    )?;
296
+                }
297
+            }
298
+
299
+            // Draw separator on the top edge if not at top boundary
300
+            if pane.bounds.y_start > 0.01 {
301
+                let sep_y = pane_y.saturating_sub(1);
302
+                let sep_color = if pane.is_active { PANE_ACTIVE_SEPARATOR_FG } else { PANE_SEPARATOR_FG };
303
+                for col in 0..pane_width {
304
+                    execute!(
305
+                        self.stdout,
306
+                        MoveTo(pane_x + col, sep_y),
307
+                        SetBackgroundColor(BG_COLOR),
308
+                        SetForegroundColor(sep_color),
309
+                        Print("─"),
310
+                    )?;
311
+                }
312
+            }
313
+        }
314
+
315
+        // Render status bar (use active pane's info)
316
+        if let Some(active_pane) = panes.iter().find(|p| p.is_active) {
317
+            self.render_status_bar_with_offset(
318
+                active_pane.cursors,
319
+                filename,
320
+                message,
321
+                left_offset,
322
+                active_pane.is_modified,
323
+            )?;
324
+        }
325
+
326
+        // Position hardware cursor
327
+        if let Some((col, row)) = cursor_screen_pos {
328
+            execute!(self.stdout, MoveTo(col, row), Show)?;
329
+        }
330
+
331
+        self.stdout.flush()?;
332
+        Ok(())
333
+    }
334
+
335
+    /// Render a single pane within its screen bounds
336
+    /// Returns the screen position of the primary cursor if this is the active pane
337
+    fn render_single_pane(
338
+        &mut self,
339
+        pane: &PaneInfo,
340
+        x: u16,
341
+        y: u16,
342
+        width: u16,
343
+        height: u16,
344
+    ) -> Result<Option<(u16, u16)>> {
345
+        let buffer = pane.buffer;
346
+        let cursors = pane.cursors;
347
+        let is_active = pane.is_active;
348
+
349
+        // Choose colors based on active state
350
+        let bg_color = if is_active { BG_COLOR } else { INACTIVE_BG_COLOR };
351
+        let current_line_bg = if is_active { CURRENT_LINE_BG } else { INACTIVE_CURRENT_LINE_BG };
352
+        let line_num_color = if is_active { LINE_NUM_COLOR } else { INACTIVE_LINE_NUM_COLOR };
353
+        let current_line_num_color = if is_active { CURRENT_LINE_NUM_COLOR } else { INACTIVE_LINE_NUM_COLOR };
354
+        let text_color = if is_active { Color::Reset } else { INACTIVE_TEXT_COLOR };
355
+
356
+        let line_num_width = self.line_number_width(buffer.line_count());
357
+        let text_cols = (width as usize).saturating_sub(line_num_width + 1);
358
+
359
+        let primary = cursors.primary();
360
+
361
+        // Collect selections and cursor positions (only show in active pane)
362
+        let selections: Vec<(Position, Position)> = if is_active {
363
+            cursors.all()
364
+                .iter()
365
+                .filter_map(|c| c.selection_bounds())
366
+                .collect()
367
+        } else {
368
+            Vec::new()
369
+        };
370
+
371
+        let primary_idx = cursors.primary_index();
372
+        let cursor_positions: Vec<(usize, usize, bool)> = if is_active {
373
+            cursors.all()
374
+                .iter()
375
+                .enumerate()
376
+                .map(|(i, c)| (c.line, c.col, i == primary_idx))
377
+                .collect()
378
+        } else {
379
+            Vec::new()
380
+        };
381
+
382
+        // Draw text area
383
+        for row in 0..height as usize {
384
+            let line_idx = pane.viewport_line + row;
385
+            let is_current_line = line_idx == primary.line;
386
+            execute!(self.stdout, MoveTo(x, y + row as u16))?;
387
+
388
+            if line_idx < buffer.line_count() {
389
+                let line_num_fg = if is_current_line {
390
+                    current_line_num_color
391
+                } else {
392
+                    line_num_color
393
+                };
394
+                let line_bg = if is_current_line { current_line_bg } else { bg_color };
395
+
396
+                execute!(
397
+                    self.stdout,
398
+                    SetBackgroundColor(line_bg),
399
+                    SetForegroundColor(line_num_fg),
400
+                    Print(format!("{:>width$} ", line_idx + 1, width = line_num_width)),
401
+                )?;
402
+
403
+                if let Some(line) = buffer.line_str(line_idx) {
404
+                    if is_active {
405
+                        // Active pane: full highlighting
406
+                        let bracket_col = pane.bracket_match
407
+                            .filter(|(bl, _)| *bl == line_idx)
408
+                            .map(|(_, bc)| bc);
409
+
410
+                        let secondary_cursors: Vec<usize> = cursor_positions.iter()
411
+                            .filter(|(l, _, is_primary)| *l == line_idx && !*is_primary)
412
+                            .map(|(_, c, _)| *c)
413
+                            .collect();
414
+
415
+                        self.render_line_with_cursors_bounded(
416
+                            &line,
417
+                            line_idx,
418
+                            text_cols,
419
+                            &selections,
420
+                            is_current_line,
421
+                            bracket_col,
422
+                            &secondary_cursors,
423
+                        )?;
424
+                    } else {
425
+                        // Inactive pane: simple dimmed text
426
+                        let chars: String = line.chars().take(text_cols).collect();
427
+                        execute!(
428
+                            self.stdout,
429
+                            SetBackgroundColor(line_bg),
430
+                            SetForegroundColor(text_color),
431
+                            Print(&chars),
432
+                        )?;
433
+                    }
434
+                }
435
+
436
+                // Fill rest of pane width
437
+                execute!(
438
+                    self.stdout,
439
+                    SetBackgroundColor(line_bg),
440
+                )?;
441
+                let line_len = buffer.line_str(line_idx).map(|l| l.len()).unwrap_or(0);
442
+                let current_col = x + line_num_width as u16 + 1 + text_cols.min(line_len) as u16;
443
+                let remaining = (x + width).saturating_sub(current_col);
444
+                if remaining > 0 {
445
+                    execute!(self.stdout, Print(" ".repeat(remaining as usize)))?;
446
+                }
447
+                execute!(self.stdout, ResetColor)?;
448
+            } else {
449
+                execute!(
450
+                    self.stdout,
451
+                    SetBackgroundColor(bg_color),
452
+                    SetForegroundColor(if is_active { Color::DarkBlue } else { INACTIVE_LINE_NUM_COLOR }),
453
+                    Print(format!("{:>width$} ", "~", width = line_num_width)),
454
+                )?;
455
+                // Fill rest of line within pane bounds
456
+                let remaining = width.saturating_sub(line_num_width as u16 + 1);
457
+                execute!(self.stdout, Print(" ".repeat(remaining as usize)), ResetColor)?;
458
+            }
459
+        }
460
+
461
+        // Return cursor position if this is the active pane
462
+        if pane.is_active {
463
+            let cursor_row = primary.line.saturating_sub(pane.viewport_line);
464
+            if cursor_row < height as usize {
465
+                let cursor_screen_row = y + cursor_row as u16;
466
+                let cursor_screen_col = x + line_num_width as u16 + 1 + primary.col as u16;
467
+                return Ok(Some((cursor_screen_col, cursor_screen_row)));
468
+            }
469
+        }
470
+
471
+        Ok(None)
472
+    }
473
+
474
+    /// Render line with cursors, bounded to a specific width
475
+    fn render_line_with_cursors_bounded(
476
+        &mut self,
477
+        line: &str,
478
+        line_idx: usize,
479
+        max_cols: usize,
480
+        selections: &[(Position, Position)],
481
+        is_current_line: bool,
482
+        bracket_col: Option<usize>,
483
+        secondary_cursors: &[usize],
484
+    ) -> Result<()> {
485
+        // Delegate to existing method - it already handles max_cols
486
+        self.render_line_with_cursors(
487
+            line,
488
+            line_idx,
489
+            max_cols,
490
+            selections,
491
+            is_current_line,
492
+            bracket_col,
493
+            secondary_cursors,
494
+        )
495
+    }
496
+
497
+    /// Render the editor view (without offsets - use render_with_offset instead)
498
+    #[allow(dead_code)]
89499
     pub fn render(
90500
         &mut self,
91501
         buffer: &Buffer,
@@ -300,6 +710,7 @@ impl Screen {
300710
         Ok(())
301711
     }
302712
 
713
+    #[allow(dead_code)]
303714
     fn render_status_bar(
304715
         &mut self,
305716
         buffer: &Buffer,
@@ -368,21 +779,83 @@ impl Screen {
368779
         scroll: usize,
369780
         width: u16,
370781
         hints_expanded: bool,
782
+        repo_name: &str,
783
+        branch: Option<&str>,
371784
     ) -> Result<()> {
372785
         let width = width as usize;
373786
         let text_rows = self.rows.saturating_sub(1) as usize;
374787
         let hint_rows = if hints_expanded { 4 } else { 1 };
375
-        let tree_rows = text_rows.saturating_sub(hint_rows);
788
+        let header_rows = 2; // Header line + separator
789
+        let tree_rows = text_rows.saturating_sub(hint_rows + header_rows);
790
+
791
+        // Draw header: repo_name:branch
792
+        execute!(self.stdout, MoveTo(0, 0))?;
793
+        let header_text = if let Some(b) = branch {
794
+            format!("{}:{}", repo_name, b)
795
+        } else {
796
+            repo_name.to_string()
797
+        };
798
+        let truncated: String = header_text.chars().take(width.saturating_sub(1)).collect();
799
+        let padded = format!("{:<width$}", truncated, width = width);
376800
 
377
-        // Draw file tree
801
+        // Render header with cyan repo name, yellow branch
802
+        execute!(
803
+            self.stdout,
804
+            SetBackgroundColor(BG_COLOR),
805
+            SetForegroundColor(Color::Cyan),
806
+        )?;
807
+        if let Some(b) = branch {
808
+            let repo_display: String = repo_name.chars().take(width.saturating_sub(1)).collect();
809
+            execute!(self.stdout, Print(&repo_display))?;
810
+            execute!(
811
+                self.stdout,
812
+                SetForegroundColor(Color::DarkGrey),
813
+                Print(":"),
814
+                SetForegroundColor(Color::Yellow),
815
+            )?;
816
+            let remaining = width.saturating_sub(repo_display.len() + 1);
817
+            let branch_display: String = b.chars().take(remaining).collect();
818
+            let branch_padded = format!("{:<width$}", branch_display, width = remaining);
819
+            execute!(self.stdout, Print(&branch_padded))?;
820
+        } else {
821
+            execute!(self.stdout, Print(&padded))?;
822
+        }
823
+        execute!(self.stdout, ResetColor)?;
824
+
825
+        // Draw separator
826
+        execute!(self.stdout, MoveTo(0, 1))?;
827
+        let separator = "─".repeat(width);
828
+        execute!(
829
+            self.stdout,
830
+            SetBackgroundColor(BG_COLOR),
831
+            SetForegroundColor(Color::DarkGrey),
832
+            Print(&separator),
833
+            ResetColor,
834
+        )?;
835
+
836
+        // Draw file tree (starting after header)
378837
         for row in 0..tree_rows {
379
-            execute!(self.stdout, MoveTo(0, row as u16))?;
838
+            let screen_row = (row + header_rows) as u16;
839
+            execute!(self.stdout, MoveTo(0, screen_row))?;
380840
 
381841
             let item_idx = scroll + row;
382842
             if item_idx < items.len() {
383843
                 let item = &items[item_idx];
384844
                 let is_selected = item_idx == selected;
385845
 
846
+                // Build git status indicator
847
+                let git_indicator = if item.git_status.staged {
848
+                    " \x1b[32m↑\x1b[0m" // Green up arrow
849
+                } else if item.git_status.unstaged {
850
+                    " \x1b[31m✗\x1b[0m" // Red X
851
+                } else if item.git_status.untracked {
852
+                    " \x1b[90m?\x1b[0m" // Gray question mark
853
+                } else if item.git_status.incoming {
854
+                    " \x1b[34m↓\x1b[0m" // Blue down arrow
855
+                } else {
856
+                    ""
857
+                };
858
+
386859
                 // Build display line
387860
                 let indent = "  ".repeat(item.depth.saturating_sub(1));
388861
                 let icon = if item.is_dir {
@@ -391,23 +864,42 @@ impl Screen {
391864
                     "  "
392865
                 };
393866
                 let suffix = if item.is_dir { "/" } else { "" };
394
-                let display = format!("{}{}{}{}", indent, icon, item.name, suffix);
395867
 
396
-                // Truncate to width
397
-                let truncated: String = display.chars().take(width.saturating_sub(1)).collect();
398
-                let padded = format!("{:<width$}", truncated, width = width);
868
+                // Calculate space for name (leave room for git indicator)
869
+                let prefix_len = indent.len() + icon.len();
870
+                let indicator_display_len = if git_indicator.is_empty() { 0 } else { 2 }; // " X"
871
+                let name_max = width.saturating_sub(prefix_len + suffix.len() + indicator_display_len);
872
+                let name_truncated: String = item.name.chars().take(name_max).collect();
873
+
874
+                let display_base = format!("{}{}{}{}", indent, icon, name_truncated, suffix);
399875
 
400876
                 if is_selected {
401
-                    // Highlight selected
877
+                    // Highlight selected - need to handle git indicator specially
878
+                    let padded_len = width.saturating_sub(indicator_display_len);
879
+                    let padded = format!("{:<width$}", display_base, width = padded_len);
402880
                     execute!(
403881
                         self.stdout,
404882
                         SetBackgroundColor(Color::DarkGrey),
405883
                         SetForegroundColor(Color::White),
406884
                         Print(&padded),
407
-                        ResetColor
408885
                     )?;
886
+                    if !git_indicator.is_empty() {
887
+                        // Git indicator with selection background
888
+                        if item.git_status.staged {
889
+                            execute!(self.stdout, SetForegroundColor(Color::Green), Print(" ↑"))?;
890
+                        } else if item.git_status.unstaged {
891
+                            execute!(self.stdout, SetForegroundColor(Color::Red), Print(" ✗"))?;
892
+                        } else if item.git_status.untracked {
893
+                            execute!(self.stdout, SetForegroundColor(Color::DarkGrey), Print(" ?"))?;
894
+                        } else if item.git_status.incoming {
895
+                            execute!(self.stdout, SetForegroundColor(Color::Blue), Print(" ↓"))?;
896
+                        }
897
+                    }
898
+                    execute!(self.stdout, ResetColor)?;
409899
                 } else if item.is_dir {
410900
                     // Directories in blue
901
+                    let padded_len = width.saturating_sub(indicator_display_len);
902
+                    let padded = format!("{:<width$}", display_base, width = padded_len);
411903
                     execute!(
412904
                         self.stdout,
413905
                         SetBackgroundColor(BG_COLOR),
@@ -415,15 +907,37 @@ impl Screen {
415907
                         Print(&padded),
416908
                         ResetColor
417909
                     )?;
910
+                } else if item.git_status.gitignored {
911
+                    // Gitignored files in dark gray
912
+                    let padded = format!("{:<width$}", display_base, width = width);
913
+                    execute!(
914
+                        self.stdout,
915
+                        SetBackgroundColor(BG_COLOR),
916
+                        SetForegroundColor(Color::DarkGrey),
917
+                        Print(&padded),
918
+                        ResetColor
919
+                    )?;
418920
                 } else {
419
-                    // Files in default color
921
+                    // Files in default color with git status
922
+                    let padded_len = width.saturating_sub(indicator_display_len);
923
+                    let padded = format!("{:<width$}", display_base, width = padded_len);
420924
                     execute!(
421925
                         self.stdout,
422926
                         SetBackgroundColor(BG_COLOR),
423927
                         SetForegroundColor(Color::Reset),
424928
                         Print(&padded),
425
-                        ResetColor
426929
                     )?;
930
+                    // Add git status indicator
931
+                    if item.git_status.staged {
932
+                        execute!(self.stdout, SetForegroundColor(Color::Green), Print(" ↑"))?;
933
+                    } else if item.git_status.unstaged {
934
+                        execute!(self.stdout, SetForegroundColor(Color::Red), Print(" ✗"))?;
935
+                    } else if item.git_status.untracked {
936
+                        execute!(self.stdout, SetForegroundColor(Color::DarkGrey), Print(" ?"))?;
937
+                    } else if item.git_status.incoming {
938
+                        execute!(self.stdout, SetForegroundColor(Color::Blue), Print(" ↓"))?;
939
+                    }
940
+                    execute!(self.stdout, ResetColor)?;
427941
                 }
428942
             } else {
429943
                 // Empty row
@@ -437,14 +951,14 @@ impl Screen {
437951
             }
438952
         }
439953
 
440
-        // Draw hints at bottom
441
-        let hint_start = tree_rows;
954
+        // Draw hints at bottom (after header + tree)
955
+        let hint_start = header_rows + tree_rows;
442956
         if hints_expanded {
443957
             let hints = [
444
-                "j/k:nav  spc:toggle  o:open  .:hidden",
445
-                "ctrl-b:close  ctrl-/:hints",
446
-                "",
447
-                "",
958
+                "j/k:nav spc:toggle o:open .:hidden",
959
+                "a:stage u:unstage d:diff m:commit",
960
+                "p:push l:pull f:fetch t:tag",
961
+                "ctrl-b:close ctrl-/:hints",
448962
             ];
449963
             for (i, hint) in hints.iter().enumerate() {
450964
                 if hint_start + i < text_rows {
@@ -477,7 +991,7 @@ impl Screen {
477991
         Ok(())
478992
     }
479993
 
480
-    /// Render the editor view with a horizontal offset (for fuss mode)
994
+    /// Render the editor view with horizontal and vertical offsets (for fuss mode and tab bar)
481995
     pub fn render_with_offset(
482996
         &mut self,
483997
         buffer: &Buffer,
@@ -486,12 +1000,14 @@ impl Screen {
4861000
         filename: Option<&str>,
4871001
         message: Option<&str>,
4881002
         bracket_match: Option<(usize, usize)>,
489
-        offset: u16,
1003
+        left_offset: u16,
1004
+        top_offset: u16,
1005
+        is_modified: bool,
4901006
     ) -> Result<()> {
4911007
         // Hide cursor during render to prevent flicker
4921008
         execute!(self.stdout, Hide)?;
4931009
 
494
-        let available_cols = self.cols.saturating_sub(offset) as usize;
1010
+        let available_cols = self.cols.saturating_sub(left_offset) as usize;
4951011
         let line_num_width = self.line_number_width(buffer.line_count());
4961012
         let text_cols = available_cols.saturating_sub(line_num_width + 1);
4971013
 
@@ -512,14 +1028,14 @@ impl Screen {
5121028
             .map(|(i, c)| (c.line, c.col, i == primary_idx))
5131029
             .collect();
5141030
 
515
-        // Reserve 1 row for status bar
516
-        let text_rows = self.rows.saturating_sub(1) as usize;
1031
+        // Reserve 1 row for status bar, accounting for top offset
1032
+        let text_rows = self.rows.saturating_sub(1 + top_offset) as usize;
5171033
 
5181034
         // Draw text area
5191035
         for row in 0..text_rows {
5201036
             let line_idx = viewport_line + row;
5211037
             let is_current_line = line_idx == primary.line;
522
-            execute!(self.stdout, MoveTo(offset, row as u16))?;
1038
+            execute!(self.stdout, MoveTo(left_offset, (row as u16) + top_offset))?;
5231039
 
5241040
             if line_idx < buffer.line_count() {
5251041
                 let line_num_fg = if is_current_line {
@@ -576,14 +1092,14 @@ impl Screen {
5761092
         }
5771093
 
5781094
         // Status bar
579
-        self.render_status_bar_with_offset(buffer, cursors, filename, message, offset)?;
1095
+        self.render_status_bar_with_offset(cursors, filename, message, left_offset, is_modified)?;
5801096
 
5811097
         // Position hardware cursor at primary cursor
582
-        let cursor_row = primary.line.saturating_sub(viewport_line);
583
-        let cursor_col = offset as usize + line_num_width + 1 + primary.col;
1098
+        let cursor_row = (primary.line.saturating_sub(viewport_line) as u16) + top_offset;
1099
+        let cursor_col = left_offset as usize + line_num_width + 1 + primary.col;
5841100
         execute!(
5851101
             self.stdout,
586
-            MoveTo(cursor_col as u16, cursor_row as u16),
1102
+            MoveTo(cursor_col as u16, cursor_row),
5871103
             Show
5881104
         )?;
5891105
 
@@ -593,11 +1109,11 @@ impl Screen {
5931109
 
5941110
     fn render_status_bar_with_offset(
5951111
         &mut self,
596
-        buffer: &Buffer,
5971112
         cursors: &Cursors,
5981113
         filename: Option<&str>,
5991114
         message: Option<&str>,
6001115
         offset: u16,
1116
+        is_modified: bool,
6011117
     ) -> Result<()> {
6021118
         let status_row = self.rows.saturating_sub(1);
6031119
         let available_cols = self.cols.saturating_sub(offset) as usize;
@@ -610,7 +1126,7 @@ impl Screen {
6101126
         )?;
6111127
 
6121128
         let name = filename.unwrap_or("[No Name]");
613
-        let modified = if buffer.modified { " [+]" } else { "" };
1129
+        let modified = if is_modified { " [+]" } else { "" };
6141130
         let cursor_count = if cursors.len() > 1 {
6151131
             format!(" ({} cursors)", cursors.len())
6161132
         } else {
@@ -639,4 +1155,226 @@ impl Screen {
6391155
 
6401156
         Ok(())
6411157
     }
1158
+
1159
+    /// Render the welcome menu
1160
+    pub fn render_welcome(
1161
+        &mut self,
1162
+        items: &[(String, String, bool, bool)], // (label, path, is_selected, is_current_dir)
1163
+        scroll: usize,
1164
+    ) -> Result<()> {
1165
+        execute!(self.stdout, Hide)?;
1166
+
1167
+        let cols = self.cols as usize;
1168
+        let rows = self.rows as usize;
1169
+
1170
+        // Fill background
1171
+        for row in 0..rows {
1172
+            execute!(
1173
+                self.stdout,
1174
+                MoveTo(0, row as u16),
1175
+                SetBackgroundColor(BG_COLOR),
1176
+                Clear(ClearType::UntilNewLine),
1177
+            )?;
1178
+        }
1179
+
1180
+        // Calculate box dimensions
1181
+        let box_width = cols.min(60).max(40);
1182
+        let box_height = rows.saturating_sub(4).min(items.len() + 6).max(10);
1183
+        let box_x = (cols.saturating_sub(box_width)) / 2;
1184
+        let box_y = (rows.saturating_sub(box_height)) / 2;
1185
+
1186
+        // Draw box border
1187
+        let top_border = format!("╭{}╮", "─".repeat(box_width.saturating_sub(2)));
1188
+        let bottom_border = format!("╰{}╯", "─".repeat(box_width.saturating_sub(2)));
1189
+
1190
+        execute!(
1191
+            self.stdout,
1192
+            MoveTo(box_x as u16, box_y as u16),
1193
+            SetBackgroundColor(BG_COLOR),
1194
+            SetForegroundColor(Color::DarkGrey),
1195
+            Print(&top_border),
1196
+        )?;
1197
+
1198
+        // Title
1199
+        let title = "Welcome to fackr";
1200
+        let title_row = box_y + 1;
1201
+        let title_x = box_x + (box_width.saturating_sub(title.len())) / 2;
1202
+        execute!(
1203
+            self.stdout,
1204
+            MoveTo(box_x as u16, title_row as u16),
1205
+            SetForegroundColor(Color::DarkGrey),
1206
+            Print("│"),
1207
+            SetForegroundColor(Color::White),
1208
+        )?;
1209
+        let padding_left = title_x.saturating_sub(box_x + 1);
1210
+        let padding_right = box_width.saturating_sub(2).saturating_sub(padding_left + title.len());
1211
+        execute!(
1212
+            self.stdout,
1213
+            Print(&" ".repeat(padding_left)),
1214
+            Print(title),
1215
+            Print(&" ".repeat(padding_right)),
1216
+            SetForegroundColor(Color::DarkGrey),
1217
+            Print("│"),
1218
+        )?;
1219
+
1220
+        // Subtitle
1221
+        let subtitle = "Select a workspace:";
1222
+        let subtitle_row = box_y + 2;
1223
+        execute!(
1224
+            self.stdout,
1225
+            MoveTo(box_x as u16, subtitle_row as u16),
1226
+            SetForegroundColor(Color::DarkGrey),
1227
+            Print("│"),
1228
+            SetForegroundColor(Color::AnsiValue(245)),
1229
+        )?;
1230
+        let padding_left = (box_width.saturating_sub(2).saturating_sub(subtitle.len())) / 2;
1231
+        let padding_right = box_width.saturating_sub(2).saturating_sub(padding_left + subtitle.len());
1232
+        execute!(
1233
+            self.stdout,
1234
+            Print(&" ".repeat(padding_left)),
1235
+            Print(subtitle),
1236
+            Print(&" ".repeat(padding_right)),
1237
+            SetForegroundColor(Color::DarkGrey),
1238
+            Print("│"),
1239
+        )?;
1240
+
1241
+        // Separator
1242
+        let separator_row = box_y + 3;
1243
+        execute!(
1244
+            self.stdout,
1245
+            MoveTo(box_x as u16, separator_row as u16),
1246
+            SetForegroundColor(Color::DarkGrey),
1247
+            Print("├"),
1248
+            Print(&"─".repeat(box_width.saturating_sub(2))),
1249
+            Print("┤"),
1250
+        )?;
1251
+
1252
+        // Item list area
1253
+        let list_start_row = box_y + 4;
1254
+        let list_height = box_height.saturating_sub(6);
1255
+        let inner_width = box_width.saturating_sub(4);
1256
+
1257
+        for i in 0..list_height {
1258
+            let row = list_start_row + i;
1259
+            let item_idx = scroll + i;
1260
+
1261
+            execute!(
1262
+                self.stdout,
1263
+                MoveTo(box_x as u16, row as u16),
1264
+                SetForegroundColor(Color::DarkGrey),
1265
+                Print("│ "),
1266
+            )?;
1267
+
1268
+            if item_idx < items.len() {
1269
+                let (label, _path, is_selected, is_current_dir) = &items[item_idx];
1270
+
1271
+                // Truncate label to fit
1272
+                let display_label: String = label.chars().take(inner_width).collect();
1273
+                let padded = format!("{:<width$}", display_label, width = inner_width);
1274
+
1275
+                if *is_selected {
1276
+                    execute!(
1277
+                        self.stdout,
1278
+                        SetBackgroundColor(Color::DarkGrey),
1279
+                        SetForegroundColor(Color::White),
1280
+                        Print(&padded),
1281
+                        SetBackgroundColor(BG_COLOR),
1282
+                    )?;
1283
+                } else if *is_current_dir {
1284
+                    execute!(
1285
+                        self.stdout,
1286
+                        SetForegroundColor(Color::Cyan),
1287
+                        Print(&padded),
1288
+                    )?;
1289
+                } else {
1290
+                    execute!(
1291
+                        self.stdout,
1292
+                        SetForegroundColor(Color::Reset),
1293
+                        Print(&padded),
1294
+                    )?;
1295
+                }
1296
+
1297
+                // Show path hint for selected item
1298
+                if *is_selected && inner_width > 30 {
1299
+                    // Clear and show path below
1300
+                }
1301
+            } else {
1302
+                execute!(
1303
+                    self.stdout,
1304
+                    SetForegroundColor(Color::Reset),
1305
+                    Print(&" ".repeat(inner_width)),
1306
+                )?;
1307
+            }
1308
+
1309
+            execute!(
1310
+                self.stdout,
1311
+                SetForegroundColor(Color::DarkGrey),
1312
+                Print(" │"),
1313
+            )?;
1314
+        }
1315
+
1316
+        // Path display row (show selected path)
1317
+        let path_row = list_start_row + list_height;
1318
+        execute!(
1319
+            self.stdout,
1320
+            MoveTo(box_x as u16, path_row as u16),
1321
+            SetForegroundColor(Color::DarkGrey),
1322
+            Print("├"),
1323
+            Print(&"─".repeat(box_width.saturating_sub(2))),
1324
+            Print("┤"),
1325
+        )?;
1326
+
1327
+        // Show selected path
1328
+        let selected_item = items.iter().find(|(_, _, sel, _)| *sel);
1329
+        let path_display_row = path_row + 1;
1330
+        execute!(
1331
+            self.stdout,
1332
+            MoveTo(box_x as u16, path_display_row as u16),
1333
+            SetForegroundColor(Color::DarkGrey),
1334
+            Print("│ "),
1335
+        )?;
1336
+        if let Some((_, path, _, _)) = selected_item {
1337
+            let truncated_path: String = path.chars().take(inner_width).collect();
1338
+            let padded_path = format!("{:<width$}", truncated_path, width = inner_width);
1339
+            execute!(
1340
+                self.stdout,
1341
+                SetForegroundColor(Color::AnsiValue(240)),
1342
+                Print(&padded_path),
1343
+            )?;
1344
+        } else {
1345
+            execute!(
1346
+                self.stdout,
1347
+                Print(&" ".repeat(inner_width)),
1348
+            )?;
1349
+        }
1350
+        execute!(
1351
+            self.stdout,
1352
+            SetForegroundColor(Color::DarkGrey),
1353
+            Print(" │"),
1354
+        )?;
1355
+
1356
+        // Bottom border
1357
+        let bottom_row = path_display_row + 1;
1358
+        execute!(
1359
+            self.stdout,
1360
+            MoveTo(box_x as u16, bottom_row as u16),
1361
+            SetForegroundColor(Color::DarkGrey),
1362
+            Print(&bottom_border),
1363
+        )?;
1364
+
1365
+        // Hints at bottom
1366
+        let hint_row = bottom_row + 1;
1367
+        let hints = "↑/↓: navigate  Enter: select  ESC: quit";
1368
+        let hints_x = (cols.saturating_sub(hints.len())) / 2;
1369
+        execute!(
1370
+            self.stdout,
1371
+            MoveTo(hints_x as u16, hint_row as u16),
1372
+            SetForegroundColor(Color::AnsiValue(240)),
1373
+            Print(hints),
1374
+            ResetColor,
1375
+        )?;
1376
+
1377
+        self.stdout.flush()?;
1378
+        Ok(())
1379
+    }
6421380
 }