fortplot_matplotlib_session.f90 Source File


Source Code

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_constants, only: REFERENCE_DPI
    use fortplot_figure_core, only: figure_t
    use fortplot_figure_initialization, only: figure_state_t
    use fortplot_figure_configuration, only: configure_figure_dimensions, setup_figure_backend
    use fortplot_legend, only: legend_t
    use fortplot_global, only: fig => global_figure
    use fortplot_logging, only: log_error, log_warning, log_info
    use fortplot_system_runtime, only: delete_file_runtime
    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 = [6.4_wp, 4.8_wp]
        if (present(figsize)) requested_size = figsize

        fig_dpi = nint(REFERENCE_DPI)
        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))

        fig = figure_t(state=figure_state_t(legend_data=legend_t()))
        call fig%initialize(dpi=real(fig_dpi, wp))
        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, axes, sharex, sharey)
        !! Initialize an nrows-by-ncols subplot grid (matplotlib-compatible)
        !!
        !! Matplotlib returns (fig, axes). Fortran cannot return tuples, so
        !! this wrapper fills the optional `axes` output with a 2D array of
        !! axis indices matching the grid (row-major). Callers that do not
        !! need the axis matrix may omit it, preserving backward compatibility.
        !!
        !! `sharex` and `sharey` are accepted for API parity but are not yet
        !! wired into the rendering pipeline.
        integer, intent(in) :: nrows, ncols
        integer, allocatable, intent(out), optional :: axes(:,:)
        logical, intent(in), optional :: sharex, sharey
        integer :: i, j

        call ensure_global_figure_initialized()

        if (nrows <= 0 .or. ncols <= 0) then
            call log_error("subplots: Invalid grid dimensions")
            if (present(axes)) allocate(axes(0, 0))
            return
        end if

        call fig%subplots(nrows, ncols)
        fig%current_subplot = 1

        if (present(axes)) then
            allocate(axes(nrows, ncols))
            do i = 1, nrows
                do j = 1, ncols
                    axes(i, j) = (i - 1) * ncols + j
                end do
            end do
        end if

        ! sharex/sharey accepted for matplotlib parity; wiring tracked separately
        call ignore_unused_subplots_kwargs(sharex, sharey)
    end subroutine subplots

    subroutine ignore_unused_subplots_kwargs(sharex, sharey)
        !! Explicit no-op to document silent acceptance of matplotlib kwargs
        logical, intent(in), optional :: sharex, sharey

        if (present(sharex) .or. present(sharey)) return
    end subroutine ignore_unused_subplots_kwargs

    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.
        !!
        !! `dpi` is applied to the figure before rendering so raster outputs
        !! honour the requested resolution. `transparent` and `bbox_inches`
        !! are accepted for signature compatibility; they are not yet wired
        !! to the raster/vector backends but are no-ops rather than warning
        !! targets so matplotlib-style code remains quiet.
        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 apply_savefig_dpi(dpi)
        call consume_savefig_stubs(transparent, bbox_inches)
        call fig%savefig(filename)
    end subroutine savefig

    subroutine savefig_with_status(filename, status, dpi, transparent, bbox_inches)
        !! Save figure and return status code for testing scenarios.
        !! Applies `dpi` and silently accepts `transparent`/`bbox_inches`.
        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 apply_savefig_dpi(dpi)
        call consume_savefig_stubs(transparent, bbox_inches)
        call fig%savefig_with_status(filename, status)
    end subroutine savefig_with_status

    subroutine apply_savefig_dpi(dpi)
        !! Propagate an explicit DPI value into figure state so rendering
        !! honours it. A non-positive value is rejected via log_error so the
        !! caller sees the problem without aborting the save.
        integer, intent(in), optional :: dpi

        if (.not. present(dpi)) return
        if (dpi <= 0) then
            call log_error('savefig: dpi must be positive; ignoring request')
            return
        end if
        call fig%set_dpi(real(dpi, wp))
    end subroutine apply_savefig_dpi

    subroutine consume_savefig_stubs(transparent, bbox_inches)
        !! Accept transparent/bbox_inches without side effects. Placeholder
        !! until raster/vector backends gain explicit support.
        logical, intent(in), optional :: transparent
        character(len=*), intent(in), optional :: bbox_inches
        if (present(transparent)) continue
        if (present(bbox_inches)) continue
    end subroutine consume_savefig_stubs

    subroutine show_data(x, y, label, title_text, xlabel_text, ylabel_text, blocking)
        !! Convenience routine mirroring matplotlib.pyplot.show signature with data
        real(wp), contiguous, 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 delete_file_runtime(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 delete_file_runtime(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