Skip to content

Commit e05eefc

Browse files
committed
Order search dialog
1 parent 667464a commit e05eefc

2 files changed

Lines changed: 192 additions & 85 deletions

File tree

realty-paper/src/main/java/io/github/md5sha256/realty/command/SearchDialog.java

Lines changed: 190 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -11,51 +11,84 @@
1111
import io.github.md5sha256.realty.settings.ConfigRegionTag;
1212
import io.github.md5sha256.realty.settings.RealtyTags;
1313
import io.papermc.paper.dialog.Dialog;
14+
import io.papermc.paper.dialog.DialogResponseView;
1415
import io.papermc.paper.registry.data.dialog.ActionButton;
1516
import io.papermc.paper.registry.data.dialog.DialogBase;
1617
import io.papermc.paper.registry.data.dialog.action.DialogAction;
1718
import io.papermc.paper.registry.data.dialog.action.DialogActionCallback;
1819
import io.papermc.paper.registry.data.dialog.body.DialogBody;
1920
import io.papermc.paper.registry.data.dialog.input.DialogInput;
20-
import io.papermc.paper.registry.data.dialog.input.SingleOptionDialogInput;
2121
import io.papermc.paper.registry.data.dialog.type.DialogType;
2222
import net.kyori.adventure.audience.Audience;
2323
import net.kyori.adventure.text.Component;
2424
import net.kyori.adventure.text.TextComponent;
2525
import net.kyori.adventure.text.event.ClickCallback;
26+
import net.kyori.adventure.text.format.NamedTextColor;
2627
import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder;
2728
import org.bukkit.entity.Player;
2829
import org.jetbrains.annotations.NotNull;
2930
import org.jetbrains.annotations.Nullable;
3031

3132
import java.util.ArrayList;
3233
import java.util.Collection;
34+
import java.util.LinkedHashMap;
3335
import java.util.List;
36+
import java.util.Map;
37+
import java.util.UUID;
3438
import java.util.concurrent.CompletableFuture;
39+
import java.util.concurrent.ConcurrentHashMap;
3540
import java.util.concurrent.atomic.AtomicReference;
3641

3742
/**
3843
* Builds and shows the search dialog to a player, and handles the search
3944
* callback when the player submits the form.
45+
*
46+
* <p>The search flow uses two dialog pages:
47+
* <ol>
48+
* <li>Main dialog — contract type checkboxes and price range inputs.</li>
49+
* <li>Tag dialog — a grid of tag buttons (max 10 per column) that cycle
50+
* through Ignore / Include / Exclude states.</li>
51+
* </ol>
52+
* Per-player state is tracked between the two pages so that criteria and tag
53+
* selections are preserved when navigating back and forth.
4054
*/
4155
public final class SearchDialog {
4256

4357
static final int PAGE_SIZE = 10;
58+
private static final int MAX_TAGS_PER_COLUMN = 10;
4459

4560
static final String INPUT_FREEHOLD = "freehold";
4661
static final String INPUT_LEASEHOLD = "leasehold";
4762
static final String INPUT_MIN_PRICE = "min_price";
4863
static final String INPUT_MAX_PRICE = "max_price";
49-
static final String TAG_PREFIX = "tag_";
50-
51-
private static final String TAG_INCLUDE = "include";
52-
private static final String TAG_EXCLUDE = "exclude";
53-
private static final String TAG_IGNORE = "ignore";
5464

5565
private final Database database;
5666
private final ExecutorState executorState;
5767
private final AtomicReference<RealtyTags> realtyTags;
5868
private final MessageContainer messages;
69+
private final ConcurrentHashMap<UUID, SearchState> playerStates = new ConcurrentHashMap<>();
70+
71+
enum TagState {
72+
IGNORE,
73+
INCLUDE,
74+
EXCLUDE;
75+
76+
TagState next() {
77+
return switch (this) {
78+
case IGNORE -> INCLUDE;
79+
case INCLUDE -> EXCLUDE;
80+
case EXCLUDE -> IGNORE;
81+
};
82+
}
83+
}
84+
85+
static final class SearchState {
86+
boolean freehold = true;
87+
boolean leasehold = true;
88+
String minPrice = "0";
89+
String maxPrice = "";
90+
final Map<String, TagState> tagStates = new LinkedHashMap<>();
91+
}
5992

6093
public SearchDialog(@NotNull Database database,
6194
@NotNull ExecutorState executorState,
@@ -72,118 +105,191 @@ public SearchDialog(@NotNull Database database,
72105
*/
73106
public void open(@NotNull Player player) {
74107
RealtyTags tags = realtyTags.get();
75-
List<DialogInput> inputs = buildInputs(player, tags);
76-
DialogActionCallback searchCallback = buildCallback(player, tags);
77-
78-
Dialog dialog = Dialog.create(factory -> factory.empty()
79-
.base(DialogBase.builder(Component.text("Search Regions"))
80-
.canCloseWithEscape(true)
81-
.afterAction(DialogBase.DialogAfterAction.CLOSE)
82-
.body(List.of(DialogBody.plainMessage(
83-
Component.text("Filter regions by type, tags, and price range."))))
84-
.inputs(inputs)
85-
.build())
86-
.type(DialogType.multiAction(
87-
List.of(ActionButton.builder(Component.text("Search"))
88-
.width(150)
89-
.action(DialogAction.customClick(
90-
searchCallback,
91-
ClickCallback.Options.builder()
92-
.uses(ClickCallback.UNLIMITED_USES)
93-
.build()))
94-
.build()),
95-
ActionButton.builder(Component.text("Cancel"))
96-
.width(150)
97-
.build(),
98-
1))
99-
);
100-
player.showDialog(dialog);
108+
SearchState state = new SearchState();
109+
for (ConfigRegionTag tag : tags.values()) {
110+
if (tag.permission() == null || player.hasPermission(tag.permission().node())) {
111+
state.tagStates.put(tag.tagId(), TagState.IGNORE);
112+
}
113+
}
114+
playerStates.put(player.getUniqueId(), state);
115+
showMainDialog(player, state, tags);
101116
}
102117

103-
private @NotNull List<DialogInput> buildInputs(@NotNull Player player,
104-
@NotNull RealtyTags tags) {
118+
private void showMainDialog(@NotNull Player player,
119+
@NotNull SearchState state,
120+
@NotNull RealtyTags tags) {
105121
List<DialogInput> inputs = new ArrayList<>();
106-
107122
inputs.add(DialogInput.bool(INPUT_FREEHOLD, Component.text("Freehold"))
108-
.initial(true)
123+
.initial(state.freehold)
109124
.onTrue("true")
110125
.onFalse("false")
111126
.build());
112127
inputs.add(DialogInput.bool(INPUT_LEASEHOLD, Component.text("Leasehold"))
113-
.initial(true)
128+
.initial(state.leasehold)
114129
.onTrue("true")
115130
.onFalse("false")
116131
.build());
117-
118-
for (ConfigRegionTag tag : tags.values()) {
119-
if (tag.permission() != null && !player.hasPermission(tag.permission().node())) {
120-
continue;
121-
}
122-
inputs.add(DialogInput.singleOption(
123-
TAG_PREFIX + tag.tagId(),
124-
tag.tagDisplayName(),
125-
List.of(
126-
SingleOptionDialogInput.OptionEntry.create(
127-
TAG_IGNORE, Component.text("Ignore"), true),
128-
SingleOptionDialogInput.OptionEntry.create(
129-
TAG_INCLUDE, Component.text("Include"), false),
130-
SingleOptionDialogInput.OptionEntry.create(
131-
TAG_EXCLUDE, Component.text("Exclude"), false)
132-
)).build());
133-
}
134-
135132
inputs.add(DialogInput.text(INPUT_MIN_PRICE, Component.text("Min Price"))
136133
.width(150)
137-
.initial("0")
134+
.initial(state.minPrice)
138135
.maxLength(15)
139136
.build());
140137
inputs.add(DialogInput.text(INPUT_MAX_PRICE, Component.text("Max Price"))
141138
.width(150)
142-
.initial("")
139+
.initial(state.maxPrice)
143140
.maxLength(15)
144141
.build());
145142

146-
return inputs;
147-
}
143+
ClickCallback.Options clickOptions = ClickCallback.Options.builder()
144+
.uses(ClickCallback.UNLIMITED_USES)
145+
.build();
148146

149-
private @NotNull DialogActionCallback buildCallback(@NotNull Player player,
150-
@NotNull RealtyTags tags) {
151-
return (response, audience) -> {
152-
Boolean freehold = response.getBoolean(INPUT_FREEHOLD);
153-
Boolean leasehold = response.getBoolean(INPUT_LEASEHOLD);
154-
boolean includeFreehold = freehold == null || freehold;
155-
boolean includeLeasehold = leasehold == null || leasehold;
147+
DialogActionCallback searchCallback = (response, audience) -> {
148+
saveCriteria(state, response);
149+
List<String> includedTags = new ArrayList<>();
150+
List<String> excludedTags = new ArrayList<>();
151+
for (Map.Entry<String, TagState> entry : state.tagStates.entrySet()) {
152+
if (entry.getValue() == TagState.INCLUDE) {
153+
includedTags.add(entry.getKey());
154+
} else if (entry.getValue() == TagState.EXCLUDE) {
155+
excludedTags.add(entry.getKey());
156+
}
157+
}
158+
Collection<String> tagFilter = includedTags.isEmpty() ? null : includedTags;
159+
Collection<String> excludeFilter = excludedTags.isEmpty() ? null : excludedTags;
160+
double minPrice = parsePrice(state.minPrice, 0.0);
161+
double maxPrice = parsePrice(state.maxPrice, Double.MAX_VALUE);
162+
boolean includeFreehold = state.freehold;
163+
boolean includeLeasehold = state.leasehold;
164+
playerStates.remove(player.getUniqueId());
156165

157166
if (!includeFreehold && !includeLeasehold) {
158167
audience.sendMessage(messages.messageFor(MessageKeys.SEARCH_NO_RESULTS));
159168
return;
160169
}
170+
performSearch(audience, includeFreehold, includeLeasehold, tagFilter,
171+
excludeFilter, minPrice, maxPrice, 1);
172+
};
161173

162-
List<String> includedTags = new ArrayList<>();
163-
List<String> excludedTags = new ArrayList<>();
164-
for (ConfigRegionTag tag : tags.values()) {
165-
if (tag.permission() != null && !player.hasPermission(tag.permission().node())) {
166-
continue;
167-
}
168-
String value = response.getText(TAG_PREFIX + tag.tagId());
169-
if (TAG_INCLUDE.equals(value)) {
170-
includedTags.add(tag.tagId());
171-
} else if (TAG_EXCLUDE.equals(value)) {
172-
excludedTags.add(tag.tagId());
173-
}
174+
DialogActionCallback configTagsCallback = (response, audience) -> {
175+
saveCriteria(state, response);
176+
showTagDialog(player, state, tags);
177+
};
178+
179+
List<ActionButton> actions = new ArrayList<>();
180+
actions.add(ActionButton.builder(Component.text("Search"))
181+
.width(150)
182+
.action(DialogAction.customClick(searchCallback, clickOptions))
183+
.build());
184+
if (!state.tagStates.isEmpty()) {
185+
actions.add(ActionButton.builder(Component.text("Filter Tags"))
186+
.width(150)
187+
.action(DialogAction.customClick(configTagsCallback, clickOptions))
188+
.build());
189+
}
190+
191+
Dialog dialog = Dialog.create(factory -> factory.empty()
192+
.base(DialogBase.builder(Component.text("Search Regions"))
193+
.canCloseWithEscape(true)
194+
.afterAction(DialogBase.DialogAfterAction.CLOSE)
195+
.body(List.of(DialogBody.plainMessage(
196+
Component.text("Filter regions by type and price range."))))
197+
.inputs(inputs)
198+
.build())
199+
.type(DialogType.multiAction(
200+
actions,
201+
ActionButton.builder(Component.text("Cancel")).width(150).build(),
202+
actions.size()))
203+
);
204+
player.showDialog(dialog);
205+
}
206+
207+
private void showTagDialog(@NotNull Player player,
208+
@NotNull SearchState state,
209+
@NotNull RealtyTags tags) {
210+
ClickCallback.Options clickOptions = ClickCallback.Options.builder()
211+
.uses(ClickCallback.UNLIMITED_USES)
212+
.build();
213+
214+
List<ActionButton> tagButtons = new ArrayList<>();
215+
for (ConfigRegionTag tag : tags.values()) {
216+
if (tag.permission() != null && !player.hasPermission(tag.permission().node())) {
217+
continue;
174218
}
219+
TagState currentState = state.tagStates.getOrDefault(tag.tagId(), TagState.IGNORE);
220+
Component label = buildTagLabel(tag.tagDisplayName(), currentState);
221+
222+
String tagId = tag.tagId();
223+
DialogActionCallback toggleCallback = (response, audience) -> {
224+
TagState current = state.tagStates.getOrDefault(tagId, TagState.IGNORE);
225+
state.tagStates.put(tagId, current.next());
226+
showTagDialog(player, state, tags);
227+
};
228+
229+
tagButtons.add(ActionButton.builder(label)
230+
.width(150)
231+
.action(DialogAction.customClick(toggleCallback, clickOptions))
232+
.build());
233+
}
175234

176-
Collection<String> tagFilter = includedTags.isEmpty() ? null : includedTags;
177-
Collection<String> excludeFilter = excludedTags.isEmpty() ? null : excludedTags;
235+
int tagCount = tagButtons.size();
236+
int columns = Math.max(1, (tagCount + MAX_TAGS_PER_COLUMN - 1) / MAX_TAGS_PER_COLUMN);
237+
238+
DialogActionCallback doneCallback = (response, audience) ->
239+
showMainDialog(player, state, tags);
178240

179-
double minPrice = parsePrice(response.getText(INPUT_MIN_PRICE), 0.0);
180-
double maxPrice = parsePrice(response.getText(INPUT_MAX_PRICE), Double.MAX_VALUE);
241+
Dialog dialog = Dialog.create(factory -> factory.empty()
242+
.base(DialogBase.builder(Component.text("Filter Tags"))
243+
.canCloseWithEscape(true)
244+
.afterAction(DialogBase.DialogAfterAction.CLOSE)
245+
.body(List.of(DialogBody.plainMessage(
246+
Component.text("Click a tag to cycle: Ignore -> Include -> Exclude"))))
247+
.inputs(List.of())
248+
.build())
249+
.type(DialogType.multiAction(
250+
tagButtons,
251+
ActionButton.builder(Component.text("Done"))
252+
.width(150)
253+
.action(DialogAction.customClick(doneCallback, clickOptions))
254+
.build(),
255+
columns))
256+
);
257+
player.showDialog(dialog);
258+
}
181259

182-
performSearch(audience, includeFreehold, includeLeasehold, tagFilter, excludeFilter,
183-
minPrice, maxPrice, 1);
260+
private @NotNull Component buildTagLabel(@NotNull Component tagName,
261+
@NotNull TagState tagState) {
262+
return switch (tagState) {
263+
case IGNORE -> tagName.colorIfAbsent(NamedTextColor.GRAY);
264+
case INCLUDE -> Component.text()
265+
.color(NamedTextColor.GREEN)
266+
.append(Component.text("[Include] "))
267+
.append(tagName)
268+
.build();
269+
case EXCLUDE -> Component.text()
270+
.color(NamedTextColor.RED)
271+
.append(Component.text("[Exclude] "))
272+
.append(tagName)
273+
.build();
184274
};
185275
}
186276

277+
private void saveCriteria(@NotNull SearchState state,
278+
@NotNull DialogResponseView response) {
279+
Boolean freehold = response.getBoolean(INPUT_FREEHOLD);
280+
state.freehold = freehold == null || freehold;
281+
Boolean leasehold = response.getBoolean(INPUT_LEASEHOLD);
282+
state.leasehold = leasehold == null || leasehold;
283+
String minPrice = response.getText(INPUT_MIN_PRICE);
284+
if (minPrice != null) {
285+
state.minPrice = minPrice;
286+
}
287+
String maxPrice = response.getText(INPUT_MAX_PRICE);
288+
if (maxPrice != null) {
289+
state.maxPrice = maxPrice;
290+
}
291+
}
292+
187293
/**
188294
* Executes the search query and sends paginated results to the audience.
189295
*/

realty-paper/src/main/java/io/github/md5sha256/realty/settings/RealtyTags.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import org.jetbrains.annotations.Nullable;
55

66
import java.util.Collection;
7+
import java.util.Collections;
78
import java.util.LinkedHashMap;
89
import java.util.Map;
910
import java.util.Set;
@@ -17,7 +18,7 @@ public RealtyTags(@NotNull RegionTagSettings settings) {
1718
for (ConfigRegionTag tag : settings.tags()) {
1819
map.put(tag.tagId(), tag);
1920
}
20-
this.tags = Map.copyOf(map);
21+
this.tags = Collections.unmodifiableMap(map);
2122
}
2223

2324
public @NotNull Map<String, ConfigRegionTag> tags() {

0 commit comments

Comments
 (0)