1111import io .github .md5sha256 .realty .settings .ConfigRegionTag ;
1212import io .github .md5sha256 .realty .settings .RealtyTags ;
1313import io .papermc .paper .dialog .Dialog ;
14+ import io .papermc .paper .dialog .DialogResponseView ;
1415import io .papermc .paper .registry .data .dialog .ActionButton ;
1516import io .papermc .paper .registry .data .dialog .DialogBase ;
1617import io .papermc .paper .registry .data .dialog .action .DialogAction ;
1718import io .papermc .paper .registry .data .dialog .action .DialogActionCallback ;
1819import io .papermc .paper .registry .data .dialog .body .DialogBody ;
1920import io .papermc .paper .registry .data .dialog .input .DialogInput ;
20- import io .papermc .paper .registry .data .dialog .input .SingleOptionDialogInput ;
2121import io .papermc .paper .registry .data .dialog .type .DialogType ;
2222import net .kyori .adventure .audience .Audience ;
2323import net .kyori .adventure .text .Component ;
2424import net .kyori .adventure .text .TextComponent ;
2525import net .kyori .adventure .text .event .ClickCallback ;
26+ import net .kyori .adventure .text .format .NamedTextColor ;
2627import net .kyori .adventure .text .minimessage .tag .resolver .Placeholder ;
2728import org .bukkit .entity .Player ;
2829import org .jetbrains .annotations .NotNull ;
2930import org .jetbrains .annotations .Nullable ;
3031
3132import java .util .ArrayList ;
3233import java .util .Collection ;
34+ import java .util .LinkedHashMap ;
3335import java .util .List ;
36+ import java .util .Map ;
37+ import java .util .UUID ;
3438import java .util .concurrent .CompletableFuture ;
39+ import java .util .concurrent .ConcurrentHashMap ;
3540import 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 */
4155public 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 */
0 commit comments