Skip to content

PageViewer

Pétrus Pradella edited this page Nov 1, 2026 · 8 revisions

PageViewer

What this page covers: the chat renderer of a Ranking list - the paginated page every /list, /baltop and /top style command sends: how it is written for each reader, its theme, and what clicking a page button actually runs.

A page separates the expensive half from the cheap half. Reading the source, ordering it, cutting it and resolving the values every reader shares belongs to the RankedList and is cached there as a RankedSnapshot. The text is cheap and differs per reader - language, their own position on the list

  • so the PageViewer builds it at send time and never caches it.

The 30-second version

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

PageViewer<FPlayer> online = PageViewer.of(
                RankedList.of(plugin, "online", FPlayer.class)    // reachable as /ecpage myplugin:online 3
                .source(() -> EverNifeCore.getPlatform().getOnlinePlayers())
                .unlimitedEntries()                               // required step - see Ranking
                .orderBy(FPlayer::getName).ascending()
                .build())
        .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. Source, ceiling, order, cache and the shared values (addValue) are the list's - see Ranking. The page adds the format, the theme, the page size (setPageSize, default 10), the navigation and the per-reader placeholders.


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 list answers for on its own

Key Value
${number} The entry's 1-based position in the whole list, not in the current page.
${value} What orderBy extracted, or the entry itself on a list with no order.
${player} The entry's name, on a list whose target type is an FPlayer or an IPlayerData. Answers null for a row read straight from storage - see Ranking.

Each of them is a default: declare the same key with addValue on the list and yours wins.


Two levels of placeholder

RankedList.of(plugin, "balance", FEPlayerData.class)
        ...
        .addValue("balance", FEPlayerData::getMoneyFormatted)        // on the list
        .build();
PageViewer.of(list)
        .addViewerPlaceholder("highlight", (row, reader) ->            // on the page
                row.getUniqueId().equals(reader.getUniqueId()) ? "&6&l" : "&e")
Level Resolved Cost
RankedList addValue(key, Function<T, Object>) once per entry per snapshot, shared by every reader, page and placeholder 1 call per entry
PageViewer addViewerPlaceholder(key, BiFunction<T, FCommandSender, Object>) in each recipient's own render 1 call per line per reader

A viewer placeholder is not computed where the line does not cite the key, and a key cited twice on the same line (text and hover) is resolved once. addValue is the one to reach for by default.

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


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 over a named list (RankedList.of) navigates by the list's id; one over an anonymous list (RankedList.anonymous) 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 list whose content does not depend on arguments, because the id has to mean the same thing tomorrow. Building a RankedList.of(owner, name, ...) is the whole registration; /ecpage <id> <page> sends the renderer the last PageViewer.of(list).build() attached, which PageViewer.attachedTo(list) answers (null for an anonymous list or one never paged). The list itself knows nothing of its chat renderer: keep the PageViewer you built in a field and send through it.

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

A list with a plain source is delivered inline, on the sender's thread. A list with an async source is delivered when its read lands, on the main thread (ECScheduler.onMainThread); the command scope (${label}) is captured at the send. A read that fails with no previous snapshot re-throws its cause to the sender's command, as a plain source that throws always did.

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(
                        RankedList.of(EverNifeCore.getEcPluginData(), "list", FPlayer.class)
                        .source(() -> EverNifeCore.getPlatform().getOnlinePlayers())
                        .unlimitedEntries()
                        .orderBy(FPlayer::getName).ascending()
                        .build())
                .setFormatLine(LINE)
                .build();
    }

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

The core's real list is /playerlist, and it also takes /list while Settings.Commands.LIST_ALIAS in EverNifeCore's config.yml is true (the default). Set it to false to keep the server's own /list.


See also

Clone this wiki locally