Module: Sergeant::Rendering

Included in:
SergeantApp
Defined in:
lib/sergeant/rendering.rb

Constant Summary collapse

WINDOWS =

Use ASCII icons on Windows for better terminal compatibility

Gem.win_platform?
ICON_DIR =
WINDOWS ? '[D] ' : '📁 '
ICON_FILE =
WINDOWS ? '[F] ' : '📄 '
ICON_MARK =
WINDOWS ? '* ' : ''
ICON_SELECT =
WINDOWS ? '> ' : ''
MIN_PREVIEW_TOTAL_WIDTH =

Side preview panel only kicks in once the terminal is wide enough that both panes stay readable; below that it just doesn't fit.

100
MIN_PREVIEW_WIDTH =
24
PREVIEW_WIDTH_RATIO =
0.35
FILE_CATEGORY_COLOR_PAIRS =

Maps file_category (Sergeant::Utils) to the color pair set up in Sergeant#apply_color_theme (pairs 7-10).

{
  archive: 7,
  media: 8,
  code: 9,
  executable: 10
}.freeze

Instance Method Summary collapse

Instance Method Details

#draw_item(item, max_x, is_selected) ⇒ Object



169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
# File 'lib/sergeant/rendering.rb', line 169

def draw_item(item, max_x, is_selected)
  icon = item[:type] == :directory ? ICON_DIR : ICON_FILE

  # Check if item is marked
  is_marked = @marked_items.include?(item[:path])
  mark_indicator = is_marked ? ICON_MARK : '  '

  prefix = is_selected ? ICON_SELECT : '  '

  size_str = format_size(item[:size])
  date_str = format_date(item[:mtime])

  if @show_ownership && item[:owner] && item[:perms]
    perms_str = item[:perms]
    owner_str = item[:owner].ljust(16)
     = perms_str.length + owner_str.length + size_str.length + date_str.length + 8
  else
    perms_str = ''
    owner_str = ''
     = size_str.length + date_str.length + 4
  end

  available = max_x - prefix.length - mark_indicator.length - icon.length -  - 1

  name = if item[:name].length > available
           "#{item[:name][0...(available - 3)]}..."
         else
           item[:name].ljust(available)
         end

  display = if @show_ownership && item[:owner] && item[:perms]
              "#{prefix}#{mark_indicator}#{icon}#{name}  #{perms_str}  #{owner_str}  #{size_str}  #{date_str}".ljust(max_x)
            else
              "#{prefix}#{mark_indicator}#{icon}#{name}  #{size_str}  #{date_str}".ljust(max_x)
            end

  addstr(display)
end

#draw_preview_divider(list_width, max_y) ⇒ Object



239
240
241
242
243
244
245
246
247
248
249
250
251
252
# File 'lib/sergeant/rendering.rb', line 239

def draw_preview_divider(list_width, max_y)
  setpos(0, list_width)
  attron(color_pair(4) | Curses::A_BOLD) { addstr('') }

  (1...max_y).each do |row|
    next if row == 2

    setpos(row, list_width)
    attron(color_pair(4)) { addstr('') }
  end

  setpos(2, list_width)
  attron(color_pair(4)) { addstr('') }
end

#draw_preview_lines(col, width, visible_lines, source_lines, pair) ⇒ Object



267
268
269
270
271
272
273
274
275
# File 'lib/sergeant/rendering.rb', line 267

def draw_preview_lines(col, width, visible_lines, source_lines, pair)
  visible_lines.times do |idx|
    setpos(idx + 3, col)
    line = source_lines[idx]
    attron(pair) do
      addstr((line ? truncate_for_preview(line, width) : '').ljust(width))
    end
  end
end

#draw_preview_message(col, width, message) ⇒ Object



277
278
279
280
281
282
# File 'lib/sergeant/rendering.rb', line 277

def draw_preview_message(col, width, message)
  setpos(3, col)
  attron(color_pair(2) | Curses::A_DIM) do
    addstr(truncate_for_preview(message, width).ljust(width))
  end
end

#draw_preview_panel(list_width, preview_width, max_y, visible_lines) ⇒ Object

Draws the divider, title row and content of the side preview panel. Content itself is precomputed by Sergeant#update_preview_if_needed - this method only ever paints what's already in @preview_lines, so it stays cheap even though draw_screen runs on every keystroke.



212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/sergeant/rendering.rb', line 212

def draw_preview_panel(list_width, preview_width, max_y, visible_lines)
  col = list_width + 1
  width = preview_width - 1
  return if width <= 1

  draw_preview_divider(list_width, max_y)
  draw_preview_title(col, width)

  case @preview_kind
  when :text
    if @preview_lines.empty?
      draw_preview_message(col, width, '(empty file)')
    else
      draw_preview_lines(col, width, visible_lines, @preview_lines, color_pair(2))
    end
  when :directory
    if @preview_lines.empty?
      draw_preview_message(col, width, '(empty directory)')
    else
      names = @preview_lines.map { |name| "  #{name}" }
      draw_preview_lines(col, width, visible_lines, names, color_pair(1))
    end
  else
    draw_preview_message(col, width, preview_placeholder_message)
  end
end

#draw_preview_title(col, width) ⇒ Object



254
255
256
257
258
259
260
261
262
263
264
265
# File 'lib/sergeant/rendering.rb', line 254

def draw_preview_title(col, width)
  label = case @preview_kind
          when :directory then "#{ICON_DIR}#{@preview_item[:name]}"
          when :text, :binary then "#{ICON_FILE}#{@preview_item[:name]}"
          else ''
          end

  setpos(1, col)
  attron(color_pair(5) | Curses::A_BOLD) do
    addstr(truncate_for_preview(label, width).ljust(width))
  end
end

#draw_screenObject



29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/sergeant/rendering.rb', line 29

def draw_screen
  # `erase` only rewrites the virtual buffer; `refresh` then diffs it
  # against the physical screen and repaints just the changed cells.
  # `clear` forces a full physical blank-then-redraw on every call,
  # which flickers visibly on fast terminals like Alacritty.
  erase

  max_y = lines - 1
  max_x = cols

  preview_enabled = @show_preview && max_x >= MIN_PREVIEW_TOTAL_WIDTH
  if preview_enabled
    preview_width = [(max_x * PREVIEW_WIDTH_RATIO).to_i, MIN_PREVIEW_WIDTH].max
    list_width = max_x - preview_width - 1
  else
    preview_width = 0
    list_width = max_x
  end

  setpos(0, 0)
  attron(color_pair(4) | Curses::A_BOLD) do
    addstr("┌─ Sergeant - 'Leave it to the Sarge!' ".ljust(max_x, ''))
  end

  setpos(1, 0)
  branch = get_git_branch

  # Build status info
  status_parts = []
  unless @marked_items.empty?
    total_size = @marked_items.sum { |path| File.size(path) rescue 0 }
    size_str = format_size(total_size)
    status_parts << "Marked: #{@marked_items.length} (#{size_str.strip})"
  end
  unless @copied_items.empty?
    mode_text = @cut_mode ? 'Cut' : 'Copied'
    status_parts << "#{mode_text}: #{@copied_items.length}"
  end
  unless @filter_text.empty?
    filtered_count = @items.length - (@items.any? { |i| i[:name] == '..' } ? 1 : 0)
    status_parts << "Filter: '#{@filter_text}' (#{filtered_count})"
  end
  status_text = status_parts.empty? ? '' : " | #{status_parts.join(' | ')}"

  status_width = list_width

  if branch
    branch_text = " [#{branch}]"
    path_max_length = status_width - 4 - branch_text.length - status_text.length
    path_display = @current_dir.length > path_max_length ? "...#{@current_dir[(-path_max_length + 3)..]}" : @current_dir

    attron(color_pair(5)) do
      addstr("#{path_display}")
    end
    attron(color_pair(6) | Curses::A_BOLD) do
      addstr(branch_text)
    end
    unless status_text.empty?
      attron(color_pair(1) | Curses::A_BOLD) do
        addstr(status_text)
      end
    end
    remaining = status_width - 2 - path_display.length - branch_text.length - status_text.length
  else
    path_max_length = status_width - 4 - status_text.length
    path_display = @current_dir.length > path_max_length ? "...#{@current_dir[(-path_max_length + 3)..]}" : @current_dir

    attron(color_pair(5)) do
      addstr("#{path_display}")
    end
    unless status_text.empty?
      attron(color_pair(1) | Curses::A_BOLD) do
        addstr(status_text)
      end
    end
    remaining = status_width - 2 - path_display.length - status_text.length
  end
  addstr(''.ljust(remaining)) if remaining.positive?

  setpos(2, 0)
  attron(color_pair(4)) do
    addstr(''.ljust(max_x, ''))
  end

  setpos(max_y, 0)
  attron(color_pair(4)) do
    help = '↑↓/jk:Move  Enter:Open  ←h:Back  Space:Mark  c:Copy  x:Cut  p:Paste  d:Del  e:Edit  m:Help  q:Quit'
    addstr("└─ #{help}".ljust(max_x, ' '))
  end

  visible_lines = max_y - 4

  if @selected_index < @scroll_offset
    @scroll_offset = @selected_index
  elsif @selected_index >= @scroll_offset + visible_lines
    @scroll_offset = @selected_index - visible_lines + 1
  end

  visible_items = @items[@scroll_offset, visible_lines] || []
  visible_items.each_with_index do |item, idx|
    line_num = idx + 3
    actual_index = @scroll_offset + idx

    setpos(line_num, 0)

    is_selected = actual_index == @selected_index

    attron(item_attributes(item, is_selected)) do
      draw_item(item, list_width, is_selected)
    end
  end

  if @items.length > visible_lines
    total = @items.length
    visible = visible_lines
    scroll_pos = (@scroll_offset.to_f / (total - visible)) * (visible - 1)
    scroll_pos = scroll_pos.round.clamp(0, visible - 1)

    setpos(3 + scroll_pos, list_width - 1)
    attron(color_pair(4) | Curses::A_BOLD) do
      addstr('')
    end
  end

  draw_preview_panel(list_width, preview_width, max_y, visible_lines) if preview_enabled

  refresh
end

#item_attributes(item, is_selected) ⇒ Object

Which curses attributes to draw a list row with: selection highlight wins, then directories, then a file's type-based color (falling back to the plain dimmed file color when its category has none).



161
162
163
164
165
166
167
# File 'lib/sergeant/rendering.rb', line 161

def item_attributes(item, is_selected)
  return color_pair(3) | Curses::A_BOLD if is_selected
  return color_pair(1) if item[:type] == :directory

  pair = FILE_CATEGORY_COLOR_PAIRS[file_category(item)]
  pair ? color_pair(pair) : (color_pair(2) | Curses::A_DIM)
end

#preview_placeholder_messageObject



291
292
293
294
295
296
297
298
299
300
# File 'lib/sergeant/rendering.rb', line 291

def preview_placeholder_message
  case @preview_kind
  when :empty then '(empty directory)'
  when :binary
    size = @preview_item && @preview_item[:size]
    size ? "(no preview - #{format_size(size).strip})" : '(no preview available)'
  else
    ''
  end
end

#truncate_for_preview(text, width) ⇒ Object



284
285
286
287
288
289
# File 'lib/sergeant/rendering.rb', line 284

def truncate_for_preview(text, width)
  return text if text.length <= width
  return text[0, width].to_s if width <= 1

  "#{text[0, width - 1]}"
end