-
Notifications
You must be signed in to change notification settings - Fork 7
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.
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.
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();| 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.
.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.
.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 entryA String or a FancyText does not know about languages, so it answers the same thing to everybody
- in practice, the default language. A Localization
LocaleMessageanswersgetFancyText(reader). The same overloads exist forsetFormatHeader(...)andsetFormatFooter(...), 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))| 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.
.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.
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 otherwiseviewer.invalidate() discards the cached read, so the next send consults the source again - for the
owner of the data, who knows when it changed.
.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 onlyEvery 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.
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").
Both paths depend on the link being stable, re-executable text:
-
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 3back, on a vanilla client. - 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.
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 readPageVisualization 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.
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);
}
}- FancyText - the rich-text model a line is written in
-
Placeholders - the
${key}engine and its rules -
Localization -
LocaleMessageand per-player language -
Argument Parsing - the
[page]argument
EverNifeCore · Home · made by Petrus Pradella
Getting Started
Commands & Text
Player Data & Storage
- PlayerData & PDSections
- Accounts
- Storage Backends
- Inline Backends for Plugins
- Block Data (SVWorldDataManager)
- Legacy Data Migration
- Cooldowns
Config & Minecraft Systems
- Configuration
- Logging & Debug Modules
- Events
- Scheduler & Threading
- Items & NBT
- GUI Framework
- Integrations
- Economy
- Version Compatibility
Architecture & Reference