@@ -0,0 +1,404 @@ |
| 1 | +! Custom Cairo Breadcrumb Widget for Sniffly |
| 2 | +! Renders path as clickable, color-coded text segments with hover effects |
| 3 | +module breadcrumb_widget |
| 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, g_signal_connect, & |
| 8 | + gtk_gesture_click_new, gtk_widget_queue_draw |
| 9 | + use cairo, only: cairo_set_source_rgb, cairo_set_source_rgba, cairo_move_to |
| 10 | + use pango, only: pango_cairo_create_layout, pango_layout_set_text, & |
| 11 | + pango_font_description_from_string, pango_layout_set_font_description, & |
| 12 | + pango_cairo_show_layout, pango_layout_get_pixel_size, & |
| 13 | + pango_font_description_free |
| 14 | + implicit none |
| 15 | + private |
| 16 | + |
| 17 | + public :: create_breadcrumb_widget, update_breadcrumb_cache, & |
| 18 | + set_navigation_callback, get_breadcrumb_widget_ptr |
| 19 | + |
| 20 | + ! Callback interface for navigation events |
| 21 | + abstract interface |
| 22 | + subroutine navigation_callback() |
| 23 | + end subroutine navigation_callback |
| 24 | + end interface |
| 25 | + |
| 26 | + ! Segment bounds for hit-testing |
| 27 | + type :: segment_bounds |
| 28 | + integer(c_int) :: x, y, width, height |
| 29 | + end type segment_bounds |
| 30 | + |
| 31 | + ! Maximum number of path segments |
| 32 | + integer, parameter :: MAX_SEGMENTS = 50 |
| 33 | + |
| 34 | + ! Path segment cache (updated on main thread only via update_breadcrumb_cache) |
| 35 | + character(len=512), dimension(MAX_SEGMENTS), save :: cached_segment_paths = "" |
| 36 | + character(len=256), dimension(MAX_SEGMENTS), save :: cached_segment_names = "" |
| 37 | + integer, save :: cached_segment_count = 0 |
| 38 | + |
| 39 | + ! Hover state |
| 40 | + integer, save :: hovered_segment = 0 ! 0 = none, 1+ = segment index |
| 41 | + integer, save :: last_hovered_segment = -1 |
| 42 | + |
| 43 | + ! Click bounds for each segment (populated during draw) |
| 44 | + type(segment_bounds), dimension(MAX_SEGMENTS), save :: segment_rects |
| 45 | + |
| 46 | + ! Widget pointer |
| 47 | + type(c_ptr), save :: breadcrumb_widget_ptr = c_null_ptr |
| 48 | + |
| 49 | + ! Navigation callback (called when user clicks a segment) |
| 50 | + procedure(navigation_callback), pointer, save :: nav_callback => null() |
| 51 | + |
| 52 | +contains |
| 53 | + |
| 54 | + ! Create and initialize the breadcrumb drawing area widget |
| 55 | + function create_breadcrumb_widget() result(widget) |
| 56 | + type(c_ptr) :: widget, motion_controller, click_controller |
| 57 | + |
| 58 | + ! Create drawing area |
| 59 | + widget = gtk_drawing_area_new() |
| 60 | + breadcrumb_widget_ptr = widget |
| 61 | + |
| 62 | + if (.not. c_associated(widget)) then |
| 63 | + print *, "ERROR: Failed to create breadcrumb drawing area" |
| 64 | + return |
| 65 | + end if |
| 66 | + |
| 67 | + ! Set minimum height (width will expand to fill) |
| 68 | + call gtk_widget_set_size_request(widget, -1_c_int, 30_c_int) |
| 69 | + |
| 70 | + ! Set draw function (called when widget needs to redraw) |
| 71 | + call gtk_drawing_area_set_draw_func(widget, & |
| 72 | + c_funloc(on_draw_breadcrumb), & |
| 73 | + c_null_ptr, & |
| 74 | + c_null_funptr) |
| 75 | + |
| 76 | + ! Add motion event controller for hover |
| 77 | + motion_controller = gtk_event_controller_motion_new() |
| 78 | + call g_signal_connect(motion_controller, "motion"//c_null_char, & |
| 79 | + c_funloc(on_breadcrumb_motion), c_null_ptr) |
| 80 | + call gtk_widget_add_controller(widget, motion_controller) |
| 81 | + |
| 82 | + ! Add click gesture controller for navigation |
| 83 | + click_controller = gtk_gesture_click_new() |
| 84 | + call g_signal_connect(click_controller, "pressed"//c_null_char, & |
| 85 | + c_funloc(on_breadcrumb_click), c_null_ptr) |
| 86 | + call gtk_widget_add_controller(widget, click_controller) |
| 87 | + |
| 88 | + print *, "Breadcrumb widget created successfully" |
| 89 | + end function create_breadcrumb_widget |
| 90 | + |
| 91 | + ! Get widget pointer (for external access) |
| 92 | + function get_breadcrumb_widget_ptr() result(ptr) |
| 93 | + type(c_ptr) :: ptr |
| 94 | + ptr = breadcrumb_widget_ptr |
| 95 | + end function get_breadcrumb_widget_ptr |
| 96 | + |
| 97 | + ! Register a callback to be called when navigation occurs |
| 98 | + subroutine set_navigation_callback(callback) |
| 99 | + procedure(navigation_callback) :: callback |
| 100 | + nav_callback => callback |
| 101 | + print *, "Breadcrumb navigation callback registered" |
| 102 | + end subroutine set_navigation_callback |
| 103 | + |
| 104 | + ! Update cached path segments (called from main thread on navigation events) |
| 105 | + ! This is the ONLY function that modifies cached_segment_* |
| 106 | + subroutine update_breadcrumb_cache(full_path) |
| 107 | + character(len=*), intent(in) :: full_path |
| 108 | + character(len=512) :: home_dir, working_path |
| 109 | + integer :: i, slash_pos, start_pos, home_len |
| 110 | + logical :: uses_tilde |
| 111 | + |
| 112 | + print *, "Updating breadcrumb cache for: ", trim(full_path) |
| 113 | + |
| 114 | + ! Reset cache |
| 115 | + cached_segment_count = 0 |
| 116 | + cached_segment_paths = "" |
| 117 | + cached_segment_names = "" |
| 118 | + |
| 119 | + if (len_trim(full_path) == 0) return |
| 120 | + |
| 121 | + ! Get home directory for ~ abbreviation |
| 122 | + call get_environment_variable("HOME", home_dir) |
| 123 | + home_len = len_trim(home_dir) |
| 124 | + |
| 125 | + ! Check if path starts with home directory |
| 126 | + working_path = trim(full_path) |
| 127 | + uses_tilde = .false. |
| 128 | + if (home_len > 0) then |
| 129 | + if (len_trim(full_path) >= home_len) then |
| 130 | + if (full_path(1:home_len) == home_dir(1:home_len)) then |
| 131 | + ! Replace home with ~ |
| 132 | + if (len_trim(full_path) == home_len) then |
| 133 | + working_path = "~" |
| 134 | + else if (full_path(home_len+1:home_len+1) == "/") then |
| 135 | + working_path = "~" // trim(full_path(home_len+1:)) |
| 136 | + end if |
| 137 | + uses_tilde = .true. |
| 138 | + end if |
| 139 | + end if |
| 140 | + end if |
| 141 | + |
| 142 | + ! Handle root path |
| 143 | + if (trim(working_path) == "/") then |
| 144 | + cached_segment_count = 1 |
| 145 | + cached_segment_paths(1) = "/" |
| 146 | + cached_segment_names(1) = "/" |
| 147 | + print *, " Segment 1: / -> /" |
| 148 | + call queue_redraw() |
| 149 | + return |
| 150 | + end if |
| 151 | + |
| 152 | + ! Handle home-only path |
| 153 | + if (trim(working_path) == "~") then |
| 154 | + cached_segment_count = 1 |
| 155 | + cached_segment_paths(1) = trim(full_path) ! Store actual path for navigation |
| 156 | + cached_segment_names(1) = "~" |
| 157 | + print *, " Segment 1: ", trim(full_path), " -> ~" |
| 158 | + call queue_redraw() |
| 159 | + return |
| 160 | + end if |
| 161 | + |
| 162 | + ! Parse path into segments |
| 163 | + start_pos = 1 |
| 164 | + |
| 165 | + ! Skip leading / or ~ |
| 166 | + if (working_path(1:1) == "/" .or. working_path(1:1) == "~") then |
| 167 | + start_pos = 2 |
| 168 | + ! Add root segment |
| 169 | + cached_segment_count = 1 |
| 170 | + if (uses_tilde) then |
| 171 | + cached_segment_paths(1) = trim(home_dir) |
| 172 | + cached_segment_names(1) = "~" |
| 173 | + else |
| 174 | + cached_segment_paths(1) = "/" |
| 175 | + cached_segment_names(1) = "/" |
| 176 | + end if |
| 177 | + print *, " Segment 1: ", trim(cached_segment_paths(1)), " -> ", trim(cached_segment_names(1)) |
| 178 | + end if |
| 179 | + |
| 180 | + ! Parse remaining segments |
| 181 | + do while (start_pos <= len_trim(working_path) .and. cached_segment_count < MAX_SEGMENTS) |
| 182 | + ! Find next slash |
| 183 | + slash_pos = index(working_path(start_pos:), "/") |
| 184 | + |
| 185 | + if (slash_pos == 0) then |
| 186 | + ! Last segment (no trailing slash) |
| 187 | + cached_segment_count = cached_segment_count + 1 |
| 188 | + ! Build full path for this segment |
| 189 | + if (uses_tilde) then |
| 190 | + cached_segment_paths(cached_segment_count) = trim(home_dir) // & |
| 191 | + trim(working_path(2:len_trim(working_path))) |
| 192 | + else |
| 193 | + cached_segment_paths(cached_segment_count) = trim(working_path) |
| 194 | + end if |
| 195 | + ! Extract just the name |
| 196 | + cached_segment_names(cached_segment_count) = trim(working_path(start_pos:)) |
| 197 | + print *, " Segment ", cached_segment_count, ": ", & |
| 198 | + trim(cached_segment_paths(cached_segment_count)), " -> ", & |
| 199 | + trim(cached_segment_names(cached_segment_count)) |
| 200 | + exit |
| 201 | + else |
| 202 | + ! Intermediate segment |
| 203 | + cached_segment_count = cached_segment_count + 1 |
| 204 | + ! Build full path up to this segment |
| 205 | + if (uses_tilde) then |
| 206 | + cached_segment_paths(cached_segment_count) = trim(home_dir) // & |
| 207 | + trim(working_path(2:start_pos+slash_pos-2)) |
| 208 | + else |
| 209 | + cached_segment_paths(cached_segment_count) = trim(working_path(1:start_pos+slash_pos-2)) |
| 210 | + end if |
| 211 | + ! Extract just the name |
| 212 | + cached_segment_names(cached_segment_count) = trim(working_path(start_pos:start_pos+slash_pos-2)) |
| 213 | + print *, " Segment ", cached_segment_count, ": ", & |
| 214 | + trim(cached_segment_paths(cached_segment_count)), " -> ", & |
| 215 | + trim(cached_segment_names(cached_segment_count)) |
| 216 | + start_pos = start_pos + slash_pos |
| 217 | + end if |
| 218 | + end do |
| 219 | + |
| 220 | + print *, "Breadcrumb cache updated: ", cached_segment_count, " segments" |
| 221 | + |
| 222 | + ! Trigger redraw |
| 223 | + call queue_redraw() |
| 224 | + end subroutine update_breadcrumb_cache |
| 225 | + |
| 226 | + ! Helper: Queue redraw of widget |
| 227 | + subroutine queue_redraw() |
| 228 | + if (c_associated(breadcrumb_widget_ptr)) then |
| 229 | + call gtk_widget_queue_draw(breadcrumb_widget_ptr) |
| 230 | + end if |
| 231 | + end subroutine queue_redraw |
| 232 | + |
| 233 | + ! Draw callback - render the breadcrumb |
| 234 | + subroutine on_draw_breadcrumb(area, cr, width, height, user_data) bind(c) |
| 235 | + type(c_ptr), value :: area, cr, user_data |
| 236 | + integer(c_int), value :: width, height |
| 237 | + type(c_ptr) :: layout, font_desc |
| 238 | + integer(c_int) :: text_width, text_height, x_offset |
| 239 | + integer :: i |
| 240 | + character(len=:), allocatable :: separator |
| 241 | + |
| 242 | + ! Early return if no segments |
| 243 | + if (cached_segment_count == 0) return |
| 244 | + |
| 245 | + ! Create Pango layout for text rendering |
| 246 | + layout = pango_cairo_create_layout(cr) |
| 247 | + if (.not. c_associated(layout)) then |
| 248 | + print *, "ERROR: Failed to create Pango layout" |
| 249 | + return |
| 250 | + end if |
| 251 | + |
| 252 | + separator = " / " |
| 253 | + x_offset = 5 ! Left padding |
| 254 | + |
| 255 | + ! Draw each segment |
| 256 | + do i = 1, cached_segment_count |
| 257 | + ! Set font and color based on segment state |
| 258 | + |
| 259 | + ! Root segment (first): green |
| 260 | + if (i == 1) then |
| 261 | + call cairo_set_source_rgb(cr, 0.0_c_double, 0.6_c_double, 0.0_c_double) |
| 262 | + font_desc = pango_font_description_from_string("Sans 11"//c_null_char) |
| 263 | + |
| 264 | + ! Active segment (last): bold red |
| 265 | + else if (i == cached_segment_count) then |
| 266 | + call cairo_set_source_rgb(cr, 0.8_c_double, 0.0_c_double, 0.0_c_double) |
| 267 | + font_desc = pango_font_description_from_string("Sans Bold 11"//c_null_char) |
| 268 | + |
| 269 | + ! Hovered segment: darker gray |
| 270 | + else if (i == hovered_segment) then |
| 271 | + call cairo_set_source_rgb(cr, 0.3_c_double, 0.3_c_double, 0.3_c_double) |
| 272 | + font_desc = pango_font_description_from_string("Sans 11"//c_null_char) |
| 273 | + |
| 274 | + ! Inactive segment: gray |
| 275 | + else |
| 276 | + call cairo_set_source_rgb(cr, 0.5_c_double, 0.5_c_double, 0.5_c_double) |
| 277 | + font_desc = pango_font_description_from_string("Sans 11"//c_null_char) |
| 278 | + end if |
| 279 | + |
| 280 | + ! Set font |
| 281 | + call pango_layout_set_font_description(layout, font_desc) |
| 282 | + call pango_font_description_free(font_desc) |
| 283 | + |
| 284 | + ! Set text |
| 285 | + call pango_layout_set_text(layout, trim(cached_segment_names(i))//c_null_char, & |
| 286 | + int(len_trim(cached_segment_names(i)), c_int)) |
| 287 | + |
| 288 | + ! Get text size |
| 289 | + call pango_layout_get_pixel_size(layout, text_width, text_height) |
| 290 | + |
| 291 | + ! Store bounds for hit-testing |
| 292 | + segment_rects(i)%x = x_offset |
| 293 | + segment_rects(i)%y = 0 |
| 294 | + segment_rects(i)%width = text_width |
| 295 | + segment_rects(i)%height = height |
| 296 | + |
| 297 | + ! Position and draw text |
| 298 | + call cairo_move_to(cr, real(x_offset, c_double), & |
| 299 | + real((height - text_height) / 2, c_double)) ! Vertically center |
| 300 | + call pango_cairo_show_layout(cr, layout) |
| 301 | + |
| 302 | + ! Update offset |
| 303 | + x_offset = x_offset + text_width |
| 304 | + |
| 305 | + ! Draw separator (if not last segment) |
| 306 | + if (i < cached_segment_count) then |
| 307 | + ! Set separator color (gray) |
| 308 | + call cairo_set_source_rgb(cr, 0.5_c_double, 0.5_c_double, 0.5_c_double) |
| 309 | + font_desc = pango_font_description_from_string("Sans 11"//c_null_char) |
| 310 | + call pango_layout_set_font_description(layout, font_desc) |
| 311 | + call pango_font_description_free(font_desc) |
| 312 | + |
| 313 | + call pango_layout_set_text(layout, trim(separator)//c_null_char, & |
| 314 | + int(len_trim(separator), c_int)) |
| 315 | + call pango_layout_get_pixel_size(layout, text_width, text_height) |
| 316 | + call cairo_move_to(cr, real(x_offset, c_double), & |
| 317 | + real((height - text_height) / 2, c_double)) |
| 318 | + call pango_cairo_show_layout(cr, layout) |
| 319 | + |
| 320 | + x_offset = x_offset + text_width |
| 321 | + end if |
| 322 | + end do |
| 323 | + |
| 324 | + ! Clean up (Pango layout is freed by GTK automatically) |
| 325 | + end subroutine on_draw_breadcrumb |
| 326 | + |
| 327 | + ! Motion callback - track hover state |
| 328 | + subroutine on_breadcrumb_motion(controller, x, y, user_data) bind(c) |
| 329 | + type(c_ptr), value :: controller, user_data |
| 330 | + real(c_double), value :: x, y |
| 331 | + integer :: new_hovered |
| 332 | + |
| 333 | + ! Find which segment is under the mouse |
| 334 | + new_hovered = find_segment_at_position(x, y) |
| 335 | + |
| 336 | + ! Only redraw if hover state CHANGED (optimization) |
| 337 | + if (new_hovered /= last_hovered_segment) then |
| 338 | + last_hovered_segment = new_hovered |
| 339 | + hovered_segment = new_hovered |
| 340 | + call queue_redraw() |
| 341 | + end if |
| 342 | + end subroutine on_breadcrumb_motion |
| 343 | + |
| 344 | + ! Click callback - handle navigation |
| 345 | + subroutine on_breadcrumb_click(gesture, n_press, x, y, user_data) bind(c) |
| 346 | + use treemap_renderer, only: get_current_view_node, scan_directory |
| 347 | + use types, only: file_node |
| 348 | + type(c_ptr), value :: gesture, user_data |
| 349 | + integer(c_int), value :: n_press |
| 350 | + real(c_double), value :: x, y |
| 351 | + integer :: clicked_segment |
| 352 | + type(file_node), pointer :: current_view |
| 353 | + character(len=:), allocatable :: target_path |
| 354 | + |
| 355 | + ! Find which segment was clicked |
| 356 | + clicked_segment = find_segment_at_position(x, y) |
| 357 | + |
| 358 | + if (clicked_segment > 0 .and. clicked_segment <= cached_segment_count) then |
| 359 | + ! Don't navigate if clicking the active (last) segment |
| 360 | + if (clicked_segment == cached_segment_count) then |
| 361 | + print *, "Clicked active segment - no navigation" |
| 362 | + return |
| 363 | + end if |
| 364 | + |
| 365 | + print *, "Breadcrumb clicked: segment ", clicked_segment, " (", & |
| 366 | + trim(cached_segment_names(clicked_segment)), ")" |
| 367 | + |
| 368 | + ! Get the full path for this segment |
| 369 | + target_path = trim(cached_segment_paths(clicked_segment)) |
| 370 | + print *, "Navigating to: ", target_path |
| 371 | + |
| 372 | + ! Scan the target directory |
| 373 | + ! This will update the current view and trigger callbacks |
| 374 | + call scan_directory(target_path) |
| 375 | + |
| 376 | + ! Call navigation callback to update UI |
| 377 | + if (associated(nav_callback)) then |
| 378 | + call nav_callback() |
| 379 | + end if |
| 380 | + |
| 381 | + ! Redraw |
| 382 | + call queue_redraw() |
| 383 | + end if |
| 384 | + end subroutine on_breadcrumb_click |
| 385 | + |
| 386 | + ! Helper: Find which segment is at given position |
| 387 | + function find_segment_at_position(x, y) result(segment_index) |
| 388 | + real(c_double), intent(in) :: x, y |
| 389 | + integer :: segment_index |
| 390 | + integer :: i |
| 391 | + |
| 392 | + segment_index = 0 |
| 393 | + do i = 1, cached_segment_count |
| 394 | + if (x >= segment_rects(i)%x .and. & |
| 395 | + x <= segment_rects(i)%x + segment_rects(i)%width .and. & |
| 396 | + y >= segment_rects(i)%y .and. & |
| 397 | + y <= segment_rects(i)%y + segment_rects(i)%height) then |
| 398 | + segment_index = i |
| 399 | + return |
| 400 | + end if |
| 401 | + end do |
| 402 | + end function find_segment_at_position |
| 403 | + |
| 404 | +end module breadcrumb_widget |