Skip to content

PageViewer

Petrus Pradella edited this page Aug 10, 2026 · 2 revisions

PageViewer

What this page covers: the paginated list every /list, /baltop and /top style command is built out of - how a page reads its data, how much of it it keeps, how it is written for each reader, and what clicking a page button actually runs.

A page separates the expensive half from the cheap half. Reading the source and ordering it is expensive and identical for everybody, so it is cached as a PageSnapshot. The text is cheap and differs per reader - language, their own position on the list - so it is built at send time and never cached.


The 30-second version

import br.com.finalcraft.evernifecore.pageviewer.PageViewer;
import br.com.finalcraft.evernifecore.pageviewer.theme.PageTheme;

PageViewer<FPlayer> online = PageViewer.of(FPlayer.class)
        .id("myplugin:online")                              // reachable as /ecpage myplugin:online 3
        .source(() -> EverNifeCore.getPlatform().getOnlinePlayers())
        .unlimitedEntries()                                 // required step - see "The ceiling"
        .orderBy(FPlayer::getName).ascending()
        .setFormatLine("&7#${number}: &a${player}")
        .theme(PageTheme.classic().withTotalCount())
        .build();

online.send(page, sender);

Build the page once and keep it; send is what you call per invocation.


The builder is a staircase

of -> source -> a ceiling -> everything else. The two first steps and the ceiling are enforced by the compiler: there is no path to build() that skips them.

PageViewer.of(IPlayerData.class)        // IStepSource:  id(...) and source(...)
        .id("finaleconomy:baltop")      // optional - see "Navigation"
        .source(() -> PlayerController.getAllPlayerData())
        .maxEntries(data -> FEConfig.BALTOP_SIZE)   // IStepLimit: one of the three below
        .orderBy(economy::balanceOf).descending()   // IBuilder from here on
        .build();

The ceiling

Step What it does
maxEntries(int) Keeps at most that many entries.
maxEntries(Function<List<O>, Integer>) Decides the cap when the source is read, from the raw list - for a ceiling that lives in the plugin's own config, or depends on how much came back.
unlimitedEntries() Every entry the source returned, however many.

There is no default. Any number the core picked would be wrong for somebody - 50 truncates the /list of a full server, 10 truncates almost everything - and it is information only the page's author has.

What the ceiling cut is counted, not hidden: the total line says how many the source returned, and its hover says how many of them this page can reach ("Showing 50 of 3412"). Nothing is silently dropped, and the warning costs no line of chat.

The order

.orderBy(FPlayer::getName).ascending()
.orderBy(IPlayerData::getBalance).descending()

The direction is the one that was asked for - there is no reversal on top of it. Without orderBy, nothing is sorted: the page comes out exactly as the source returned it.

Numbers compare numerically whatever type they arrived in; constants of one enum compare in the order they were declared in; two values of the very same Comparable type compare the way that type says; anything else compares as case-insensitive text.


The line

.setFormatLine("&7#${number}: &a${value}")          // same text for everybody
.setFormatLine(FancyText.of("...").setHover("..."))  // same, with hover/click
.setFormatLine(LINE_MESSAGE)                         // a LocaleMessage: each reader's language
.setFormatLine(entry -> new FancySegment(...))       // a fresh FancyText per entry

A String or a FancyText does not know about languages, so it answers the same thing to everybody

  • in practice, the default language. A Localization LocaleMessage answers getFancyText(reader). The same overloads exist for setFormatHeader(...) and setFormatFooter(...), which take varargs (one line each).

Anything sendable is also a line, which is how a decorated message becomes one - custom() hands back a per-send decoration, and appending joins two locale messages into a single line:

.setFormatLine(TOP_LINE.custom()
        .setHover("&7Click for details of ${player}")
        .append(TOP_LINE_SUFFIX))

Keys the page answers for on its own

Key Value
${number} The entry's 1-based position in the whole page, not in the current slice.
${value} What orderBy extracted, or the entry itself on a page with no order.
${player} The entry's name, on a page whose target type is an FPlayer or an IPlayerData.

Each of them is a default: declare the same key yourself and yours wins.


Two levels of placeholder

.addRowPlaceholder("balance", data -> economy.balanceOf(data))
.addViewerPlaceholder("highlight", (data, reader) ->
        data.getUniqueId().equals(reader.getUniqueId()) ? "&6&l" : "&e")
Level Resolved Cost
addRowPlaceholder(key, Function<OBJ, Object>) once per entry, shared by everyone reading the page 1 call per line
addViewerPlaceholder(key, BiFunction<OBJ, FCommandSender, Object>) in each recipient's own render 1 call per line per reader

Neither is computed where the line does not cite the key, and a key cited twice on the same line (text and hover) is resolved once. The cost is in the name of the method: addRowPlaceholder is the one to reach for by default.

Keys are declared bare - "balance", never "${balance}". See Placeholders.


The cache

One read of the source becomes a PageSnapshot: the entries, ordered, numbered and already cut to the ceiling. How long it is reused is declared where the cost is known:

.cache(CachePolicy.ttl(Duration.ofSeconds(5)))   // the default
.cache(CachePolicy.none())                       // read the source on every send
.cache(CachePolicy.manual())                     // kept until invalidate() says otherwise

viewer.invalidate() discards the cached read, so the next send consults the source again - for the owner of the data, who knows when it changed.


The chrome

.theme(PageTheme.classic())                        // a rule above the entries
.theme(PageTheme.classic().withDate().withTotalCount())
.theme(PageTheme.autoFit())                        // the rule measured against the chat width
.theme(PageTheme.none())                           // entries only

Every line of a theme is a locale message, so restyling or translating a page is editing lang_XX.yml - the same road as any other message of the core.

classic() writes the rule out as 53 dashes, the number that fills a 320px chat line. autoFit() measures it instead, which depends on the client: chat width, GUI scale, forced unicode font and resource packs are all its options, and the server cannot see any of them. On a platform that cannot measure text at all, autoFit() hands its input straight back rather than guessing.

A page with no entries prints the theme's empty state instead of buttons pointing at page 1 of 0.


Navigation

What a page button runs. A page that declares an id navigates by name; one that does not opens a session for its reader. Either is overridden by navigation(...).

Strategy Memory The link Re-runs the command
PageNavigation.command("/finaljobs top %page%") none /finaljobs top 3 yes
PageNavigation.registered("finaljobs:top") none /ecpage finaljobs:top 3 no
PageNavigation.session() one entry per reader, 10 min /ecpage 8f3a1c...-b1 3 no
PageNavigation.none() none no bar at all -

command(...) runs the caller's own command line again, side effects and all. Only for a command that is idempotent and cheap; one that charges the player, consumes a cooldown or writes an audit entry must not use it.

registered(...) needs a page whose content does not depend on arguments, because the id has to mean the same thing tomorrow. Declaring .id("plugin:name") on the builder is the whole registration.

session() is the fallback for a page that depends on an argument and therefore cannot be named. The handle belongs to one reader - somebody else holding it is answered exactly like a handle that expired - and it does not survive a relog. When it is gone, the page says so and names the way out ("run the command again").

What survives a relog

Both paths depend on the link being stable, re-executable text:

  1. The client's command history. Clicking a button runs a command, and that command enters the history. Up-arrow after a relog brings /ecpage finaljobs:top 3 back, on a vanilla client.
  2. Mods that keep the chat across relogs. The clickable button comes back whole.

Which is why a named page is worth naming: /ecpage finaljobs:top 3 still means the same page tomorrow, and a session handle does not.


Sending

viewer.send(sender);                    // page 1
viewer.send(Integer.valueOf(3), sender);
viewer.send(pageVisualization, sender); // the [page] argument - see Argument Parsing
viewer.send(sender, otherSender);       // one page, several recipients, one source read

PageVisualization is what the [page] argument parses into: a page, a range (2-4) or all, the last two behind their own permission nodes. all shows everything the page holds - which is everything the ceiling let in, with the rest still counted in the total.


Gotcha: build the page after its locale fields exist

A command executor is instantiated before its @FCLocale fields are filled, so a page built in a field initialiser would capture a message that is still null. Build it lazily:

public class CMDList {

    @FCLocale(lang = LocaleType.EN_US, text = "&7# ${number}: &a${player}")
    private static LocaleMessage LINE;

    // Initialised on first use, which is after the locale scan has run.
    private static final class Page {
        static final PageViewer<FPlayer> ONLINE = PageViewer.of(FPlayer.class)
                .id("evernifecore:list")
                .source(() -> EverNifeCore.getPlatform().getOnlinePlayers())
                .unlimitedEntries()
                .orderBy(FPlayer::getName).ascending()
                .setFormatLine(LINE)
                .build();
    }

    @FinalCMD(aliases = {"list"})
    public void onCommand(FCommandSender sender, @Arg("[page]") PageVisualization page) {
        Page.ONLINE.send(page, sender);
    }
}

See also

Clone this wiki locally