6 ex__gui__tooltip_8c
gullradriel edited this page 2026-10-09 00:55:18 +02:00

examples/ex_gui_tooltip.c

Functions

Name
void usage(void )
void render(N_GUI_CTX * gui, ALLEGRO_BITMAP * target, int armed, int widget_id)
void process_args(int argc, char ** argv)
int main(int argc, char ** argv)
int hover_tooltip_at(N_GUI_CTX * gui, int x, int y)
void expect_true(const char * label, int cond)
int bubble_width(ALLEGRO_BITMAP * a, ALLEGRO_BITMAP * b)

Attributes

Name
int failures

Defines

Name
TT_W
TT_H
SPLIT_Y
SPLIT_X
SPLIT_W
SPLIT_H
PANEL_X
PANEL_W
ALLEGRO_UNSTABLE

Functions Documentation

function usage

static void usage(
    void 
)

function render

static void render(
    N_GUI_CTX * gui,
    ALLEGRO_BITMAP * target,
    int armed,
    int widget_id
)

function process_args

static void process_args(
    int argc,
    char ** argv
)

function main

int main(
    int argc,
    char ** argv
)

function hover_tooltip_at

static int hover_tooltip_at(
    N_GUI_CTX * gui,
    int x,
    int y
)

function expect_true

static void expect_true(
    const char * label,
    int cond
)

function bubble_width

static int bubble_width(
    ALLEGRO_BITMAP * a,
    ALLEGRO_BITMAP * b
)

Attributes Documentation

variable failures

static int failures = 0;

Macros Documentation

define TT_W

#define TT_W 1200

define TT_H

#define TT_H 400

define SPLIT_Y

#define SPLIT_Y 100.0f

define SPLIT_X

#define SPLIT_X 400.0f

define SPLIT_W

#define SPLIT_W 200.0f

define SPLIT_H

#define SPLIT_H 120.0f

define PANEL_X

#define PANEL_X 10.0f

define PANEL_W

#define PANEL_W 80.0f

define ALLEGRO_UNSTABLE

#define ALLEGRO_UNSTABLE 1

Source code

/*
 * Nilorea Library
 * Copyright (C) 2005-2026 Castagnier Mickael
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
 * implied. See the License for the specific language governing
 * permissions and limitations under the License.
 *
 * SPDX-License-Identifier: Apache-2.0
 */


#define ALLEGRO_UNSTABLE 1

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#include "nilorea/n_common.h"
#include "nilorea/n_log.h"
#include "nilorea/n_gui.h"

/* wide enough that an uncapped bubble can outrun style.tooltip_max_w */
#define TT_W 1200
#define TT_H 400

/* the panel is deliberately tiny: it is the "70 px HUD gauge" shape that used to
   squeeze the bubble down to a few characters per line */
#define PANEL_X 10.0f
#define PANEL_W 80.0f

/* window holding the split pane whose divider band is the only tooltip target */
#define SPLIT_X 400.0f
#define SPLIT_Y 100.0f
#define SPLIT_W 200.0f
#define SPLIT_H 120.0f

static int failures = 0;

static void expect_true(const char* label, int cond) {
    if (!cond) {
        n_log(LOG_ERR, "%s: condition false", label);
        failures++;
    }
}

static void usage(void) {
    fprintf(stderr,
            "     -v version\n"
            "     -V log level: LOG_NULL, LOG_NOTICE, LOG_INFO, LOG_ERR, LOG_DEBUG\n"
            "     -h help\n");
}

/* the example is quiet by default (LOG_ERR); -V raises it, which is what
   run_tests.sh passes. */
static void process_args(int argc, char** argv) {
    int getoptret = 0;
    int log_level = LOG_ERR;

    while ((getoptret = getopt(argc, argv, "hvV:")) != EOF) {
        switch (getoptret) {
            case 'v':
                fprintf(stderr, "build date: %s %s\n", __DATE__, __TIME__);
                exit(0);
            case 'V':
                if (!strcmp("LOG_NULL", optarg))
                    log_level = LOG_NULL;
                else if (!strcmp("LOG_NOTICE", optarg))
                    log_level = LOG_NOTICE;
                else if (!strcmp("LOG_INFO", optarg))
                    log_level = LOG_INFO;
                else if (!strcmp("LOG_ERR", optarg))
                    log_level = LOG_ERR;
                else if (!strcmp("LOG_DEBUG", optarg))
                    log_level = LOG_DEBUG;
                else {
                    fprintf(stderr, "%s is not a valid log level\n", optarg);
                    exit(1);
                }
                break;
            case 'h':
                usage();
                exit(0);
            default:
            case '?':
                if (optopt == 'V') fprintf(stderr, "\n      Missing log level\n");
                usage();
                exit(1);
        }
    }
    set_log_level(log_level);
}

/* Render the whole GUI into target, with the tooltip either armed or not. The
   bubble is found by differencing the two renders, so nothing here depends on
   the bubble's colours or on how its corners are rasterized. */
static void render(N_GUI_CTX* gui, ALLEGRO_BITMAP* target, int armed, int widget_id) {
    al_set_target_bitmap(target);
    al_clear_to_color(al_map_rgb(0, 0, 0));
    gui->tooltip_widget_id = armed ? widget_id : -1;
    if (armed) {
        /* pretend the hover happened long enough ago that the delay has elapsed */
        gui->tooltip_armed_at = al_get_time() - (double)gui->style.tooltip_delay - 1.0;
    }
    n_gui_draw(gui);
}

/* Width in pixels of the region that appears only when the tooltip is armed,
   i.e. the bubble. Returns 0 when the two renders are identical. */
static int bubble_width(ALLEGRO_BITMAP* a, ALLEGRO_BITMAP* b) {
    ALLEGRO_LOCKED_REGION* la = al_lock_bitmap(a, ALLEGRO_PIXEL_FORMAT_ABGR_8888_LE, ALLEGRO_LOCK_READONLY);
    ALLEGRO_LOCKED_REGION* lb = al_lock_bitmap(b, ALLEGRO_PIXEL_FORMAT_ABGR_8888_LE, ALLEGRO_LOCK_READONLY);
    int min_x = TT_W, max_x = -1;

    if (la && lb) {
        for (int y = 0; y < TT_H; y++) {
            const unsigned char* ra = (const unsigned char*)la->data + (ptrdiff_t)y * la->pitch;
            const unsigned char* rb = (const unsigned char*)lb->data + (ptrdiff_t)y * lb->pitch;
            for (int x = 0; x < TT_W; x++) {
                if (memcmp(ra + (ptrdiff_t)x * 4, rb + (ptrdiff_t)x * 4, 4) != 0) {
                    if (x < min_x) min_x = x;
                    if (x > max_x) max_x = x;
                }
            }
        }
    }
    if (la) al_unlock_bitmap(a);
    if (lb) al_unlock_bitmap(b);

    return (max_x < 0) ? 0 : (max_x - min_x + 1);
}

/* Move the pointer to (x,y) through a real mouse event and report which widget
   the arming scan picked, so the test exercises n_gui_process_event rather than
   the tooltip_widget_id field it maintains. */
static int hover_tooltip_at(N_GUI_CTX* gui, int x, int y) {
    ALLEGRO_EVENT ev;
    memset(&ev, 0, sizeof(ev));
    ev.type = ALLEGRO_EVENT_MOUSE_AXES;
    ev.mouse.x = x;
    ev.mouse.y = y;
    n_gui_process_event(gui, ev);
    return gui->tooltip_widget_id;
}

int main(int argc, char** argv) {
    process_args(argc, argv);

    if (!al_init() || !al_init_font_addon()) {
        n_log(LOG_ERR, "allegro init failed");
        return 1;
    }
    al_init_primitives_addon();

    /* memory bitmaps: the whole test runs without a display, like ex_gui_kvtable */
    al_set_new_bitmap_flags(ALLEGRO_MEMORY_BITMAP);
    ALLEGRO_BITMAP* plain = al_create_bitmap(TT_W, TT_H);
    ALLEGRO_BITMAP* tipped = al_create_bitmap(TT_W, TT_H);
    ALLEGRO_FONT* font = al_create_builtin_font();
    if (!plain || !tipped || !font) {
        n_log(LOG_ERR, "bitmap or font creation failed");
        return 1;
    }

    N_GUI_CTX* gui = n_gui_new_ctx(font);
    if (!gui) {
        n_log(LOG_ERR, "n_gui_new_ctx failed");
        return 1;
    }
    n_gui_set_display_size(gui, (float)TT_W, (float)TT_H);

    int win = n_gui_add_window(gui, "HUD", PANEL_X, 10.0f, PANEL_W, 60.0f);
    int btn = n_gui_add_button(gui, win, "?", 5.0f, 5.0f, 30.0f, 20.0f, N_GUI_SHAPE_RECT, NULL, NULL);
    expect_true("window created", win >= 0);
    expect_true("button created", btn >= 0);

    /* long enough that its natural width outruns both the panel and the cap */
    n_gui_set_widget_tooltip(gui, btn,
                             "Promote this panel to a real OS window, and back again, "
                             "which is the sort of sentence a tooltip has to hold when it "
                             "explains something instead of restating the label beside it.");

    /* The middle of that button, in display coordinates: the client area of a
       titled window starts below its title bar, so 30,40 is the title bar and
       not the button. The blocks that drive the arming scan through real mouse
       motion need the real thing. */
    const int btn_x = (int)(PANEL_X + 5.0f + 15.0f);
    const int btn_y = (int)(10.0f + gui->style.titlebar_h + 5.0f + 10.0f);

    /* cursor inside the tiny panel, with room to the right for the bubble */
    gui->mouse_x = 30;
    gui->mouse_y = 40;

    /* An in-app window must not narrow the bubble. Before the fix the bubble was
       clamped to the widget's window rect, so it came out no wider than the 80px
       panel and the sentence wrapped to a few characters per line. */
    {
        render(gui, plain, 0, btn);
        render(gui, tipped, 1, btn);
        int w = bubble_width(plain, tipped);
        expect_true("tooltip is drawn at all", w > 0);
        expect_true("bubble is not narrowed to the in-app window", (float)w > PANEL_W);
        n_log(LOG_INFO, "in-app bubble width: %d px (panel is %.0f px)", w, (double)PANEL_W);
    }

    /* style.tooltip_max_w caps it: the bubble stays near the cap, not the display */
    {
        gui->style.tooltip_max_w = 480.0f;
        render(gui, plain, 0, btn);
        render(gui, tipped, 1, btn);
        int capped = bubble_width(plain, tipped);
        /* text width + the bubble's 8px horizontal padding each side, plus a pixel
           of slack for the rounded border */
        expect_true("bubble honours tooltip_max_w", capped <= 480 + 16 + 2);
        expect_true("bubble still uses the width it is allowed", capped > 480 / 2);

        /* 0 disables the cap, and the same tooltip then runs far wider */
        gui->style.tooltip_max_w = 0.0f;
        render(gui, plain, 0, btn);
        render(gui, tipped, 1, btn);
        int uncapped = bubble_width(plain, tipped);
        expect_true("tooltip_max_w = 0 restores wrap-at-the-clamp-edge", uncapped > capped);
        n_log(LOG_INFO, "capped %d px, uncapped %d px", capped, uncapped);

        gui->style.tooltip_max_w = 480.0f;
    }

    /* a widget with no tooltip draws nothing extra */
    {
        int bare = n_gui_add_button(gui, win, "x", 40.0f, 5.0f, 20.0f, 20.0f, N_GUI_SHAPE_RECT, NULL, NULL);
        render(gui, plain, 0, bare);
        render(gui, tipped, 1, bare);
        expect_true("no tooltip text means no bubble", bubble_width(plain, tipped) == 0);
    }

    /* A split pane spans the whole region it divides, so only its divider band
       arms its tooltip: taking the whole rect would answer for every
       widget-free pixel of both panes. This drives the arming scan through
       real mouse motion rather than poking tooltip_widget_id. */
    {
        int spwin = n_gui_add_window(gui, "Split", SPLIT_X, SPLIT_Y, SPLIT_W, SPLIT_H);
        int sp = n_gui_add_splitpane(gui, spwin, 0.0f, 0.0f, SPLIT_W, SPLIT_H,
                                     N_GUI_SPLIT_VERTICAL, 0.5f, NULL, NULL);
        expect_true("split pane created", sp >= 0);
        n_gui_set_widget_tooltip(gui, sp, "Drag to resize");

        /* client-area origin of a titled window: y is below the title bar */
        float ox = SPLIT_X;
        float oy = SPLIT_Y + gui->style.titlebar_h;
        int divider_x = (int)(ox + SPLIT_W * 0.5f);

        expect_true("pointer on the divider arms the split pane",
                    hover_tooltip_at(gui, divider_x, (int)(oy + SPLIT_H * 0.5f)) == sp);
        expect_true("pointer well left of the divider arms nothing",
                    hover_tooltip_at(gui, (int)(ox + 10.0f), (int)(oy + SPLIT_H * 0.5f)) == -1);
        expect_true("pointer well right of the divider arms nothing",
                    hover_tooltip_at(gui, (int)(ox + SPLIT_W - 10.0f), (int)(oy + SPLIT_H * 0.5f)) == -1);

        /* a widget sitting over a pane still gets its own tooltip */
        int over = n_gui_add_button(gui, spwin, "b", 10.0f, 10.0f, 30.0f, 20.0f, N_GUI_SHAPE_RECT, NULL, NULL);
        n_gui_set_widget_tooltip(gui, over, "on top of the pane");
        expect_true("a widget above the pane still arms its own tooltip",
                    hover_tooltip_at(gui, (int)(ox + 20.0f), (int)(oy + 20.0f)) == over);

        n_gui_close_window(gui, spwin);
    }

    /* A window opened over the armed widget hides the bubble. The bubble is
       drawn last and over everything, and nothing re-runs the arming scan when
       a window opens without the pointer moving, so it used to be painted over
       the window that had just appeared - a context menu on a right click, with
       its first entries covered until the pointer moved. */
    {
        int menu = 0;

        gui->mouse_x = btn_x;
        gui->mouse_y = btn_y;
        render(gui, plain, 0, btn);
        render(gui, tipped, 1, btn);
        expect_true("the bubble shows with nothing over it", bubble_width(plain, tipped) > 0);

        /* over the pointer, and above the panel: a pop-up sorts in front */
        menu = n_gui_add_window(gui, "Menu", 20.0f, 30.0f, 120.0f, 90.0f);
        expect_true("menu window created", menu >= 0);
        n_gui_window_set_flags(gui, menu, N_GUI_WIN_FRAMELESS);
        n_gui_window_set_zorder(gui, menu, N_GUI_ZORDER_POPUP, 0);
        expect_true("nothing is pending while a window covers the pointer",
                    n_gui_tooltip_pending(gui) == 0);
        render(gui, plain, 0, btn);
        render(gui, tipped, 1, btn);
        expect_true("and no bubble is drawn over it", bubble_width(plain, tipped) == 0);

        /* the same window somewhere else leaves the bubble alone */
        n_gui_window_set_rect(gui, menu, 600.0f, 200.0f, 120.0f, 90.0f);
        render(gui, plain, 0, btn);
        render(gui, tipped, 1, btn);
        expect_true("a window that does not cover the pointer changes nothing",
                    bubble_width(plain, tipped) > 0);

        /* and closing it brings the bubble back where it was */
        n_gui_window_set_rect(gui, menu, 20.0f, 30.0f, 120.0f, 90.0f);
        n_gui_close_window(gui, menu);
        render(gui, plain, 0, btn);
        render(gui, tipped, 1, btn);
        expect_true("a closed window covers nothing", bubble_width(plain, tipped) > 0);
    }

    /* Any button dismisses an armed tooltip, not only the first. A right click
       is what opens a context menu, and it used to leave the bubble armed. */
    {
        ALLEGRO_EVENT ev;
        int button = 0;

        for (button = 1; button <= 3; button++) {
            expect_true("the pointer arms the button's tooltip",
                        hover_tooltip_at(gui, btn_x, btn_y) == btn);
            memset(&ev, 0, sizeof(ev));
            ev.type = ALLEGRO_EVENT_MOUSE_BUTTON_DOWN;
            ev.mouse.button = (unsigned)button;
            ev.mouse.x = btn_x;
            ev.mouse.y = btn_y;
            n_gui_process_event(gui, ev);
            expect_true("and a press on any button dismisses it", gui->tooltip_widget_id == -1);

            memset(&ev, 0, sizeof(ev));
            ev.type = ALLEGRO_EVENT_MOUSE_BUTTON_UP;
            ev.mouse.button = (unsigned)button;
            ev.mouse.x = btn_x;
            ev.mouse.y = btn_y;
            n_gui_process_event(gui, ev);
        }
    }

    n_gui_destroy_ctx(&gui);
    al_destroy_font(font);
    al_destroy_bitmap(plain);
    al_destroy_bitmap(tipped);

    if (failures) {
        n_log(LOG_ERR, "ex_gui_tooltip: %d failure(s)", failures);
        return 1;
    }
    n_log(LOG_NOTICE, "ex_gui_tooltip: all checks passed");
    return 0;
}