RapidGameFramework
Reusable Godot managers for data-driven small games
Loading...
Searching...
No Matches
inventory_list.gd
Go to the documentation of this file.
1extends RefCounted
2
3
7
8const DEFAULT_COLORS := {
9 "surface": Color("#202a34"),
10 "surface_selected": Color("#18212a"),
11 "border": Color("#33404d"),
12 "text": Color("#eef3f8"),
13 "muted": Color("#aab6c4"),
14 "accent": Color("#f2c86b"),
15 "boost": Color("#55d878")
16}
17const DENSITY_PRESETS := {
18 "compact": {
19 "row_height": 58.0,
20 "header_height": 38.0,
21 "filter_height": 36.0,
22 "section_gap": 5,
23 "row_gap": 3
24 },
25 "comfortable": {
26 "row_height": 76.0,
27 "header_height": 46.0,
28 "filter_height": 40.0,
29 "section_gap": 6,
30 "row_gap": 4
31 },
32 "large": {
33 "row_height": 92.0,
34 "header_height": 52.0,
35 "filter_height": 46.0,
36 "section_gap": 8,
37 "row_gap": 5
38 }
39}
40
41var root:VBoxContainer
42var filter_row:HBoxContainer
43var header_row:HBoxContainer
44var scroll_frame:PanelContainer
45var scroll:ScrollContainer
46var rows:VBoxContainer
47var content_margin:MarginContainer
48var columns := []
49var filters := []
50var options := {}
51var colors := DEFAULT_COLORS.duplicate(true)
52var total_rows_created:int = 0
53var total_rows_reused:int = 0
54var pass_rows_created:int = 0
55var pass_rows_reused:int = 0
56var last_hidden:int = 0
57
58
59func create(parent:Node, insert_index:int, next_options:Dictionary = {}) -> VBoxContainer:
60 options = _options_with_density(next_options)
61 columns = options.get("columns", [])
62 filters = options.get("filters", [])
63 colors = DEFAULT_COLORS.duplicate(true)
64 for key in options.get("colors", {}).keys():
65 colors[key] = options["colors"][key]
66 root = VBoxContainer.new()
67 root.name = str(options.get("name", "InventoryList"))
68 root.size_flags_horizontal = Control.SIZE_EXPAND_FILL
69 root.add_theme_constant_override("separation", int(options.get("section_gap", 6)))
70 parent.add_child(root)
71 parent.move_child(root, insert_index)
72 _build_filters()
73 _build_header()
74 _build_scroll()
75 return root
76
77
78func render(records:Array, state:Dictionary = {}) -> void:
79 if root == null:
80 return
81 pass_rows_created = 0
82 pass_rows_reused = 0
83 last_hidden = 0
84 _style_filters(str(state.get("filter", "all")))
85 _style_header(str(state.get("sort_key", "")), bool(state.get("sort_ascending", true)))
86 var existing_rows:Array = rows.get_children()
87 for index in range(records.size()):
88 var row:Button = null
89 if index < existing_rows.size() and existing_rows[index] is Button:
90 row = existing_rows[index]
91 pass_rows_reused += 1
92 total_rows_reused += 1
93 else:
94 row = _create_row_shell()
95 rows.add_child(row)
96 pass_rows_created += 1
97 total_rows_created += 1
98 _configure_row(row, records[index])
99 row.visible = true
100 for index in range(records.size(), existing_rows.size()):
101 var row = existing_rows[index]
102 if row is CanvasItem:
103 row.visible = false
104 last_hidden += 1
105
106
107func set_minimum_height(height:float) -> void:
108 if scroll != null:
109 scroll.custom_minimum_size.y = height
110
111
112func set_available_height(height:float) -> void:
113 if root == null:
114 return
115 var target_height:float = maxf(0.0, height)
116 root.custom_minimum_size.y = target_height
117 var chrome_height:float = 0.0
118 var visible_sections:int = 0
119 for section in [filter_row, header_row]:
120 if section is Control and section.visible:
121 chrome_height += _section_minimum_height(section)
122 visible_sections += 1
123 if scroll_frame != null and scroll_frame.visible:
124 visible_sections += 1
125 var gap:float = float(root.get_theme_constant("separation")) * float(max(0, visible_sections - 1))
126 var scroll_height:float = maxf(0.0, target_height - chrome_height - gap)
127 if scroll_frame != null:
128 scroll_frame.custom_minimum_size.y = scroll_height
129 if scroll != null:
130 scroll.custom_minimum_size.y = scroll_height
131
132
133func _section_minimum_height(section:Control) -> float:
134 if section == null:
135 return 0.0
136 var height:float = section.custom_minimum_size.y
137 for child in section.get_children():
138 if child is Control and child.visible:
139 height = maxf(height, child.custom_minimum_size.y)
140 return height
141
142
143func set_density(density_id:String) -> void:
144 var density_options:Dictionary = _density_options(density_id)
145 if density_options.is_empty():
146 return
147 options["density"] = density_id
148 for key in density_options.keys():
149 options[key] = density_options[key]
150 if filter_row != null:
151 filter_row.add_theme_constant_override("separation", int(options.get("filter_gap", 6)))
152 for button in filter_row.get_children():
153 if button is Button:
154 button.custom_minimum_size.y = float(options.get("filter_height", 40.0))
155 if header_row != null:
156 for child in header_row.get_children():
157 if child is Control:
158 child.custom_minimum_size.y = float(options.get("header_height", 46.0))
159 if rows != null:
160 rows.add_theme_constant_override("separation", int(options.get("row_gap", 4)))
161 for row in rows.get_children():
162 if row is Button:
163 row.custom_minimum_size.y = float(options.get("row_height", 76.0))
164
165
166func set_font_sizes(row_size:int, header_size:int = -1, filter_size:int = -1) -> void:
167 options["font_size"] = row_size
168 options["header_font_size"] = header_size if header_size > 0 else row_size
169 options["filter_font_size"] = filter_size if filter_size > 0 else row_size
170 if rows == null:
171 return
172 _style_filters(str(options.get("active_filter", "all")))
173 _style_header(str(options.get("active_sort_key", "")), bool(options.get("active_sort_ascending", true)))
174 for row in rows.get_children():
175 _apply_font_size_recursive(row, row_size)
176
177
178func set_enabled(enabled:bool) -> void:
179 if root == null:
180 return
181 root.mouse_filter = Control.MOUSE_FILTER_STOP if enabled else Control.MOUSE_FILTER_IGNORE
182 for row in rows.get_children():
183 if row is Button:
184 row.disabled = not enabled
185
186
187func set_visible(visible:bool) -> void:
188 if root != null:
189 root.visible = visible
190
191
192func get_stats() -> Dictionary:
193 var row_count:int = 0
194 var visible_count:int = 0
195 if rows != null:
196 row_count = rows.get_child_count()
197 for row in rows.get_children():
198 if row is CanvasItem and row.visible:
199 visible_count += 1
200 return {
201 "rows": row_count,
202 "visible_rows": visible_count,
203 "hidden_last_pass": last_hidden,
204 "pass_rows_created": pass_rows_created,
205 "pass_rows_reused": pass_rows_reused,
206 "total_rows_created": total_rows_created,
207 "total_rows_reused": total_rows_reused
208 }
209
210
211func get_cache_stats() -> Dictionary:
212 return get_stats()
213
214
215func _build_filters() -> void:
216 if filters.is_empty():
217 return
218 filter_row = HBoxContainer.new()
219 filter_row.name = "Filters"
220 filter_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
221 filter_row.add_theme_constant_override("separation", int(options.get("filter_gap", 6)))
222 root.add_child(filter_row)
223 for filter in filters:
224 var button = Button.new()
225 button.name = "%sFilter" % str(filter.get("id", "all")).capitalize()
226 button.text = str(filter.get("label", "Filter"))
227 button.custom_minimum_size = Vector2(0, float(options.get("filter_height", 40.0)))
228 button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
229 button.focus_mode = Control.FOCUS_NONE
230 var filter_id = str(filter.get("id", "all"))
231 var callback = options.get("filter_callback", Callable())
232 if callback.is_valid():
233 button.pressed.connect(func(): callback.call(filter_id))
234 filter_row.add_child(button)
235
236
237func _options_with_density(next_options:Dictionary) -> Dictionary:
238 var merged:Dictionary = {}
239 var density_options:Dictionary = _density_options(str(next_options.get("density", "comfortable")))
240 for key in density_options.keys():
241 merged[key] = density_options[key]
242 for key in next_options.keys():
243 merged[key] = next_options[key]
244 return merged
245
246
247func _density_options(density_id:String) -> Dictionary:
248 if not DENSITY_PRESETS.has(density_id):
249 return {}
250 return DENSITY_PRESETS[density_id].duplicate(true)
251
252
253func _build_header() -> void:
254 header_row = HBoxContainer.new()
255 header_row.name = "Header"
256 header_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
257 header_row.add_theme_constant_override("separation", 0)
258 root.add_child(header_row)
259 for column in columns:
260 var button = Button.new()
261 button.name = "%sHeader" % str(column.get("key", "column")).capitalize()
262 button.text = str(column.get("label", "Column"))
263 button.custom_minimum_size = Vector2(0, float(options.get("header_height", 46.0)))
264 button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
265 button.size_flags_stretch_ratio = float(column.get("ratio", 1.0))
266 button.focus_mode = Control.FOCUS_NONE
267 var sort_key = str(column.get("sort_key", column.get("key", "")))
268 var sortable = bool(column.get("sortable", true)) and sort_key != ""
269 var callback = options.get("sort_callback", Callable())
270 if sortable and callback.is_valid():
271 button.pressed.connect(func(): callback.call(sort_key))
272 else:
273 button.disabled = true
274 header_row.add_child(button)
275 var spacer = Control.new()
276 spacer.name = "ScrollbarPad"
277 spacer.custom_minimum_size = Vector2(float(options.get("scrollbar_padding", 38)), float(options.get("header_height", 46.0)))
278 header_row.add_child(spacer)
279
280
281func _build_scroll() -> void:
282 scroll_frame = PanelContainer.new()
283 scroll_frame.name = "ScrollFrame"
284 scroll_frame.size_flags_horizontal = Control.SIZE_EXPAND_FILL
285 scroll_frame.size_flags_vertical = Control.SIZE_EXPAND_FILL
286 scroll_frame.add_theme_stylebox_override("panel", _make_box(colors.get("surface"), colors.get("border"), int(options.get("frame_radius", 0))))
287 root.add_child(scroll_frame)
288
289 scroll = ScrollContainer.new()
290 scroll.name = "Scroll"
291 scroll.custom_minimum_size = Vector2(0, float(options.get("height", 320.0)))
292 scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
293 scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
294 scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
295 scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_SHOW_ALWAYS
296 scroll.follow_focus = true
297 scroll.add_theme_constant_override("scrollbar_minimum_size", int(options.get("scrollbar_width", 34)))
298 scroll_frame.add_child(scroll)
299
300 content_margin = MarginContainer.new()
301 content_margin.name = "ContentMargin"
302 content_margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
303 content_margin.add_theme_constant_override("margin_left", int(options.get("scrollbar_content_padding", 4)))
304 content_margin.add_theme_constant_override("margin_top", int(options.get("scrollbar_content_padding", 4)))
305 content_margin.add_theme_constant_override("margin_bottom", int(options.get("scrollbar_content_padding", 4)))
306 content_margin.add_theme_constant_override("margin_right", int(options.get("scrollbar_padding", 38)))
307 scroll.add_child(content_margin)
308
309 rows = VBoxContainer.new()
310 rows.name = "Rows"
311 rows.size_flags_horizontal = Control.SIZE_EXPAND_FILL
312 rows.add_theme_constant_override("separation", int(options.get("row_gap", 4)))
313 content_margin.add_child(rows)
314 var bar = scroll.get_v_scroll_bar()
315 if bar != null:
316 bar.custom_minimum_size.x = int(options.get("scrollbar_width", 34))
317
318
319func _style_filters(active_filter:String) -> void:
320 options["active_filter"] = active_filter
321 if filter_row == null:
322 return
323 for button in filter_row.get_children():
324 if not button is Button:
325 continue
326 var active = str(button.name).to_lower().begins_with(active_filter)
327 _apply_button_style(button, active, true)
328
329
330func _style_header(sort_key:String, sort_ascending:bool) -> void:
331 options["active_sort_key"] = sort_key
332 options["active_sort_ascending"] = sort_ascending
333 if header_row == null:
334 return
335 for index in range(columns.size()):
336 var button = header_row.get_child(index)
337 if not button is Button:
338 continue
339 var column = columns[index]
340 var key = str(column.get("sort_key", column.get("key", "")))
341 var active = key == sort_key
342 var label = str(column.get("label", "Column"))
343 if active:
344 label += " v" if sort_ascending else " ^"
345 button.text = label
346 _apply_button_style(button, active, false)
347
348
349func _create_row_shell() -> Button:
350 var row = Button.new()
351 row.text = ""
352 row.clip_text = true
353 row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
354 row.focus_mode = Control.FOCUS_ALL
355 row.pressed.connect(func(): _emit_row_selected(row))
356 row.focus_entered.connect(func(): _emit_row_selected(row))
357 return row
358
359
360func _configure_row(row:Button, record:Dictionary) -> void:
361 row.name = "Row_%s" % str(record.get("id", "item"))
362 row.set_meta("record_id", str(record.get("id", "")))
363 row.tooltip_text = str(record.get("tooltip", ""))
364 row.custom_minimum_size = Vector2(0, float(options.get("row_height", 76.0)))
365 row.disabled = bool(record.get("disabled", false))
366 var selected = bool(record.get("selected", false))
367 var border = record.get("border_color", colors.get("border"))
368 if selected:
369 border = colors.get("accent")
370 row.add_theme_stylebox_override("normal", _make_box(colors.get("surface_selected") if selected else colors.get("surface"), border, 0))
371 row.add_theme_stylebox_override("hover", _make_box(colors.get("surface_selected"), colors.get("accent"), 0))
372 row.add_theme_stylebox_override("pressed", _make_box(colors.get("surface_selected"), colors.get("accent"), 0))
373 row.add_theme_stylebox_override("disabled", _make_box(colors.get("surface"), border.darkened(0.25), 0))
374 for child in row.get_children():
375 row.remove_child(child)
376 child.queue_free()
377
378 var margin = MarginContainer.new()
379 margin.set_anchors_preset(Control.PRESET_FULL_RECT)
380 margin.offset_left = 6
381 margin.offset_top = 5
382 margin.offset_right = -6
383 margin.offset_bottom = -5
384 margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
385 row.add_child(margin)
386
387 var cells = HBoxContainer.new()
388 cells.size_flags_horizontal = Control.SIZE_EXPAND_FILL
389 cells.add_theme_constant_override("separation", 0)
390 cells.mouse_filter = Control.MOUSE_FILTER_IGNORE
391 margin.add_child(cells)
392 for index in range(columns.size()):
393 cells.add_child(_build_cell(record, columns[index], index))
394 _apply_font_size_recursive(row, int(options.get("font_size", 16)))
395
396
397func _emit_row_selected(row:Button) -> void:
398 var callback = options.get("select_callback", Callable())
399 if callback.is_valid():
400 callback.call(str(row.get_meta("record_id", "")))
401
402
403func _build_cell(record:Dictionary, column:Dictionary, index:int) -> Control:
404 var key = str(column.get("key", ""))
405 var values = record.get("values", {})
406 var cell = HBoxContainer.new()
407 cell.size_flags_horizontal = Control.SIZE_EXPAND_FILL
408 cell.size_flags_vertical = Control.SIZE_EXPAND_FILL
409 cell.size_flags_stretch_ratio = float(column.get("ratio", 1.0))
410 cell.add_theme_constant_override("separation", 5)
411 cell.mouse_filter = Control.MOUSE_FILTER_IGNORE
412 if index == 0 and record.get("icon", null) != null:
413 var icon = TextureRect.new()
414 icon.texture = record.get("icon")
415 icon.custom_minimum_size = Vector2(34, 34)
416 icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
417 icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
418 icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
419 cell.add_child(icon)
420 var value = values.get(key, {})
421 var label = Label.new()
422 label.text = str(value.get("text", ""))
423 label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART if bool(column.get("wrap", false)) else TextServer.AUTOWRAP_OFF
424 label.max_lines_visible = int(column.get("lines", 1))
425 label.clip_text = true
426 label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
427 label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
428 label.horizontal_alignment = int(column.get("align", value.get("align", HORIZONTAL_ALIGNMENT_LEFT)))
429 label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
430 label.size_flags_vertical = Control.SIZE_EXPAND_FILL
431 label.add_theme_color_override("font_color", value.get("color", colors.get("text")))
432 label.mouse_filter = Control.MOUSE_FILTER_IGNORE
433 cell.add_child(label)
434 return cell
435
436
437func _apply_button_style(button:Button, active:bool, rounded:bool) -> void:
438 var border = colors.get("accent") if active else colors.get("border")
439 button.add_theme_stylebox_override("normal", _make_box(colors.get("surface"), border, 0 if not rounded else 4))
440 button.add_theme_stylebox_override("hover", _make_box(colors.get("surface_selected"), colors.get("accent"), 0 if not rounded else 4))
441 button.add_theme_stylebox_override("pressed", _make_box(colors.get("surface_selected"), colors.get("accent"), 0 if not rounded else 4))
442 button.add_theme_color_override("font_color", colors.get("text"))
443 var size_key = "filter_font_size" if rounded else "header_font_size"
444 var font_size = int(options.get(size_key, options.get("font_size", 16)))
445 button.add_theme_font_size_override("font_size", font_size)
446 button.add_theme_font_size_override("font_hover_size", font_size)
447 button.add_theme_font_size_override("font_pressed_size", font_size)
448
449
450func _make_box(bg:Color, border:Color, radius:int) -> StyleBoxFlat:
451 var style = StyleBoxFlat.new()
452 style.bg_color = bg
453 style.border_color = border
454 style.set_border_width_all(1)
455 style.set_corner_radius_all(radius)
456 style.content_margin_left = 6
457 style.content_margin_right = 6
458 style.content_margin_top = 6
459 style.content_margin_bottom = 6
460 return style
461
462
463func _apply_font_size_recursive(node:Node, font_size:int) -> void:
464 if node is Control:
465 node.add_theme_font_size_override("font_size", font_size)
466 if node is Button:
467 node.add_theme_font_size_override("font_hover_size", font_size)
468 node.add_theme_font_size_override("font_pressed_size", font_size)
469 for child in node.get_children():
470 _apply_font_size_recursive(child, font_size)