module fortplot_matplotlib_session !! Global figure session helpers shared across matplotlib-compatible wrappers !! !! Provides lifecycle management for the singleton figure used by the !! matplotlib facade. Responsibilities include creating and reusing the !! global figure, grid/subplot helpers, and viewer integration utilities. use, intrinsic :: iso_fortran_env, only: wp => real64 use fortplot_figure_core, only: figure_t use fortplot_figure_initialization, only: configure_figure_dimensions, setup_figure_backend use fortplot_global, only: fig => global_figure use fortplot_logging, only: log_error, log_warning, log_info use fortplot_security, only: safe_remove_file use fortplot_system_viewer, only: launch_system_viewer use fortplot_png, only: png_context use fortplot_pdf, only: pdf_context use fortplot_ascii, only: ascii_context implicit none private public :: ensure_fig_init public :: ensure_global_figure_initialized public :: get_global_figure public :: figure public :: subplot public :: subplots public :: subplots_grid public :: savefig public :: savefig_with_status public :: show_data public :: show_figure public :: show_viewer public :: ion, ioff, draw, pause logical, save :: interactive_mode = .false. contains subroutine ion() !! Enable interactive mode for live terminal visualization !! !! When interactive mode is enabled, plots are displayed in the terminal !! using ASCII art and can be updated in real-time using draw() or pause(). interactive_mode = .true. end subroutine ion subroutine ioff() !! Disable interactive mode interactive_mode = .false. end subroutine ioff subroutine draw() !! Redraw the current figure to terminal (ASCII output) !! !! Clears the terminal and displays the current plot state. !! Useful for live visualization in loops. call ensure_global_figure_initialized() call execute_command_line("clear", wait=.true.) call fig%savefig("terminal") end subroutine draw subroutine pause(seconds) !! Draw the current figure and pause for specified duration !! !! This is the primary function for live visualization. It: !! 1. Clears the terminal !! 2. Displays the current plot as ASCII art !! 3. Waits for the specified number of seconds !! !! @param seconds: Time to pause in seconds real(wp), intent(in) :: seconds call draw() if (seconds > 0.0_wp) then call sleep_fortran(nint(seconds * 1000.0_wp)) end if end subroutine pause subroutine ensure_fig_init() !! Ensure the global figure exists and is initialized if (.not. allocated(fig)) then allocate(figure_t :: fig) end if if (.not. fig%backend_associated()) then call fig%initialize() end if end subroutine ensure_fig_init subroutine ensure_global_figure_initialized() !! Public wrapper to guarantee the singleton is ready for use call ensure_fig_init() end subroutine ensure_global_figure_initialized function get_global_figure() result(global_fig) !! Return the global figure pointer, initializing on demand class(figure_t), pointer :: global_fig call ensure_fig_init() global_fig => fig end function get_global_figure subroutine figure(num, figsize, dpi) !! Create a matplotlib-style figure using the shared singleton integer, intent(in), optional :: num real(wp), dimension(2), intent(in), optional :: figsize integer, intent(in), optional :: dpi integer :: fig_num, fig_dpi real(wp), dimension(2) :: requested_size, safe_size integer :: width_px, height_px character(len=256) :: msg fig_num = 1 if (present(num)) fig_num = num requested_size = [8.0_wp, 6.0_wp] if (present(figsize)) requested_size = figsize fig_dpi = 100 if (present(dpi)) fig_dpi = dpi if (requested_size(1) <= 0.0_wp .or. requested_size(2) <= 0.0_wp) then call log_error("figure: Invalid figure size") return end if if (fig_dpi <= 0) then call log_error("figure: Invalid DPI value") return end if width_px = nint(requested_size(1) * real(fig_dpi, wp)) height_px = nint(requested_size(2) * real(fig_dpi, wp)) safe_size = requested_size if (width_px > 10000 .or. height_px > 10000) then call log_warning("figure: Large figure size may cause memory issues") end if write(msg, '(A,I0)') "figure: Creating figure ", fig_num call log_info(trim(msg)) if (allocated(fig)) then deallocate(fig) end if allocate(figure_t :: fig) call fig%initialize() call configure_figure_dimensions(fig%state, width=width_px, height=height_px) if (allocated(fig%state%backend)) then select type (bk => fig%state%backend) type is (png_context) call setup_figure_backend(fig%state, 'png') type is (pdf_context) call setup_figure_backend(fig%state, 'pdf') type is (ascii_context) call setup_figure_backend(fig%state, 'ascii') class default call setup_figure_backend(fig%state, 'png') end select end if end subroutine figure subroutine subplot(nrows, ncols, index) !! Select a subplot in an nrows-by-ncols grid (matplotlib-compatible) !! !! Behavior: !! - On first call or when grid shape differs, (re)create the grid !! - Set the current subplot selection to `index` (row-major order) integer, intent(in) :: nrows, ncols, index character(len=256) :: msg integer :: rows, cols call ensure_global_figure_initialized() if (nrows <= 0 .or. ncols <= 0) then call log_error("subplot: Invalid grid dimensions") return end if if (index <= 0 .or. index > nrows * ncols) then call log_error("subplot: Invalid subplot index") return end if rows = fig%subplot_rows cols = fig%subplot_cols ! (Re)create grid if missing or shape changed if (rows /= nrows .or. cols /= ncols) then call fig%subplots(nrows, ncols) end if fig%current_subplot = index write(msg, '(A,I0,A,I0,A,I0,A)') & "Selected subplot ", index, " in ", nrows, "x", ncols, " grid" call log_info(trim(msg)) end subroutine subplot subroutine subplots(nrows, ncols) !! Initialize a subplot grid using the global figure integer, intent(in) :: nrows, ncols call ensure_global_figure_initialized() if (nrows <= 0 .or. ncols <= 0) then call log_error("subplots: Invalid grid dimensions") return end if call fig%subplots(nrows, ncols) fig%current_subplot = 1 end subroutine subplots function subplots_grid(nrows, ncols) result(axes) !! Create subplot grid and return axis indices in row-major order integer, intent(in) :: nrows, ncols integer, allocatable :: axes(:,:) integer :: i, j call ensure_global_figure_initialized() if (nrows <= 0 .or. ncols <= 0) then call log_error("subplots_grid: Invalid grid dimensions") allocate(axes(0, 0)) return end if call fig%subplots(nrows, ncols) allocate(axes(nrows, ncols)) do i = 1, nrows do j = 1, ncols axes(i, j) = (i - 1) * ncols + j end do end do end function subplots_grid subroutine savefig(filename, dpi, transparent, bbox_inches) !! Save current figure using matplotlib-compatible API character(len=*), intent(in) :: filename integer, intent(in), optional :: dpi logical, intent(in), optional :: transparent character(len=*), intent(in), optional :: bbox_inches call ensure_global_figure_initialized() call fig%savefig(filename) if (present(dpi) .or. present(transparent) .or. present(bbox_inches)) then call log_warning("savefig: backend ignores dpi/transparent/bbox settings") end if end subroutine savefig subroutine savefig_with_status(filename, status, dpi, transparent, bbox_inches) !! Save figure and return status code for testing scenarios character(len=*), intent(in) :: filename integer, intent(out) :: status integer, intent(in), optional :: dpi logical, intent(in), optional :: transparent character(len=*), intent(in), optional :: bbox_inches call ensure_global_figure_initialized() call fig%savefig_with_status(filename, status) if (present(dpi) .or. present(transparent) .or. present(bbox_inches)) then call log_warning( & "savefig_with_status: backend ignores dpi/transparent/bbox settings") end if end subroutine savefig_with_status subroutine show_data(x, y, label, title_text, xlabel_text, ylabel_text, blocking) !! Convenience routine mirroring matplotlib.pyplot.show signature with data real(wp), intent(in) :: x(:), y(:) character(len=*), intent(in), optional :: label, title_text character(len=*), intent(in), optional :: xlabel_text, ylabel_text logical, intent(in), optional :: blocking call ensure_global_figure_initialized() call fig%add_plot(x, y, label=label) if (present(title_text)) call fig%set_title(title_text) if (present(xlabel_text)) call fig%set_xlabel(xlabel_text) if (present(ylabel_text)) call fig%set_ylabel(ylabel_text) call fig%show(blocking=blocking) end subroutine show_data subroutine show_figure(blocking) !! Show the global figure via backend implementation logical, intent(in), optional :: blocking call ensure_global_figure_initialized() call fig%show(blocking=blocking) end subroutine show_figure subroutine show_viewer(blocking) !! Launch external viewer with saved figure artifact when available logical, intent(in), optional :: blocking call ensure_global_figure_initialized() call show_viewer_implementation(blocking) end subroutine show_viewer subroutine show_viewer_implementation(blocking) logical, intent(in), optional :: blocking character(len=512) :: temp_file logical :: is_blocking, success integer :: status real :: start_time, current_time, random_val logical, save :: no_gui_warning_shown = .false. is_blocking = .false. if (present(blocking)) is_blocking = blocking if (.not. is_gui_available()) then if (.not. no_gui_warning_shown) then call log_warning("No GUI available, saving to show_output.png instead") no_gui_warning_shown = .true. end if call fig%savefig("show_output.png") return end if call random_number(random_val) call get_environment_variable("TMPDIR", temp_file, status=status) if (status /= 0) temp_file = "/tmp" write(temp_file, '(A,A,I0,A)') trim(temp_file), "/fortplot_", & int(random_val * 1000000), ".png" call fig%savefig_with_status(trim(temp_file), status) if (status /= 0) then call log_error("Failed to save figure for viewing") return end if call launch_system_viewer(trim(temp_file), success) if (.not. success) then call log_error("Failed to launch image viewer") call safe_remove_file(trim(temp_file), success) return end if if (is_blocking) then call log_info("Viewer launched in blocking mode. Close viewer to continue.") call cpu_time(start_time) do call cpu_time(current_time) if (current_time - start_time > 30.0) exit call sleep_fortran(100) end do else call sleep_fortran(1000) end if call safe_remove_file(trim(temp_file), success) end subroutine show_viewer_implementation function is_gui_available() result(gui_available) logical :: gui_available character(len=256) :: display_var integer :: status logical, save :: ssh_warning_shown = .false. gui_available = .false. call get_environment_variable("DISPLAY", display_var, status=status) if (status == 0 .and. len_trim(display_var) > 0) then gui_available = .true. end if call get_environment_variable("SSH_CLIENT", display_var, status=status) if (status == 0 .and. .not. gui_available .and. .not. ssh_warning_shown) then call log_warning("SSH session detected without X forwarding") ssh_warning_shown = .true. end if end function is_gui_available subroutine sleep_fortran(milliseconds) integer, intent(in) :: milliseconds real :: seconds integer :: start_count, end_count, count_rate, target_count seconds = real(milliseconds) / 1000.0 call system_clock(start_count, count_rate) target_count = int(seconds * real(count_rate)) do call system_clock(end_count) if (end_count - start_count >= target_count) exit end do end subroutine sleep_fortran end module fortplot_matplotlib_session