diff --git a/distribution/openrct2.d.ts b/distribution/openrct2.d.ts index 6c04404802..3096912c53 100644 --- a/distribution/openrct2.d.ts +++ b/distribution/openrct2.d.ts @@ -1031,7 +1031,7 @@ declare global { * Represents the type of a widget, e.g. button or label. */ type WidgetType = - "button" | "checkbox" | "dropdown" | "groupbox" | "label" | "spinner" | "viewport"; + "button" | "checkbox" | "dropdown" | "groupbox" | "label" | "listview" | "spinner" | "viewport"; interface Widget { type: WidgetType; @@ -1072,6 +1072,39 @@ declare global { onChange: (index: number) => void; } + type SortOrder = "none" | "ascending" | "descending"; + + type ScrollType = "none" | "horizontal" | "vertical" | "both"; + + interface ListViewColumn { + canSort?: boolean; + sortOrder?: SortOrder; + header?: string; + headerTooltip?: string; + width?: number; + ratioWidth?: number; + minWidth?: number; + maxWidth?: number; + } + + type ListViewItem = string[]; + + interface ListView extends Widget { + scroll?: ScrollType; + isStriped?: boolean; + showColumnHeaders?: boolean; + columns?: ListViewColumn[]; + items?: string[] | ListViewItem[]; + selectedIndex?: number; + highlightedIndex?: number; + + onHighlight: (index: number) => void; + onSelect: (index: number) => void; + + getCell(row: number, column: number): string; + setCell(row: number, column: number, value: string): void; + } + interface SpinnerWidget extends Widget { text: string; onDecrement: () => void; diff --git a/src/openrct2-ui/scripting/CustomWindow.cpp b/src/openrct2-ui/scripting/CustomWindow.cpp index 9681fdbadc..56f0692f4e 100644 --- a/src/openrct2-ui/scripting/CustomWindow.cpp +++ b/src/openrct2-ui/scripting/CustomWindow.cpp @@ -30,6 +30,125 @@ using namespace OpenRCT2; using namespace OpenRCT2::Scripting; +namespace OpenRCT2::Ui::Windows +{ + enum class ScrollbarType + { + None, + Horizontal, + Vertical, + Both + }; + + enum class ColumnSortOrder + { + None, + Ascending, + Descending, + }; + + struct ListViewColumn + { + bool CanSort{}; + ColumnSortOrder SortOrder; + std::string Header; + std::string HeaderTooltip; + std::optional RatioWidth{}; + std::optional MinWidth{}; + std::optional MaxWidth{}; + int32_t Width{}; + }; + + struct ListViewItem + { + std::vector Cells; + + ListViewItem() = default; + explicit ListViewItem(const std::string_view& text) + { + Cells.emplace_back(text); + } + explicit ListViewItem(std::vector&& cells) + : Cells(cells) + { + } + }; +} // namespace OpenRCT2::Ui::Windows + +namespace OpenRCT2::Scripting +{ + static std::string ProcessString(const DukValue& value) + { + if (value.type() == DukValue::Type::STRING) + return language_convert_string(value.as_string()); + return {}; + } + + template<> ColumnSortOrder FromDuk(const DukValue& d) + { + if (d.type() == DukValue::Type::STRING) + { + auto s = d.as_string(); + if (s == "ascending") + return ColumnSortOrder::Ascending; + if (s == "descending") + return ColumnSortOrder::Descending; + } + return ColumnSortOrder::None; + } + + template<> std::optional FromDuk(const DukValue& d) + { + if (d.type() == DukValue::Type::NUMBER) + { + return d.as_int(); + } + return std::nullopt; + } + + template<> ListViewColumn FromDuk(const DukValue& d) + { + ListViewColumn result; + result.CanSort = AsOrDefault(d["canSort"], false); + result.SortOrder = FromDuk(d["sortOrder"]); + result.Header = AsOrDefault(d["header"], ""); + result.HeaderTooltip = AsOrDefault(d["headerTooltip"], ""); + result.MinWidth = FromDuk>(d["minWidth"]); + result.MaxWidth = FromDuk>(d["maxWidth"]); + result.RatioWidth = FromDuk>(d["ratioWidth"]); + if (d["width"].type() == DukValue::Type::NUMBER) + { + result.MinWidth = d["width"].as_int(); + result.MaxWidth = result.MinWidth; + result.RatioWidth = std::nullopt; + } + else + { + result.RatioWidth = 1; + } + return result; + } + + template<> ListViewItem FromDuk(const DukValue& d) + { + ListViewItem result; + if (d.type() == DukValue::Type::STRING) + { + result = ListViewItem(ProcessString(d)); + } + else if (d.is_array()) + { + std::vector cells; + for (const auto& dukCell : d.as_array()) + { + cells.push_back(ProcessString(dukCell)); + } + result = ListViewItem(std::move(cells)); + } + return result; + } +} // namespace OpenRCT2::Scripting + namespace OpenRCT2::Ui::Windows { enum CUSTOM_WINDOW_WIDX @@ -54,8 +173,12 @@ namespace OpenRCT2::Ui::Windows static void window_custom_resize(rct_window* w); static void window_custom_dropdown(rct_window* w, rct_widgetindex widgetIndex, int32_t dropdownIndex); static void window_custom_update(rct_window* w); + static void window_custom_scrollgetsize(rct_window* w, int32_t scrollIndex, int32_t* width, int32_t* height); + static void window_custom_scrollmousedown(rct_window* w, int32_t scrollIndex, const ScreenCoordsXY& screenCoords); + static void window_custom_scrollmouseover(rct_window* w, int32_t scrollIndex, const ScreenCoordsXY& screenCoords); static void window_custom_invalidate(rct_window* w); static void window_custom_paint(rct_window* w, rct_drawpixelinfo* dpi); + static void window_custom_scrollpaint(rct_window* w, rct_drawpixelinfo* dpi, int32_t scrollIndex); static void window_custom_update_viewport(rct_window* w); static rct_window_event_list window_custom_events = { window_custom_close, @@ -73,10 +196,10 @@ namespace OpenRCT2::Ui::Windows nullptr, nullptr, nullptr, + window_custom_scrollgetsize, + window_custom_scrollmousedown, nullptr, - nullptr, - nullptr, - nullptr, + window_custom_scrollmouseover, nullptr, nullptr, nullptr, @@ -85,7 +208,7 @@ namespace OpenRCT2::Ui::Windows nullptr, window_custom_invalidate, window_custom_paint, - nullptr }; + window_custom_scrollpaint }; struct CustomWidgetDesc { @@ -100,10 +223,14 @@ namespace OpenRCT2::Ui::Windows std::string Text; std::string Tooltip; std::vector Items; + std::vector ListViewItems; + std::vector ListViewColumns; int32_t SelectedIndex{}; bool IsChecked{}; bool IsDisabled{}; bool HasBorder{}; + bool ShowColumnHeaders{}; + bool IsStriped{}; // Event handlers DukValue OnClick; @@ -111,13 +238,6 @@ namespace OpenRCT2::Ui::Windows DukValue OnIncrement; DukValue OnDecrement; - static std::string ProcessString(const DukValue& value) - { - if (value.type() == DukValue::Type::STRING) - return language_convert_string(value.as_string()); - return {}; - } - static CustomWidgetDesc FromDukValue(DukValue desc) { CustomWidgetDesc result; @@ -157,11 +277,6 @@ namespace OpenRCT2::Ui::Windows } else if (result.Type == "dropdown") { - auto dukItems = desc["items"].as_array(); - for (const auto& dukItem : dukItems) - { - result.Items.push_back(ProcessString(dukItem)); - } result.SelectedIndex = desc["selectedIndex"].as_int(); result.OnChange = desc["onChange"]; } @@ -169,6 +284,27 @@ namespace OpenRCT2::Ui::Windows { result.Text = ProcessString(desc["text"]); } + else if (result.Type == "listview") + { + if (desc["columns"].is_array()) + { + auto dukColumns = desc["columns"].as_array(); + for (const auto& dukColumn : dukColumns) + { + result.ListViewColumns.push_back(FromDuk(dukColumn)); + } + } + if (desc["items"].is_array()) + { + auto dukItems = desc["items"].as_array(); + for (const auto& dukItem : dukItems) + { + result.ListViewItems.push_back(FromDuk(dukItem)); + } + } + result.ShowColumnHeaders = AsOrDefault(desc["showColumnHeaders"], false); + result.IsStriped = AsOrDefault(desc["isStriped"], false); + } else if (result.Type == "spinner") { result.Text = ProcessString(desc["text"]); @@ -305,6 +441,331 @@ namespace OpenRCT2::Ui::Windows } }; + class CustomListViewInfo + { + private: + struct HitTestResult + { + static constexpr size_t HEADER_ROW = std::numeric_limits::max(); + + size_t Row{}; + size_t Column{}; + + bool IsHeader() const + { + return Row == HEADER_ROW; + } + }; + + public: + std::shared_ptr Owner; + std::vector Columns; + std::vector Items; + std::optional HighlightedRow; + std::optional HighlightedColumn; + std::optional SelectedRow; + std::optional SelectedColumn; + std::optional ColumnHeaderPressed; + bool ShowColumnHeaders{}; + bool IsStriped{}; + ScreenSize LastKnownSize; + ScrollbarType Scrollbars = ScrollbarType::Vertical; + + DukValue OnHighlight; + DukValue OnSelect; + + void Resize(const ScreenSize& size) + { + if (size == LastKnownSize) + return; + + LastKnownSize = size; + + // Calculate the total of all ratios + int32_t totalRatio = 0; + for (size_t c = 0; c < Columns.size(); c++) + { + auto& column = Columns[c]; + if (column.RatioWidth) + { + totalRatio += *column.RatioWidth; + } + } + + // Calculate column widths + int32_t widthRemaining = size.width; + for (size_t c = 0; c < Columns.size(); c++) + { + auto& column = Columns[c]; + if (c == Columns.size() - 1) + { + column.Width = widthRemaining; + } + else + { + if (column.RatioWidth) + { + column.Width = (size.width * *column.RatioWidth) / totalRatio; + } + if (column.MinWidth) + { + column.Width = std::max(column.Width, *column.MinWidth); + } + if (column.MaxWidth) + { + column.Width = std::min(column.Width, *column.MaxWidth); + } + } + widthRemaining -= column.Width; + } + } + + ScreenSize GetSize() const + { + ScreenSize result; + result.width = 0; + result.height = static_cast(Items.size() * LIST_ROW_HEIGHT); + return result; + } + + void MouseOver(const ScreenCoordsXY& pos) + { + auto hitResult = GetItemIndexAt(pos); + if (hitResult) + { + if (HighlightedRow != hitResult->Row || HighlightedColumn != hitResult->Column) + { + HighlightedRow = hitResult->Row; + HighlightedColumn = hitResult->Column; + if (!hitResult->IsHeader() && OnHighlight.context() != nullptr && OnHighlight.is_function()) + { + auto ctx = OnHighlight.context(); + duk_push_int(ctx, static_cast(*HighlightedRow)); + auto dukRow = DukValue::take_from_stack(ctx, -1); + duk_push_int(ctx, static_cast(*HighlightedColumn)); + auto dukColumn = DukValue::take_from_stack(ctx, -1); + auto& scriptEngine = GetContext()->GetScriptEngine(); + scriptEngine.ExecutePluginCall(Owner, OnHighlight, { dukRow, dukColumn }, false); + } + } + } + ColumnHeaderPressed = std::nullopt; + } + + void MouseDown(const ScreenCoordsXY& pos) + { + auto hitResult = GetItemIndexAt(pos); + if (hitResult) + { + if (SelectedRow != hitResult->Row || SelectedColumn != hitResult->Column) + { + SelectedRow = hitResult->Row; + SelectedColumn = hitResult->Column; + if (!hitResult->IsHeader() && OnSelect.context() != nullptr && OnSelect.is_function()) + { + auto ctx = OnSelect.context(); + duk_push_int(ctx, static_cast(*SelectedRow)); + auto dukRow = DukValue::take_from_stack(ctx, -1); + duk_push_int(ctx, static_cast(*SelectedColumn)); + auto dukColumn = DukValue::take_from_stack(ctx, -1); + auto& scriptEngine = GetContext()->GetScriptEngine(); + scriptEngine.ExecutePluginCall(Owner, OnSelect, { dukRow, dukColumn }, false); + } + } + } + if (hitResult && hitResult->IsHeader()) + { + ColumnHeaderPressed = hitResult->Column; + } + else + { + ColumnHeaderPressed = std::nullopt; + } + } + + void Paint(rct_window* w, rct_drawpixelinfo* dpi, const rct_scroll* scroll) const + { + auto paletteIndex = ColourMapA[w->colours[1]].mid_light; + gfx_fill_rect(dpi, dpi->x, dpi->y, dpi->x + dpi->width, dpi->y + dpi->height, paletteIndex); + + int32_t y = ShowColumnHeaders ? LIST_ROW_HEIGHT + 1 : 0; + for (size_t i = 0; i < Items.size(); i++) + { + if (y > dpi->y + dpi->height) + { + // Past the scroll view area + break; + } + + if (y + LIST_ROW_HEIGHT >= dpi->y) + { + // Background colour + auto isStriped = IsStriped && (i & 1); + auto isHighlighted = i == HighlightedRow; + if (isHighlighted) + { + gfx_filter_rect(dpi, dpi->x, y, dpi->x + dpi->width, y + (LIST_ROW_HEIGHT - 1), PALETTE_DARKEN_1); + } + else if (isStriped) + { + gfx_fill_rect( + dpi, dpi->x, y, dpi->x + dpi->width, y + (LIST_ROW_HEIGHT - 1), + ColourMapA[w->colours[1]].lighter | 0x1000000); + } + + // Columns + const auto& item = Items[i]; + if (Columns.size() == 0) + { + const auto& text = item.Cells[0]; + if (!text.empty()) + { + ScreenSize cellSize = { std::numeric_limits::max(), LIST_ROW_HEIGHT }; + PaintCell(dpi, { 0, y }, cellSize, text.c_str(), isHighlighted); + } + } + else + { + int32_t x = 0; + for (size_t j = 0; j < Columns.size(); j++) + { + const auto& column = Columns[j]; + if (item.Cells.size() > j) + { + const auto& text = item.Cells[j]; + if (!text.empty()) + { + ScreenSize cellSize = { column.Width, LIST_ROW_HEIGHT }; + PaintCell(dpi, { x, y }, cellSize, text.c_str(), isHighlighted); + } + } + x += column.Width; + } + } + } + + y += LIST_ROW_HEIGHT; + } + + if (ShowColumnHeaders) + { + y = scroll->v_top; + + auto bgColour = ColourMapA[w->colours[1]].mid_light; + gfx_fill_rect(dpi, dpi->x, y, dpi->x + dpi->width, y + 12, bgColour); + + int32_t x = 0; + for (size_t j = 0; j < Columns.size(); j++) + { + const auto& column = Columns[j]; + auto columnWidth = column.Width; + if (columnWidth != 0) + { + bool isPressed = ColumnHeaderPressed == j; + PaintHeading( + w, dpi, { x, y }, { column.Width, LIST_ROW_HEIGHT }, column.Header, ColumnSortOrder::None, + isPressed); + x += columnWidth; + } + } + } + } + + private: + void PaintHeading( + rct_window* w, rct_drawpixelinfo* dpi, const ScreenCoordsXY& pos, const ScreenSize& size, const std::string& text, + ColumnSortOrder sortOrder, bool isPressed) const + { + auto boxFlags = 0; + if (isPressed) + { + boxFlags = INSET_RECT_FLAG_BORDER_INSET; + } + gfx_fill_rect_inset(dpi, pos.x, pos.y, pos.x + size.width - 1, pos.y + size.height - 1, w->colours[1], boxFlags); + if (!text.empty()) + { + PaintCell(dpi, pos, size, text.c_str(), false); + } + + if (sortOrder == ColumnSortOrder::Ascending) + { + auto ft = Formatter::Common(); + ft.Add(STR_UP); + gfx_draw_string_right(dpi, STR_BLACK_STRING, gCommonFormatArgs, COLOUR_BLACK, pos.x + size.width - 1, pos.y); + } + else if (sortOrder == ColumnSortOrder::Descending) + { + auto ft = Formatter::Common(); + ft.Add(STR_DOWN); + gfx_draw_string_right(dpi, STR_BLACK_STRING, gCommonFormatArgs, COLOUR_BLACK, pos.y + size.width - 1, pos.y); + } + } + + void PaintCell( + rct_drawpixelinfo* dpi, const ScreenCoordsXY& pos, const ScreenSize& size, const char* text, + bool isHighlighted) const + { + rct_string_id stringId = isHighlighted ? STR_WINDOW_COLOUR_2_STRINGID : STR_BLACK_STRING; + + auto ft = Formatter::Common(); + ft.Add(STR_STRING); + ft.Add(text); + gfx_draw_string_left_clipped(dpi, stringId, gCommonFormatArgs, COLOUR_BLACK, pos.x, pos.y, size.width); + } + + std::optional GetItemIndexAt(const ScreenCoordsXY& pos) + { + std::optional result; + if (pos.x >= 0) + { + // Check if we pressed the header + if (ShowColumnHeaders && pos.y >= 0 && pos.y < LIST_ROW_HEIGHT) + { + result = HitTestResult(); + result->Row = HitTestResult::HEADER_ROW; + } + else + { + // Check what row we pressed + int32_t firstY = ShowColumnHeaders ? LIST_ROW_HEIGHT + 1 : 0; + int32_t row = (pos.y - firstY) / LIST_ROW_HEIGHT; + if (row >= 0 && row < static_cast(Items.size())) + { + result = HitTestResult(); + result->Row = static_cast(row); + } + } + + // Check what column we pressed if there are any + if (result && Columns.size() > 0) + { + bool found = false; + int32_t x = 0; + for (size_t c = 0; c < Columns.size(); c++) + { + const auto& column = Columns[c]; + x += column.Width; + if (column.Width != 0) + { + if (pos.x < x) + { + result->Column = c; + found = true; + break; + } + } + } + if (!found) + { + // Past all columns + return std::nullopt; + } + } + } + return result; + } + }; + class CustomWindowInfo { public: @@ -312,6 +773,7 @@ namespace OpenRCT2::Ui::Windows CustomWindowDesc Desc; std::vector Widgets; std::vector WidgetIndexMap; + std::vector ListViews; CustomWindowInfo(std::shared_ptr owner, const CustomWindowDesc& desc) : Owner(owner) @@ -576,6 +1038,35 @@ namespace OpenRCT2::Ui::Windows } } + static void window_custom_scrollgetsize(rct_window* w, int32_t scrollIndex, int32_t* width, int32_t* height) + { + const auto& info = GetInfo(w); + if (scrollIndex < info.ListViews.size()) + { + auto size = info.ListViews[scrollIndex].GetSize(); + *width = size.width; + *height = size.height; + } + } + + static void window_custom_scrollmousedown(rct_window* w, int32_t scrollIndex, const ScreenCoordsXY& screenCoords) + { + auto& info = GetInfo(w); + if (scrollIndex < info.ListViews.size()) + { + info.ListViews[scrollIndex].MouseDown(screenCoords); + } + } + + static void window_custom_scrollmouseover(rct_window* w, int32_t scrollIndex, const ScreenCoordsXY& screenCoords) + { + auto& info = GetInfo(w); + if (scrollIndex < info.ListViews.size()) + { + info.ListViews[scrollIndex].MouseOver(screenCoords); + } + } + static void window_custom_set_pressed_tab(rct_window* w) { const auto& info = GetInfo(w); @@ -604,6 +1095,28 @@ namespace OpenRCT2::Ui::Windows const auto& desc = GetInfo(w).Desc; set_format_arg(0, void*, desc.Title.c_str()); + + auto& info = GetInfo(w); + size_t scrollIndex = 0; + for (auto widget = w->widgets; widget->type != WWT_LAST; widget++) + { + if (widget->type == WWT_SCROLL) + { + auto& listView = info.ListViews[scrollIndex]; + auto width = widget->right - widget->left + 1 - 2; + auto height = widget->bottom - widget->top + 1 - 2; + if (listView.Scrollbars == ScrollbarType::Horizontal || listView.Scrollbars == ScrollbarType::Both) + { + height -= SCROLLBAR_WIDTH + 1; + } + if (listView.Scrollbars == ScrollbarType::Vertical || listView.Scrollbars == ScrollbarType::Both) + { + width -= SCROLLBAR_WIDTH + 1; + } + listView.Resize({ width, height }); + scrollIndex++; + } + } } static void window_custom_draw_tab_images(rct_window* w, rct_drawpixelinfo* dpi) @@ -642,6 +1155,15 @@ namespace OpenRCT2::Ui::Windows } } + static void window_custom_scrollpaint(rct_window* w, rct_drawpixelinfo* dpi, int32_t scrollIndex) + { + const auto& info = GetInfo(w); + if (scrollIndex < info.ListViews.size()) + { + info.ListViews[scrollIndex].Paint(w, dpi, &w->scrolls[scrollIndex]); + } + } + static std::optional GetViewportWidgetIndex(rct_window* w) { rct_widgetindex widgetIndex = 0; @@ -782,6 +1304,12 @@ namespace OpenRCT2::Ui::Windows widget.flags |= WIDGET_FLAGS::TEXT_IS_STRING; widgetList.push_back(widget); } + else if (desc.Type == "listview") + { + widget.type = WWT_SCROLL; + widget.text = SCROLL_VERTICAL; + widgetList.push_back(widget); + } else if (desc.Type == "spinner") { widget.type = WWT_SPINNER; @@ -827,6 +1355,7 @@ namespace OpenRCT2::Ui::Windows widgets.clear(); info.WidgetIndexMap.clear(); + info.ListViews.clear(); // Add default widgets (window shim) widgets.insert(widgets.begin(), std::begin(CustomDefaultWidgets), std::end(CustomDefaultWidgets)); @@ -877,6 +1406,16 @@ namespace OpenRCT2::Ui::Windows { info.WidgetIndexMap.push_back(widgetDescIndex); } + + if (widgetDesc.Type == "listview") + { + CustomListViewInfo listView; + listView.Columns = widgetDesc.ListViewColumns; + listView.Items = widgetDesc.ListViewItems; + listView.ShowColumnHeaders = widgetDesc.ShowColumnHeaders; + listView.IsStriped = widgetDesc.IsStriped; + info.ListViews.push_back(std::move(listView)); + } } for (size_t i = firstCustomWidgetIndex; i < widgets.size(); i++) diff --git a/src/openrct2/world/Location.hpp b/src/openrct2/world/Location.hpp index 22aa3243e9..d2222e66d6 100644 --- a/src/openrct2/world/Location.hpp +++ b/src/openrct2/world/Location.hpp @@ -85,6 +85,29 @@ struct ScreenCoordsXY } }; +struct ScreenSize +{ + int32_t width{}; + int32_t height{}; + + ScreenSize() = default; + constexpr ScreenSize(int32_t _width, int32_t _height) + : width(_width) + , height(_height) + { + } + + bool operator==(const ScreenSize& other) const + { + return width == other.width && height == other.height; + } + + bool operator!=(const ScreenSize& other) const + { + return !(*this == other); + } +}; + /** * Tile coordinates use 1 x/y increment per tile and 1 z increment per step. * Regular ('big', 'sprite') coordinates use 32 x/y increments per tile and 8 z increments per step.