fortrangoingonforty/sniffly / bc16410

Browse files

implement cairo filing cabinet tab bar with card styling

Co-Authored-By: mfwolffe <wolffemf@dukes.jmu.edu>
Authored by espadonne
SHA
bc164103961e89457338c1901d092390230c8506
Parents
6528e81
Tree
dd64bbe

3 changed files

StatusFile+-
M meson.build 1 0
A src/gui/cairo_tab_bar.f90 494 0
M src/gui/gtk_app.f90 101 15
meson.buildmodified
@@ -93,6 +93,7 @@ gui_sources = [
93
   # GTK4 interface
93
   # GTK4 interface
94
   'src/gui/tab_manager.f90',       # Tab state management (only depends on types)
94
   'src/gui/tab_manager.f90',       # Tab state management (only depends on types)
95
   'src/gui/tab_widget.f90',        # Tab bar UI (depends on tab_manager)
95
   'src/gui/tab_widget.f90',        # Tab bar UI (depends on tab_manager)
96
+  'src/gui/cairo_tab_bar.f90',     # Cairo-rendered filing cabinet tabs
96
   'src/gui/treemap_widget.f90',    # Must come before gtk_app (dependency)
97
   'src/gui/treemap_widget.f90',    # Must come before gtk_app (dependency)
97
   'src/gui/breadcrumb_widget.f90',  # Must come before gtk_app (dependency)
98
   'src/gui/breadcrumb_widget.f90',  # Must come before gtk_app (dependency)
98
   'src/gui/gtk_app.f90',
99
   'src/gui/gtk_app.f90',
src/gui/cairo_tab_bar.f90added
@@ -0,0 +1,494 @@
1
+! Cairo-rendered Filing Cabinet Tab Bar for Sniffly
2
+! Custom tab bar with squarrounded tabs that resemble filing cabinet tabs
3
+module cairo_tab_bar
4
+  use, intrinsic :: iso_c_binding
5
+  use gtk, only: gtk_drawing_area_new, gtk_drawing_area_set_draw_func, &
6
+                 gtk_widget_set_size_request, gtk_event_controller_motion_new, &
7
+                 gtk_widget_add_controller, gtk_gesture_click_new, &
8
+                 gtk_widget_queue_draw, gtk_widget_set_hexpand, g_signal_connect
9
+  use cairo, only: cairo_set_source_rgb, cairo_set_source_rgba, cairo_move_to, &
10
+                   cairo_line_to, cairo_curve_to, cairo_fill, cairo_stroke, &
11
+                   cairo_set_line_width, cairo_arc
12
+  use pango, only: pango_cairo_create_layout, pango_layout_set_text, &
13
+                   pango_font_description_from_string, pango_layout_set_font_description, &
14
+                   pango_cairo_show_layout, pango_layout_get_pixel_size, &
15
+                   pango_font_description_free
16
+  implicit none
17
+  private
18
+
19
+  public :: create_cairo_tab_bar, refresh_cairo_tab_bar, get_cairo_tab_bar_widget, &
20
+            register_cairo_tab_switch_callback, register_cairo_tab_close_callback, &
21
+            register_cairo_new_tab_callback
22
+
23
+  ! Tab dimensions
24
+  integer, parameter :: TAB_HEIGHT = 32
25
+  integer, parameter :: TAB_MIN_WIDTH = 120
26
+  integer, parameter :: TAB_MAX_WIDTH = 200
27
+  integer, parameter :: TAB_CORNER_RADIUS = 8
28
+  integer, parameter :: TAB_OVERLAP = 10
29
+  integer, parameter :: PLUS_BUTTON_WIDTH = 32
30
+  integer, parameter :: CLOSE_BUTTON_SIZE = 16
31
+  integer, parameter :: CLOSE_BUTTON_MARGIN = 8
32
+
33
+  ! Tab bar state
34
+  type(c_ptr), save :: tab_bar_widget = c_null_ptr
35
+  integer, save :: hovered_tab = 0  ! 0 = none, -1 = plus button, 1+ = tab index
36
+  integer, save :: hovered_close_button = 0  ! Which tab's close button is hovered
37
+
38
+  ! Tab bounds for hit testing
39
+  type :: tab_bounds
40
+    integer :: x, y, width, height
41
+    integer :: close_x, close_y  ! Close button position
42
+  end type tab_bounds
43
+
44
+  type(tab_bounds), dimension(5), save :: tab_rects  ! Max 5 tabs
45
+  integer :: plus_button_x, plus_button_y
46
+
47
+  ! Callback interfaces
48
+  abstract interface
49
+    subroutine tab_switch_callback(tab_index)
50
+      integer, intent(in) :: tab_index
51
+    end subroutine tab_switch_callback
52
+
53
+    subroutine tab_close_callback(tab_index)
54
+      integer, intent(in) :: tab_index
55
+    end subroutine tab_close_callback
56
+
57
+    subroutine new_tab_callback()
58
+    end subroutine new_tab_callback
59
+  end interface
60
+
61
+  ! Registered callbacks
62
+  procedure(tab_switch_callback), pointer, save :: switch_cb => null()
63
+  procedure(tab_close_callback), pointer, save :: close_cb => null()
64
+  procedure(new_tab_callback), pointer, save :: new_tab_cb => null()
65
+
66
+contains
67
+
68
+  ! Create the Cairo-rendered tab bar
69
+  function create_cairo_tab_bar() result(widget)
70
+    type(c_ptr) :: widget, motion_controller, click_controller
71
+
72
+    ! Create drawing area
73
+    widget = gtk_drawing_area_new()
74
+    tab_bar_widget = widget
75
+
76
+    if (.not. c_associated(widget)) then
77
+      print *, "ERROR: Failed to create cairo tab bar drawing area"
78
+      return
79
+    end if
80
+
81
+    ! Set fixed height, expand horizontally
82
+    call gtk_widget_set_size_request(widget, -1_c_int, TAB_HEIGHT)
83
+    call gtk_widget_set_hexpand(widget, 1_c_int)
84
+
85
+    ! Set draw function
86
+    call gtk_drawing_area_set_draw_func(widget, c_funloc(on_draw_tabs), &
87
+                                        c_null_ptr, c_null_ptr)
88
+
89
+    ! Add motion controller for hover effects
90
+    motion_controller = gtk_event_controller_motion_new()
91
+    call g_signal_connect(motion_controller, "motion"//c_null_char, &
92
+                          c_funloc(on_tab_motion), c_null_ptr)
93
+    call g_signal_connect(motion_controller, "leave"//c_null_char, &
94
+                          c_funloc(on_tab_leave), c_null_ptr)
95
+    call gtk_widget_add_controller(widget, motion_controller)
96
+
97
+    ! Add click controller
98
+    click_controller = gtk_gesture_click_new()
99
+    call g_signal_connect(click_controller, "pressed"//c_null_char, &
100
+                          c_funloc(on_tab_click), c_null_ptr)
101
+    call gtk_widget_add_controller(widget, click_controller)
102
+
103
+    print *, "Cairo tab bar created"
104
+  end function create_cairo_tab_bar
105
+
106
+  ! Draw the tab bar
107
+  subroutine on_draw_tabs(area, cr, width, height, user_data) bind(c)
108
+    use tab_manager, only: num_tabs, active_tab_index, get_tab, tab_state
109
+    type(c_ptr), value :: area, cr, user_data
110
+    integer(c_int), value :: width, height
111
+    type(tab_state), pointer :: tab
112
+    integer :: i, tab_x, tab_width, total_tabs_width
113
+    real(c_double) :: r, g, b, alpha
114
+
115
+    ! Clear background
116
+    call cairo_set_source_rgb(cr, 0.95_c_double, 0.95_c_double, 0.95_c_double)
117
+    call cairo_move_to(cr, 0.0_c_double, 0.0_c_double)
118
+    call cairo_line_to(cr, real(width, c_double), 0.0_c_double)
119
+    call cairo_line_to(cr, real(width, c_double), real(height, c_double))
120
+    call cairo_line_to(cr, 0.0_c_double, real(height, c_double))
121
+    call cairo_fill(cr)
122
+
123
+    ! Calculate starting position (flush to right edge, no overlap between tabs)
124
+    total_tabs_width = num_tabs * TAB_MIN_WIDTH + PLUS_BUTTON_WIDTH
125
+    tab_x = width - total_tabs_width
126
+
127
+    ! Draw plus button first (leftmost)
128
+    call draw_plus_button(cr, tab_x, 0)
129
+    plus_button_x = tab_x
130
+    plus_button_y = 0
131
+    tab_x = tab_x + PLUS_BUTTON_WIDTH
132
+
133
+    ! First pass: calculate positions and store in tab_rects
134
+    do i = 1, num_tabs
135
+      tab => get_tab(i)
136
+      if (.not. associated(tab)) cycle
137
+
138
+      tab_width = TAB_MIN_WIDTH
139
+
140
+      ! Store bounds for hit testing
141
+      tab_rects(i)%x = tab_x
142
+      tab_rects(i)%y = 0
143
+      tab_rects(i)%width = tab_width
144
+      tab_rects(i)%height = TAB_HEIGHT
145
+
146
+      ! No overlap - tabs sit flush next to each other
147
+      tab_x = tab_x + tab_width
148
+    end do
149
+
150
+    ! Second pass: draw inactive tabs first (so they appear behind)
151
+    do i = 1, num_tabs
152
+      if (i == active_tab_index) cycle  ! Skip active tab
153
+      tab => get_tab(i)
154
+      if (.not. associated(tab)) cycle
155
+
156
+      call draw_inactive_tab(cr, tab_rects(i)%x, TAB_MIN_WIDTH, trim(tab%label), i == hovered_tab)
157
+    end do
158
+
159
+    ! Third pass: draw active tab last (so it appears on top)
160
+    if (active_tab_index >= 1 .and. active_tab_index <= num_tabs) then
161
+      tab => get_tab(active_tab_index)
162
+      if (associated(tab)) then
163
+        call draw_active_tab(cr, tab_rects(active_tab_index)%x, TAB_MIN_WIDTH, &
164
+                           trim(tab%label), active_tab_index == hovered_tab)
165
+      end if
166
+    end if
167
+  end subroutine on_draw_tabs
168
+
169
+  ! Draw an active tab (full brightness, merges into canvas)
170
+  subroutine draw_active_tab(cr, x, width, label, is_hovered)
171
+    type(c_ptr), intent(in) :: cr
172
+    integer, intent(in) :: x, width
173
+    character(len=*), intent(in) :: label
174
+    logical, intent(in) :: is_hovered
175
+    real(c_double) :: x_d, y_d, w_d, h_d, radius
176
+
177
+    x_d = real(x, c_double)
178
+    y_d = 0.0_c_double
179
+    w_d = real(width, c_double)
180
+    h_d = real(TAB_HEIGHT, c_double)
181
+    radius = real(TAB_CORNER_RADIUS, c_double)
182
+
183
+    ! Draw squarrounded shape (rounded top, flat bottom)
184
+    call cairo_move_to(cr, x_d, h_d)  ! Bottom left
185
+    call cairo_line_to(cr, x_d, y_d + radius)  ! Left edge
186
+    call cairo_arc(cr, x_d + radius, y_d + radius, radius, 3.14159_c_double, -1.57080_c_double)  ! Top left corner
187
+    call cairo_line_to(cr, x_d + w_d - radius, y_d)  ! Top edge
188
+    call cairo_arc(cr, x_d + w_d - radius, y_d + radius, radius, -1.57080_c_double, 0.0_c_double)  ! Top right corner
189
+    call cairo_line_to(cr, x_d + w_d, h_d)  ! Right edge to bottom
190
+
191
+    ! Fill with white/light gray (active color)
192
+    if (is_hovered) then
193
+      call cairo_set_source_rgb(cr, 1.0_c_double, 1.0_c_double, 1.0_c_double)
194
+    else
195
+      call cairo_set_source_rgb(cr, 0.98_c_double, 0.98_c_double, 0.98_c_double)
196
+    end if
197
+    call cairo_fill(cr)
198
+
199
+    ! Draw border (but not bottom for "seeping" effect)
200
+    call draw_tab_border_no_bottom(cr, x_d, y_d, w_d, h_d, radius)
201
+
202
+    ! Draw label
203
+    call draw_tab_label(cr, x, 0, width, TAB_HEIGHT, label, .false.)
204
+
205
+    ! Draw close button
206
+    call draw_close_button(cr, x, width, .false.)
207
+  end subroutine draw_active_tab
208
+
209
+  ! Draw an inactive tab (dimmed, with bottom border)
210
+  subroutine draw_inactive_tab(cr, x, width, label, is_hovered)
211
+    type(c_ptr), intent(in) :: cr
212
+    integer, intent(in) :: x, width
213
+    character(len=*), intent(in) :: label
214
+    logical, intent(in) :: is_hovered
215
+    real(c_double) :: x_d, y_d, w_d, h_d, radius
216
+
217
+    x_d = real(x, c_double)
218
+    y_d = 2.0_c_double  ! Slightly lower than active
219
+    w_d = real(width, c_double)
220
+    h_d = real(TAB_HEIGHT - 2, c_double)
221
+    radius = real(TAB_CORNER_RADIUS, c_double)
222
+
223
+    ! Draw squarrounded shape
224
+    call cairo_move_to(cr, x_d, y_d + h_d)  ! Bottom left
225
+    call cairo_line_to(cr, x_d, y_d + radius)  ! Left edge
226
+    call cairo_arc(cr, x_d + radius, y_d + radius, radius, 3.14159_c_double, -1.57080_c_double)
227
+    call cairo_line_to(cr, x_d + w_d - radius, y_d)
228
+    call cairo_arc(cr, x_d + w_d - radius, y_d + radius, radius, -1.57080_c_double, 0.0_c_double)
229
+    call cairo_line_to(cr, x_d + w_d, y_d + h_d)
230
+
231
+    ! Fill with darker gray (inactive)
232
+    if (is_hovered) then
233
+      call cairo_set_source_rgb(cr, 0.88_c_double, 0.88_c_double, 0.88_c_double)
234
+    else
235
+      call cairo_set_source_rgb(cr, 0.82_c_double, 0.82_c_double, 0.82_c_double)
236
+    end if
237
+    call cairo_fill(cr)
238
+
239
+    ! Draw full border including bottom
240
+    call draw_tab_border_full(cr, x_d, y_d, w_d, h_d, radius)
241
+
242
+    ! Draw label (dimmed)
243
+    call draw_tab_label(cr, x, 2, width, TAB_HEIGHT - 2, label, .true.)
244
+
245
+    ! Draw close button
246
+    call draw_close_button(cr, x, width, .true.)
247
+  end subroutine draw_inactive_tab
248
+
249
+  ! Draw tab border without bottom (for active tab)
250
+  subroutine draw_tab_border_no_bottom(cr, x, y, width, height, radius)
251
+    type(c_ptr), intent(in) :: cr
252
+    real(c_double), intent(in) :: x, y, width, height, radius
253
+
254
+    call cairo_set_source_rgb(cr, 0.7_c_double, 0.7_c_double, 0.7_c_double)
255
+    call cairo_set_line_width(cr, 1.0_c_double)
256
+
257
+    call cairo_move_to(cr, x, y + height)
258
+    call cairo_line_to(cr, x, y + radius)
259
+    call cairo_arc(cr, x + radius, y + radius, radius, 3.14159_c_double, -1.57080_c_double)
260
+    call cairo_line_to(cr, x + width - radius, y)
261
+    call cairo_arc(cr, x + width - radius, y + radius, radius, -1.57080_c_double, 0.0_c_double)
262
+    call cairo_line_to(cr, x + width, y + height)
263
+    call cairo_stroke(cr)
264
+  end subroutine draw_tab_border_no_bottom
265
+
266
+  ! Draw full tab border (for inactive tabs)
267
+  subroutine draw_tab_border_full(cr, x, y, width, height, radius)
268
+    type(c_ptr), intent(in) :: cr
269
+    real(c_double), intent(in) :: x, y, width, height, radius
270
+
271
+    call cairo_set_source_rgb(cr, 0.6_c_double, 0.6_c_double, 0.6_c_double)
272
+    call cairo_set_line_width(cr, 1.0_c_double)
273
+
274
+    call cairo_move_to(cr, x, y + height)
275
+    call cairo_line_to(cr, x, y + radius)
276
+    call cairo_arc(cr, x + radius, y + radius, radius, 3.14159_c_double, -1.57080_c_double)
277
+    call cairo_line_to(cr, x + width - radius, y)
278
+    call cairo_arc(cr, x + width - radius, y + radius, radius, -1.57080_c_double, 0.0_c_double)
279
+    call cairo_line_to(cr, x + width, y + height)
280
+    call cairo_line_to(cr, x, y + height)
281
+    call cairo_stroke(cr)
282
+  end subroutine draw_tab_border_full
283
+
284
+  ! Draw tab label text
285
+  subroutine draw_tab_label(cr, x, y, width, height, text, is_dimmed)
286
+    type(c_ptr), intent(in) :: cr
287
+    integer, intent(in) :: x, y, width, height
288
+    character(len=*), intent(in) :: text
289
+    logical, intent(in) :: is_dimmed
290
+    type(c_ptr) :: layout, font_desc
291
+    integer(c_int), target :: text_width, text_height
292
+    real(c_double) :: text_x, text_y
293
+
294
+    ! Create pango layout
295
+    layout = pango_cairo_create_layout(cr)
296
+    call pango_layout_set_text(layout, trim(text)//c_null_char, -1_c_int)
297
+
298
+    ! Set font
299
+    font_desc = pango_font_description_from_string("Sans 10"//c_null_char)
300
+    call pango_layout_set_font_description(layout, font_desc)
301
+
302
+    ! Get text size
303
+    call pango_layout_get_pixel_size(layout, c_loc(text_width), c_loc(text_height))
304
+
305
+    ! Center text in tab (leaving room for close button)
306
+    text_x = real(x, c_double) + real(width - CLOSE_BUTTON_SIZE - CLOSE_BUTTON_MARGIN - text_width, c_double) / 2.0_c_double
307
+    text_y = real(y, c_double) + real(height - text_height, c_double) / 2.0_c_double
308
+
309
+    ! Set text color
310
+    if (is_dimmed) then
311
+      call cairo_set_source_rgb(cr, 0.4_c_double, 0.4_c_double, 0.4_c_double)
312
+    else
313
+      call cairo_set_source_rgb(cr, 0.2_c_double, 0.2_c_double, 0.2_c_double)
314
+    end if
315
+
316
+    call cairo_move_to(cr, text_x, text_y)
317
+    call pango_cairo_show_layout(cr, layout)
318
+
319
+    call pango_font_description_free(font_desc)
320
+  end subroutine draw_tab_label
321
+
322
+  ! Draw close button (×)
323
+  subroutine draw_close_button(cr, tab_x, tab_width, is_dimmed)
324
+    type(c_ptr), intent(in) :: cr
325
+    integer, intent(in) :: tab_x, tab_width
326
+    logical, intent(in) :: is_dimmed
327
+    real(c_double) :: btn_x, btn_y, btn_size
328
+
329
+    btn_x = real(tab_x + tab_width - CLOSE_BUTTON_SIZE - CLOSE_BUTTON_MARGIN, c_double)
330
+    btn_y = real((TAB_HEIGHT - CLOSE_BUTTON_SIZE) / 2, c_double)
331
+    btn_size = real(CLOSE_BUTTON_SIZE, c_double)
332
+
333
+    ! Draw × symbol
334
+    call cairo_set_line_width(cr, 1.5_c_double)
335
+    if (is_dimmed) then
336
+      call cairo_set_source_rgb(cr, 0.5_c_double, 0.5_c_double, 0.5_c_double)
337
+    else
338
+      call cairo_set_source_rgb(cr, 0.3_c_double, 0.3_c_double, 0.3_c_double)
339
+    end if
340
+
341
+    ! Draw X
342
+    call cairo_move_to(cr, btn_x + 4.0_c_double, btn_y + 4.0_c_double)
343
+    call cairo_line_to(cr, btn_x + btn_size - 4.0_c_double, btn_y + btn_size - 4.0_c_double)
344
+    call cairo_stroke(cr)
345
+
346
+    call cairo_move_to(cr, btn_x + btn_size - 4.0_c_double, btn_y + 4.0_c_double)
347
+    call cairo_line_to(cr, btn_x + 4.0_c_double, btn_y + btn_size - 4.0_c_double)
348
+    call cairo_stroke(cr)
349
+  end subroutine draw_close_button
350
+
351
+  ! Draw plus button
352
+  subroutine draw_plus_button(cr, x, y)
353
+    type(c_ptr), intent(in) :: cr
354
+    integer, intent(in) :: x, y
355
+    real(c_double) :: btn_x, btn_y, btn_size
356
+    logical :: is_hovered
357
+
358
+    is_hovered = (hovered_tab == -1)
359
+
360
+    btn_x = real(x, c_double) + 4.0_c_double
361
+    btn_y = real(y, c_double) + 4.0_c_double
362
+    btn_size = real(PLUS_BUTTON_WIDTH - 8, c_double)
363
+
364
+    ! Draw circle background
365
+    if (is_hovered) then
366
+      call cairo_set_source_rgb(cr, 0.9_c_double, 0.9_c_double, 0.9_c_double)
367
+    else
368
+      call cairo_set_source_rgb(cr, 0.85_c_double, 0.85_c_double, 0.85_c_double)
369
+    end if
370
+    call cairo_arc(cr, btn_x + btn_size / 2.0_c_double, btn_y + btn_size / 2.0_c_double, &
371
+                   btn_size / 2.0_c_double, 0.0_c_double, 6.28319_c_double)
372
+    call cairo_fill(cr)
373
+
374
+    ! Draw + symbol
375
+    call cairo_set_line_width(cr, 2.0_c_double)
376
+    call cairo_set_source_rgb(cr, 0.3_c_double, 0.3_c_double, 0.3_c_double)
377
+
378
+    ! Horizontal line
379
+    call cairo_move_to(cr, btn_x + 6.0_c_double, btn_y + btn_size / 2.0_c_double)
380
+    call cairo_line_to(cr, btn_x + btn_size - 6.0_c_double, btn_y + btn_size / 2.0_c_double)
381
+    call cairo_stroke(cr)
382
+
383
+    ! Vertical line
384
+    call cairo_move_to(cr, btn_x + btn_size / 2.0_c_double, btn_y + 6.0_c_double)
385
+    call cairo_line_to(cr, btn_x + btn_size / 2.0_c_double, btn_y + btn_size - 6.0_c_double)
386
+    call cairo_stroke(cr)
387
+  end subroutine draw_plus_button
388
+
389
+  ! Handle mouse motion for hover effects
390
+  subroutine on_tab_motion(controller, x, y, user_data) bind(c)
391
+    use tab_manager, only: num_tabs
392
+    type(c_ptr), value :: controller, user_data
393
+    real(c_double), value :: x, y
394
+    integer :: i, old_hovered
395
+
396
+    old_hovered = hovered_tab
397
+    hovered_tab = 0
398
+
399
+    ! Check plus button
400
+    if (x >= plus_button_x .and. x < plus_button_x + PLUS_BUTTON_WIDTH .and. &
401
+        y >= plus_button_y .and. y < plus_button_y + TAB_HEIGHT) then
402
+      hovered_tab = -1
403
+    else
404
+      ! Check tabs
405
+      do i = 1, num_tabs
406
+        if (x >= tab_rects(i)%x .and. x < tab_rects(i)%x + tab_rects(i)%width .and. &
407
+            y >= tab_rects(i)%y .and. y < tab_rects(i)%y + tab_rects(i)%height) then
408
+          hovered_tab = i
409
+          exit
410
+        end if
411
+      end do
412
+    end if
413
+
414
+    ! Redraw if hover state changed
415
+    if (hovered_tab /= old_hovered .and. c_associated(tab_bar_widget)) then
416
+      call gtk_widget_queue_draw(tab_bar_widget)
417
+    end if
418
+  end subroutine on_tab_motion
419
+
420
+  ! Handle mouse leave (clear hover state)
421
+  subroutine on_tab_leave(controller, user_data) bind(c)
422
+    type(c_ptr), value :: controller, user_data
423
+
424
+    ! Clear hover state when mouse leaves the tab bar
425
+    if (hovered_tab /= 0 .and. c_associated(tab_bar_widget)) then
426
+      hovered_tab = 0
427
+      hovered_close_button = 0
428
+      call gtk_widget_queue_draw(tab_bar_widget)
429
+    end if
430
+  end subroutine on_tab_leave
431
+
432
+  ! Handle clicks
433
+  subroutine on_tab_click(gesture, n_press, x, y, user_data) bind(c)
434
+    use tab_manager, only: num_tabs
435
+    type(c_ptr), value :: gesture, user_data
436
+    integer(c_int), value :: n_press
437
+    real(c_double), value :: x, y
438
+    integer :: i
439
+
440
+    ! Check plus button
441
+    if (x >= plus_button_x .and. x < plus_button_x + PLUS_BUTTON_WIDTH .and. &
442
+        y >= plus_button_y .and. y < plus_button_y + TAB_HEIGHT) then
443
+      if (associated(new_tab_cb)) call new_tab_cb()
444
+      return
445
+    end if
446
+
447
+    ! Check tabs
448
+    do i = 1, num_tabs
449
+      if (x >= tab_rects(i)%x .and. x < tab_rects(i)%x + tab_rects(i)%width .and. &
450
+          y >= tab_rects(i)%y .and. y < tab_rects(i)%y + tab_rects(i)%height) then
451
+
452
+        ! Check if close button was clicked
453
+        if (x >= tab_rects(i)%x + tab_rects(i)%width - CLOSE_BUTTON_SIZE - CLOSE_BUTTON_MARGIN .and. &
454
+            x < tab_rects(i)%x + tab_rects(i)%width - CLOSE_BUTTON_MARGIN) then
455
+          if (associated(close_cb)) call close_cb(i)
456
+        else
457
+          ! Tab body clicked
458
+          if (associated(switch_cb)) call switch_cb(i)
459
+        end if
460
+        return
461
+      end if
462
+    end do
463
+  end subroutine on_tab_click
464
+
465
+  ! Refresh (trigger redraw)
466
+  subroutine refresh_cairo_tab_bar()
467
+    if (c_associated(tab_bar_widget)) then
468
+      call gtk_widget_queue_draw(tab_bar_widget)
469
+    end if
470
+  end subroutine refresh_cairo_tab_bar
471
+
472
+  ! Get widget
473
+  function get_cairo_tab_bar_widget() result(widget)
474
+    type(c_ptr) :: widget
475
+    widget = tab_bar_widget
476
+  end function get_cairo_tab_bar_widget
477
+
478
+  ! Register callbacks
479
+  subroutine register_cairo_tab_switch_callback(callback)
480
+    procedure(tab_switch_callback) :: callback
481
+    switch_cb => callback
482
+  end subroutine register_cairo_tab_switch_callback
483
+
484
+  subroutine register_cairo_tab_close_callback(callback)
485
+    procedure(tab_close_callback) :: callback
486
+    close_cb => callback
487
+  end subroutine register_cairo_tab_close_callback
488
+
489
+  subroutine register_cairo_new_tab_callback(callback)
490
+    procedure(new_tab_callback) :: callback
491
+    new_tab_cb => callback
492
+  end subroutine register_cairo_new_tab_callback
493
+
494
+end module cairo_tab_bar
src/gui/gtk_app.f90modified
@@ -32,9 +32,10 @@ module gtk_app
32
   use treemap_renderer, only: register_progress_callback, scan_directory, set_redraw_widget, &
32
   use treemap_renderer, only: register_progress_callback, scan_directory, set_redraw_widget, &
33
                                register_scan_completion_callback, set_renderer_state_from_tab
33
                                register_scan_completion_callback, set_renderer_state_from_tab
34
   use tab_manager, only: tab_state, init_tab_manager, create_tab, get_active_tab, &
34
   use tab_manager, only: tab_state, init_tab_manager, create_tab, get_active_tab, &
35
-                         switch_to_tab, num_tabs, active_tab_index, get_path_basename
35
+                         switch_to_tab, close_tab, num_tabs, active_tab_index, get_path_basename
36
-  use tab_widget, only: create_tab_bar, refresh_tab_bar, register_tab_switch_callback, &
36
+  use cairo_tab_bar, only: create_cairo_tab_bar, refresh_cairo_tab_bar, &
37
-                        update_tab_visual_states
37
+                           register_cairo_tab_switch_callback, register_cairo_tab_close_callback, &
38
+                           register_cairo_new_tab_callback
38
   implicit none
39
   implicit none
39
   private
40
   private
40
 
41
 
@@ -398,17 +399,19 @@ contains
398
     ! Add breadcrumb widget to breadcrumb row
399
     ! Add breadcrumb widget to breadcrumb row
399
     call gtk_box_append(breadcrumb_row, breadcrumb_widget)
400
     call gtk_box_append(breadcrumb_row, breadcrumb_widget)
400
 
401
 
401
-    ! Create tab bar (on right side of breadcrumb row)
402
+    ! Create Cairo-rendered tab bar (on right side of breadcrumb row)
402
-    tab_bar = create_tab_bar()
403
+    tab_bar = create_cairo_tab_bar()
403
     if (c_associated(tab_bar)) then
404
     if (c_associated(tab_bar)) then
404
       call gtk_box_append(breadcrumb_row, tab_bar)
405
       call gtk_box_append(breadcrumb_row, tab_bar)
405
-      ! Populate with tabs
406
+      ! Register callbacks for tab interactions
406
-      call refresh_tab_bar()
407
+      call register_cairo_tab_switch_callback(on_cairo_tab_switch)
407
-      ! Register callback for tab switching to update UI
408
+      call register_cairo_tab_close_callback(on_cairo_tab_close)
408
-      call register_tab_switch_callback(update_ui_for_active_tab)
409
+      call register_cairo_new_tab_callback(on_cairo_new_tab)
409
-      print *, "Tab bar added to breadcrumb row"
410
+      ! Trigger initial draw
411
+      call refresh_cairo_tab_bar()
412
+      print *, "Cairo tab bar added to breadcrumb row"
410
     else
413
     else
411
-      print *, "ERROR: Failed to create tab bar"
414
+      print *, "ERROR: Failed to create Cairo tab bar"
412
     end if
415
     end if
413
 
416
 
414
     ! Add breadcrumb row to main box
417
     ! Add breadcrumb row to main box
@@ -425,6 +428,9 @@ contains
425
     ! Store pointer for later use (e.g., tab switching redraw)
428
     ! Store pointer for later use (e.g., tab switching redraw)
426
     drawing_area_ptr = drawing_area
429
     drawing_area_ptr = drawing_area
427
 
430
 
431
+    ! Add CSS class for card styling (border that matches active tab)
432
+    call gtk_widget_add_css_class(drawing_area, "canvas-card"//c_null_char)
433
+
428
     ! Make drawing area expand to fill space
434
     ! Make drawing area expand to fill space
429
     call gtk_widget_set_hexpand(drawing_area, 1_c_int)
435
     call gtk_widget_set_hexpand(drawing_area, 1_c_int)
430
     call gtk_widget_set_vexpand(drawing_area, 1_c_int)
436
     call gtk_widget_set_vexpand(drawing_area, 1_c_int)
@@ -502,6 +508,7 @@ contains
502
     character(len=:), allocatable :: css_data
508
     character(len=:), allocatable :: css_data
503
 
509
 
504
     ! CSS with pulsing animation for suggested-action class (empty tabs)
510
     ! CSS with pulsing animation for suggested-action class (empty tabs)
511
+    ! and card border styling to match active tab appearance
505
     css_data = &
512
     css_data = &
506
       "@keyframes pulse { " // &
513
       "@keyframes pulse { " // &
507
       "0% { opacity: 1.0; } " // &
514
       "0% { opacity: 1.0; } " // &
@@ -510,6 +517,11 @@ contains
510
       "} " // &
517
       "} " // &
511
       ".suggested-action { " // &
518
       ".suggested-action { " // &
512
       "animation: pulse 1.5s ease-in-out infinite; " // &
519
       "animation: pulse 1.5s ease-in-out infinite; " // &
520
+      "} " // &
521
+      ".canvas-card { " // &
522
+      "background-color: rgba(250, 250, 250, 1.0); " // &
523
+      "border: 1px solid rgba(179, 179, 179, 1.0); " // &
524
+      "border-top: none; " // &
513
       "}"
525
       "}"
514
 
526
 
515
     ! Create CSS provider
527
     ! Create CSS provider
@@ -524,7 +536,7 @@ contains
524
     ! Add CSS provider to display (600 = GTK_STYLE_PROVIDER_PRIORITY_APPLICATION)
536
     ! Add CSS provider to display (600 = GTK_STYLE_PROVIDER_PRIORITY_APPLICATION)
525
     call gtk_style_context_add_provider_for_display(display, css_provider, 600_c_int)
537
     call gtk_style_context_add_provider_for_display(display, css_provider, 600_c_int)
526
 
538
 
527
-    print *, "Custom CSS loaded (pulsing animation for empty tabs)"
539
+    print *, "Custom CSS loaded (pulsing animation + canvas card styling)"
528
   end subroutine load_custom_css
540
   end subroutine load_custom_css
529
 
541
 
530
   ! Callback when Open Directory button is clicked
542
   ! Callback when Open Directory button is clicked
@@ -1365,6 +1377,81 @@ contains
1365
     call sniffly_update_status("Toggled render mode (flat vs cushioned)")
1377
     call sniffly_update_status("Toggled render mode (flat vs cushioned)")
1366
   end subroutine on_toggle_render_mode_clicked
1378
   end subroutine on_toggle_render_mode_clicked
1367
 
1379
 
1380
+  ! Cairo tab bar callback wrappers
1381
+  subroutine on_cairo_tab_switch(tab_index)
1382
+    integer, intent(in) :: tab_index
1383
+
1384
+    ! Skip if already on this tab
1385
+    if (tab_index == active_tab_index) return
1386
+
1387
+    print *, "Cairo tab switch to tab ", tab_index
1388
+
1389
+    ! Switch to the clicked tab
1390
+    call switch_to_tab(tab_index)
1391
+
1392
+    ! Trigger redraw of tab bar (highlights active tab)
1393
+    call refresh_cairo_tab_bar()
1394
+
1395
+    ! Update UI for the new active tab
1396
+    call update_ui_for_active_tab()
1397
+
1398
+    print *, "Switched to tab ", tab_index
1399
+  end subroutine on_cairo_tab_switch
1400
+
1401
+  subroutine on_cairo_tab_close(tab_index)
1402
+    integer, intent(in) :: tab_index
1403
+
1404
+    print *, "Cairo tab close for tab ", tab_index
1405
+
1406
+    ! Prevent closing last tab
1407
+    if (num_tabs <= 1) then
1408
+      print *, "ERROR: Cannot close last tab"
1409
+      return
1410
+    end if
1411
+
1412
+    ! Close the tab
1413
+    call close_tab(tab_index)
1414
+
1415
+    ! Trigger redraw of tab bar
1416
+    call refresh_cairo_tab_bar()
1417
+
1418
+    ! Update UI for the new active tab
1419
+    call update_ui_for_active_tab()
1420
+
1421
+    print *, "Tab ", tab_index, " closed - now ", num_tabs, " tabs remaining"
1422
+  end subroutine on_cairo_tab_close
1423
+
1424
+  subroutine on_cairo_new_tab()
1425
+    integer :: new_tab_index
1426
+    character(len=512) :: new_tab_path
1427
+
1428
+    print *, "Cairo new tab button clicked"
1429
+
1430
+    ! New tabs start completely empty - no path to avoid accidental scans
1431
+    new_tab_path = ""
1432
+
1433
+    ! Create a new empty tab
1434
+    new_tab_index = create_tab(new_tab_path)
1435
+
1436
+    if (new_tab_index < 0) then
1437
+      print *, "ERROR: Failed to create new tab (max tabs reached?)"
1438
+      return
1439
+    end if
1440
+
1441
+    print *, "Created new tab ", new_tab_index
1442
+
1443
+    ! Switch to the new tab
1444
+    call switch_to_tab(new_tab_index)
1445
+
1446
+    ! Trigger redraw of tab bar
1447
+    call refresh_cairo_tab_bar()
1448
+
1449
+    ! Update UI for the new tab
1450
+    call update_ui_for_active_tab()
1451
+
1452
+    print *, "Tab bar refreshed and switched to new tab ", new_tab_index
1453
+  end subroutine on_cairo_new_tab
1454
+
1368
   ! Commented out unused helper function - was used by removed search/filter feature
1455
   ! Commented out unused helper function - was used by removed search/filter feature
1369
   ! Uncomment if needed in future
1456
   ! Uncomment if needed in future
1370
 
1457
 
@@ -1838,9 +1925,8 @@ contains
1838
     ! Update button states now that history may have changed
1925
     ! Update button states now that history may have changed
1839
     call update_history_buttons()
1926
     call update_history_buttons()
1840
 
1927
 
1841
-    ! Refresh tab bar to show updated label
1928
+    ! Refresh Cairo tab bar to show updated label
1842
-    call refresh_tab_bar()
1929
+    call refresh_cairo_tab_bar()
1843
-    call update_tab_visual_states()
1844
   end subroutine breadcrumb_callback
1930
   end subroutine breadcrumb_callback
1845
 
1931
 
1846
   ! Show progress bar (now just resets to prepare for updates)
1932
   ! Show progress bar (now just resets to prepare for updates)