/*
* This file is part of OpenTTD.
* OpenTTD is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 2.
* OpenTTD is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with OpenTTD. If not, see .
*/
/** @file story_gui.cpp GUI for stories. */
#include "stdafx.h"
#include "window_gui.h"
#include "strings_func.h"
#include "date_func.h"
#include "gui.h"
#include "story_base.h"
#include "core/geometry_func.hpp"
#include "company_func.h"
#include "command_func.h"
#include "widgets/dropdown_type.h"
#include "widgets/dropdown_func.h"
#include "sortlist_type.h"
#include "goal_base.h"
#include "viewport_func.h"
#include "window_func.h"
#include "company_base.h"
#include "widgets/story_widget.h"
#include "table/strings.h"
#include "table/sprites.h"
#include "safeguards.h"
typedef GUIList GUIStoryPageList;
typedef GUIList GUIStoryPageElementList;
struct StoryBookWindow : Window {
protected:
Scrollbar *vscroll; ///< Scrollbar of the page text.
GUIStoryPageList story_pages; ///< Sorted list of pages.
GUIStoryPageElementList story_page_elements; ///< Sorted list of page elements that belong to the current page.
StoryPageID selected_page_id; ///< Pool index of selected page.
char selected_generic_title[255]; ///< If the selected page doesn't have a custom title, this buffer is used to store a generic page title.
static GUIStoryPageList::SortFunction * const page_sorter_funcs[];
static GUIStoryPageElementList::SortFunction * const page_element_sorter_funcs[];
/** (Re)Build story page list. */
void BuildStoryPageList()
{
if (this->story_pages.NeedRebuild()) {
this->story_pages.clear();
for (const StoryPage *p : StoryPage::Iterate()) {
if (this->IsPageAvailable(p)) {
this->story_pages.push_back(p);
}
}
this->story_pages.shrink_to_fit();
this->story_pages.RebuildDone();
}
this->story_pages.Sort();
}
/** Sort story pages by order value. */
static bool PageOrderSorter(const StoryPage * const &a, const StoryPage * const &b)
{
return a->sort_value < b->sort_value;
}
/** (Re)Build story page element list. */
void BuildStoryPageElementList()
{
if (this->story_page_elements.NeedRebuild()) {
this->story_page_elements.clear();
const StoryPage *p = GetSelPage();
if (p != nullptr) {
for (const StoryPageElement *pe : StoryPageElement::Iterate()) {
if (pe->page == p->index) {
this->story_page_elements.push_back(pe);
}
}
}
this->story_page_elements.shrink_to_fit();
this->story_page_elements.RebuildDone();
}
this->story_page_elements.Sort();
}
/** Sort story page elements by order value. */
static bool PageElementOrderSorter(const StoryPageElement * const &a, const StoryPageElement * const &b)
{
return a->sort_value < b->sort_value;
}
/*
* Checks if a given page should be visible in the story book.
* @param page The page to check.
* @return True if the page should be visible, otherwise false.
*/
bool IsPageAvailable(const StoryPage *page) const
{
return page->company == INVALID_COMPANY || page->company == this->window_number;
}
/**
* Get instance of selected page.
* @return Instance of selected page or nullptr if no page is selected.
*/
StoryPage *GetSelPage() const
{
if (!_story_page_pool.IsValidID(selected_page_id)) return nullptr;
return _story_page_pool.Get(selected_page_id);
}
/**
* Get the page number of selected page.
* @return Number of available pages before to the selected one, or -1 if no page is selected.
*/
int GetSelPageNum() const
{
int page_number = 0;
for (const StoryPage *p : this->story_pages) {
if (p->index == this->selected_page_id) {
return page_number;
}
page_number++;
}
return -1;
}
/**
* Check if the selected page is also the first available page.
*/
bool IsFirstPageSelected()
{
/* Verify that the selected page exist. */
if (!_story_page_pool.IsValidID(this->selected_page_id)) return false;
return this->story_pages.front()->index == this->selected_page_id;
}
/**
* Check if the selected page is also the last available page.
*/
bool IsLastPageSelected()
{
/* Verify that the selected page exist. */
if (!_story_page_pool.IsValidID(this->selected_page_id)) return false;
if (this->story_pages.size() <= 1) return true;
const StoryPage *last = this->story_pages.back();
return last->index == this->selected_page_id;
}
/**
* Updates the content of selected page.
*/
void RefreshSelectedPage()
{
/* Generate generic title if selected page have no custom title. */
StoryPage *page = this->GetSelPage();
if (page != nullptr && page->title == nullptr) {
SetDParam(0, GetSelPageNum() + 1);
GetString(selected_generic_title, STR_STORY_BOOK_GENERIC_PAGE_ITEM, lastof(selected_generic_title));
}
this->story_page_elements.ForceRebuild();
this->BuildStoryPageElementList();
this->vscroll->SetCount(this->GetContentHeight());
this->SetWidgetDirty(WID_SB_SCROLLBAR);
this->SetWidgetDirty(WID_SB_SEL_PAGE);
this->SetWidgetDirty(WID_SB_PAGE_PANEL);
}
/**
* Selects the previous available page before the currently selected page.
*/
void SelectPrevPage()
{
if (!_story_page_pool.IsValidID(this->selected_page_id)) return;
/* Find the last available page which is previous to the current selected page. */
const StoryPage *last_available;
last_available = nullptr;
for (const StoryPage *p : this->story_pages) {
if (p->index == this->selected_page_id) {
if (last_available == nullptr) return; // No previous page available.
this->SetSelectedPage(last_available->index);
return;
}
last_available = p;
}
}
/**
* Selects the next available page after the currently selected page.
*/
void SelectNextPage()
{
if (!_story_page_pool.IsValidID(this->selected_page_id)) return;
/* Find selected page. */
for (auto iter = this->story_pages.begin(); iter != this->story_pages.end(); iter++) {
const StoryPage *p = *iter;
if (p->index == this->selected_page_id) {
/* Select the page after selected page. */
iter++;
if (iter != this->story_pages.end()) {
this->SetSelectedPage((*iter)->index);
}
return;
}
}
}
/**
* Builds the page selector drop down list.
*/
DropDownList BuildDropDownList() const
{
DropDownList list;
uint16 page_num = 1;
for (const StoryPage *p : this->story_pages) {
bool current_page = p->index == this->selected_page_id;
DropDownListStringItem *item = nullptr;
if (p->title != nullptr) {
item = new DropDownListCharStringItem(p->title, p->index, current_page);
} else {
/* No custom title => use a generic page title with page number. */
DropDownListParamStringItem *str_item =
new DropDownListParamStringItem(STR_STORY_BOOK_GENERIC_PAGE_ITEM, p->index, current_page);
str_item->SetParam(0, page_num);
item = str_item;
}
list.emplace_back(item);
page_num++;
}
return list;
}
/**
* Get the width available for displaying content on the page panel.
*/
uint GetAvailablePageContentWidth()
{
return this->GetWidget(WID_SB_PAGE_PANEL)->current_x - WD_FRAMETEXT_LEFT - WD_FRAMERECT_RIGHT;
}
/**
* Counts how many pixels of height that are used by Date and Title
* (excluding marginal after Title, as each body element has
* an empty row before the element).
* @param max_width Available width to display content.
* @return the height in pixels.
*/
uint GetHeadHeight(int max_width) const
{
StoryPage *page = this->GetSelPage();
if (page == nullptr) return 0;
int height = 0;
/* Title lines */
height += FONT_HEIGHT_NORMAL; // Date always use exactly one line.
SetDParamStr(0, page->title != nullptr ? page->title : this->selected_generic_title);
height += GetStringHeight(STR_STORY_BOOK_TITLE, max_width);
return height;
}
/**
* Decides which sprite to display for a given page element.
* @param pe The page element.
* @return The SpriteID of the sprite to display.
* @pre pe.type must be SPET_GOAL or SPET_LOCATION.
*/
SpriteID GetPageElementSprite(const StoryPageElement &pe) const
{
switch (pe.type) {
case SPET_GOAL: {
Goal *g = Goal::Get((GoalID) pe.referenced_id);
if (g == nullptr) return SPR_IMG_GOAL_BROKEN_REF;
return g->completed ? SPR_IMG_GOAL_COMPLETED : SPR_IMG_GOAL;
}
case SPET_LOCATION:
return SPR_IMG_VIEW_LOCATION;
default:
NOT_REACHED();
}
}
/**
* Get the height in pixels used by a page element.
* @param pe The story page element.
* @param max_width Available width to display content.
* @return the height in pixels.
*/
uint GetPageElementHeight(const StoryPageElement &pe, int max_width)
{
switch (pe.type) {
case SPET_TEXT:
SetDParamStr(0, pe.text);
return GetStringHeight(STR_BLACK_RAW_STRING, max_width);
break;
case SPET_GOAL:
case SPET_LOCATION: {
Dimension sprite_dim = GetSpriteSize(GetPageElementSprite(pe));
return sprite_dim.height;
break;
}
default:
NOT_REACHED();
}
return 0;
}
/**
* Get the total height of the content displayed
* in this window.
* @return the height in pixels
*/
uint GetContentHeight()
{
StoryPage *page = this->GetSelPage();
if (page == nullptr) return 0;
int max_width = GetAvailablePageContentWidth();
uint element_vertical_dist = FONT_HEIGHT_NORMAL;
/* Head */
uint height = GetHeadHeight(max_width);
/* Body */
for (const StoryPageElement *pe : this->story_page_elements) {
height += element_vertical_dist;
height += GetPageElementHeight(*pe, max_width);
}
return height;
}
/**
* Draws a page element that is composed of a sprite to the left and a single line of
* text after that. These page elements are generally clickable and are thus called
* action elements.
* @param y_offset Current y_offset which will get updated when this method has completed its drawing.
* @param width Width of the region available for drawing.
* @param line_height Height of one line of text.
* @param action_sprite The sprite to draw.
* @param string_id The string id to draw.
* @return the number of lines.
*/
void DrawActionElement(int &y_offset, int width, int line_height, SpriteID action_sprite, StringID string_id = STR_JUST_RAW_STRING) const
{
Dimension sprite_dim = GetSpriteSize(action_sprite);
uint element_height = max(sprite_dim.height, (uint)line_height);
uint sprite_top = y_offset + (element_height - sprite_dim.height) / 2;
uint text_top = y_offset + (element_height - line_height) / 2;
DrawSprite(action_sprite, PAL_NONE, 0, sprite_top);
DrawString(sprite_dim.width + WD_FRAMETEXT_LEFT, width, text_top, string_id, TC_BLACK);
y_offset += element_height;
}
/**
* Internal event handler for when a page element is clicked.
* @param pe The clicked page element.
*/
void OnPageElementClick(const StoryPageElement& pe)
{
switch (pe.type) {
case SPET_TEXT:
/* Do nothing. */
break;
case SPET_LOCATION:
if (_ctrl_pressed) {
ShowExtraViewPortWindow((TileIndex)pe.referenced_id);
} else {
ScrollMainWindowToTile((TileIndex)pe.referenced_id);
}
break;
case SPET_GOAL:
ShowGoalsList((CompanyID)this->window_number);
break;
default:
NOT_REACHED();
}
}
public:
StoryBookWindow(WindowDesc *desc, WindowNumber window_number) : Window(desc)
{
this->CreateNestedTree();
this->vscroll = this->GetScrollbar(WID_SB_SCROLLBAR);
this->vscroll->SetStepSize(FONT_HEIGHT_NORMAL);
/* Initialize page sort. */
this->story_pages.SetSortFuncs(StoryBookWindow::page_sorter_funcs);
this->story_pages.ForceRebuild();
this->BuildStoryPageList();
this->story_page_elements.SetSortFuncs(StoryBookWindow::page_element_sorter_funcs);
/* story_page_elements will get built by SetSelectedPage */
this->FinishInitNested(window_number);
this->owner = (Owner)this->window_number;
/* Initialize selected vars. */
this->selected_generic_title[0] = '\0';
this->selected_page_id = INVALID_STORY_PAGE;
this->OnInvalidateData(-1);
}
/**
* Updates the disabled state of the prev/next buttons.
*/
void UpdatePrevNextDisabledState()
{
this->SetWidgetDisabledState(WID_SB_PREV_PAGE, story_pages.size() == 0 || this->IsFirstPageSelected());
this->SetWidgetDisabledState(WID_SB_NEXT_PAGE, story_pages.size() == 0 || this->IsLastPageSelected());
this->SetWidgetDirty(WID_SB_PREV_PAGE);
this->SetWidgetDirty(WID_SB_NEXT_PAGE);
}
/**
* Sets the selected page.
* @param page_index pool index of the page to select.
*/
void SetSelectedPage(uint16 page_index)
{
if (this->selected_page_id != page_index) {
this->selected_page_id = page_index;
this->RefreshSelectedPage();
this->UpdatePrevNextDisabledState();
}
}
void SetStringParameters(int widget) const override
{
switch (widget) {
case WID_SB_SEL_PAGE: {
StoryPage *page = this->GetSelPage();
SetDParamStr(0, page != nullptr && page->title != nullptr ? page->title : this->selected_generic_title);
break;
}
case WID_SB_CAPTION:
if (this->window_number == INVALID_COMPANY) {
SetDParam(0, STR_STORY_BOOK_SPECTATOR_CAPTION);
} else {
SetDParam(0, STR_STORY_BOOK_CAPTION);
SetDParam(1, this->window_number);
}
break;
}
}
void OnPaint() override
{
/* Detect if content has changed height. This can happen if a
* multi-line text contains eg. {COMPANY} and that company is
* renamed.
*/
if (this->vscroll->GetCount() != this->GetContentHeight()) {
this->vscroll->SetCount(this->GetContentHeight());
this->SetWidgetDirty(WID_SB_SCROLLBAR);
this->SetWidgetDirty(WID_SB_PAGE_PANEL);
}
this->DrawWidgets();
}
void DrawWidget(const Rect &r, int widget) const override
{
if (widget != WID_SB_PAGE_PANEL) return;
StoryPage *page = this->GetSelPage();
if (page == nullptr) return;
const int x = r.left + WD_FRAMETEXT_LEFT;
const int y = r.top + WD_FRAMETEXT_TOP;
const int right = r.right - WD_FRAMETEXT_RIGHT;
const int bottom = r.bottom - WD_FRAMETEXT_BOTTOM;
/* Set up a clipping region for the panel. */
DrawPixelInfo tmp_dpi;
if (!FillDrawPixelInfo(&tmp_dpi, x, y, right - x + 1, bottom - y + 1)) return;
DrawPixelInfo *old_dpi = _cur_dpi;
_cur_dpi = &tmp_dpi;
/* Draw content (now coordinates given to Draw** are local to the new clipping region). */
int line_height = FONT_HEIGHT_NORMAL;
int y_offset = - this->vscroll->GetPosition();
/* Date */
if (page->date != INVALID_DATE) {
SetDParam(0, page->date);
DrawString(0, right - x, y_offset, STR_JUST_DATE_LONG, TC_BLACK);
}
y_offset += line_height;
/* Title */
SetDParamStr(0, page->title != nullptr ? page->title : this->selected_generic_title);
y_offset = DrawStringMultiLine(0, right - x, y_offset, bottom - y, STR_STORY_BOOK_TITLE, TC_BLACK, SA_TOP | SA_HOR_CENTER);
/* Page elements */
for (const StoryPageElement *const pe : this->story_page_elements) {
y_offset += line_height; // margin to previous element
switch (pe->type) {
case SPET_TEXT:
SetDParamStr(0, pe->text);
y_offset = DrawStringMultiLine(0, right - x, y_offset, bottom - y, STR_JUST_RAW_STRING, TC_BLACK, SA_TOP | SA_LEFT);
break;
case SPET_GOAL: {
Goal *g = Goal::Get((GoalID) pe->referenced_id);
StringID string_id = g == nullptr ? STR_STORY_BOOK_INVALID_GOAL_REF : STR_JUST_RAW_STRING;
if (g != nullptr) SetDParamStr(0, g->text);
DrawActionElement(y_offset, right - x, line_height, GetPageElementSprite(*pe), string_id);
break;
}
case SPET_LOCATION:
SetDParamStr(0, pe->text);
DrawActionElement(y_offset, right - x, line_height, GetPageElementSprite(*pe));
break;
default: NOT_REACHED();
}
}
/* Restore clipping region. */
_cur_dpi = old_dpi;
}
void UpdateWidgetSize(int widget, Dimension *size, const Dimension &padding, Dimension *fill, Dimension *resize) override
{
if (widget != WID_SB_SEL_PAGE && widget != WID_SB_PAGE_PANEL) return;
Dimension d;
d.height = FONT_HEIGHT_NORMAL;
d.width = 0;
switch (widget) {
case WID_SB_SEL_PAGE: {
/* Get max title width. */
for (uint16 i = 0; i < this->story_pages.size(); i++) {
const StoryPage *s = this->story_pages[i];
if (s->title != nullptr) {
SetDParamStr(0, s->title);
} else {
SetDParamStr(0, this->selected_generic_title);
}
Dimension title_d = GetStringBoundingBox(STR_BLACK_RAW_STRING);
if (title_d.width > d.width) {
d.width = title_d.width;
}
}
d.width += padding.width;
d.height += padding.height;
*size = maxdim(*size, d);
break;
}
case WID_SB_PAGE_PANEL: {
d.height *= 5;
d.height += padding.height + WD_FRAMETEXT_TOP + WD_FRAMETEXT_BOTTOM;
*size = maxdim(*size, d);
break;
}
}
}
void OnResize() override
{
this->vscroll->SetCapacityFromWidget(this, WID_SB_PAGE_PANEL, WD_FRAMETEXT_TOP + WD_FRAMETEXT_BOTTOM);
this->vscroll->SetCount(this->GetContentHeight());
}
void OnClick(Point pt, int widget, int click_count) override
{
switch (widget) {
case WID_SB_SEL_PAGE: {
DropDownList list = this->BuildDropDownList();
if (!list.empty()) {
/* Get the index of selected page. */
int selected = 0;
for (uint16 i = 0; i < this->story_pages.size(); i++) {
const StoryPage *p = this->story_pages[i];
if (p->index == this->selected_page_id) break;
selected++;
}
ShowDropDownList(this, std::move(list), selected, widget);
}
break;
}
case WID_SB_PREV_PAGE:
this->SelectPrevPage();
break;
case WID_SB_NEXT_PAGE:
this->SelectNextPage();
break;
case WID_SB_PAGE_PANEL: {
uint clicked_y = this->vscroll->GetScrolledRowFromWidget(pt.y, this, WID_SB_PAGE_PANEL, WD_FRAMETEXT_TOP);
uint max_width = GetAvailablePageContentWidth();
/* Skip head rows. */
uint head_height = this->GetHeadHeight(max_width);
if (clicked_y < head_height) return;
/* Detect if a page element was clicked. */
uint y = head_height;
uint element_vertical_dist = FONT_HEIGHT_NORMAL;
for (const StoryPageElement *const pe : this->story_page_elements) {
y += element_vertical_dist; // margin row
uint content_height = GetPageElementHeight(*pe, max_width);
if (clicked_y >= y && clicked_y < y + content_height) {
this->OnPageElementClick(*pe);
return;
}
y += content_height;
}
}
}
}
void OnDropdownSelect(int widget, int index) override
{
if (widget != WID_SB_SEL_PAGE) return;
/* index (which is set in BuildDropDownList) is the page id. */
this->SetSelectedPage(index);
}
/**
* Some data on this window has become invalid.
* @param data Information about the changed data.
* -1 Rebuild page list and refresh current page;
* >= 0 Id of the page that needs to be refreshed. If it is not the current page, nothing happens.
* @param gui_scope Whether the call is done from GUI scope. You may not do everything when not in GUI scope. See #InvalidateWindowData() for details.
*/
void OnInvalidateData(int data = 0, bool gui_scope = true) override
{
if (!gui_scope) return;
/* If added/removed page, force rebuild. Sort order never change so just a
* re-sort is never needed.
*/
if (data == -1) {
this->story_pages.ForceRebuild();
this->BuildStoryPageList();
/* Was the last page removed? */
if (this->story_pages.size() == 0) {
this->selected_generic_title[0] = '\0';
}
/* Verify page selection. */
if (!_story_page_pool.IsValidID(this->selected_page_id)) {
this->selected_page_id = INVALID_STORY_PAGE;
}
if (this->selected_page_id == INVALID_STORY_PAGE && this->story_pages.size() > 0) {
/* No page is selected, but there exist at least one available.
* => Select first page.
*/
this->SetSelectedPage(this->story_pages[0]->index);
}
this->SetWidgetDisabledState(WID_SB_SEL_PAGE, this->story_pages.size() == 0);
this->SetWidgetDirty(WID_SB_SEL_PAGE);
this->UpdatePrevNextDisabledState();
} else if (data >= 0 && this->selected_page_id == data) {
this->RefreshSelectedPage();
}
}
};
GUIStoryPageList::SortFunction * const StoryBookWindow::page_sorter_funcs[] = {
&PageOrderSorter,
};
GUIStoryPageElementList::SortFunction * const StoryBookWindow::page_element_sorter_funcs[] = {
&PageElementOrderSorter,
};
static const NWidgetPart _nested_story_book_widgets[] = {
NWidget(NWID_HORIZONTAL),
NWidget(WWT_CLOSEBOX, COLOUR_BROWN),
NWidget(WWT_CAPTION, COLOUR_BROWN, WID_SB_CAPTION), SetDataTip(STR_JUST_STRING, STR_TOOLTIP_WINDOW_TITLE_DRAG_THIS),
NWidget(WWT_SHADEBOX, COLOUR_BROWN),
NWidget(WWT_DEFSIZEBOX, COLOUR_BROWN),
NWidget(WWT_STICKYBOX, COLOUR_BROWN),
EndContainer(),
NWidget(NWID_HORIZONTAL), SetFill(1, 1),
NWidget(NWID_VERTICAL), SetFill(1, 1),
NWidget(WWT_PANEL, COLOUR_BROWN, WID_SB_PAGE_PANEL), SetResize(1, 1), SetScrollbar(WID_SB_SCROLLBAR), EndContainer(),
NWidget(NWID_HORIZONTAL),
NWidget(WWT_TEXTBTN, COLOUR_BROWN, WID_SB_PREV_PAGE), SetMinimalSize(100, 0), SetFill(0, 0), SetDataTip(STR_STORY_BOOK_PREV_PAGE, STR_STORY_BOOK_PREV_PAGE_TOOLTIP),
NWidget(NWID_BUTTON_DROPDOWN, COLOUR_BROWN, WID_SB_SEL_PAGE), SetMinimalSize(93, 12), SetFill(1, 0),
SetDataTip(STR_BLACK_RAW_STRING, STR_STORY_BOOK_SEL_PAGE_TOOLTIP), SetResize(1, 0),
NWidget(WWT_TEXTBTN, COLOUR_BROWN, WID_SB_NEXT_PAGE), SetMinimalSize(100, 0), SetFill(0, 0), SetDataTip(STR_STORY_BOOK_NEXT_PAGE, STR_STORY_BOOK_NEXT_PAGE_TOOLTIP),
EndContainer(),
EndContainer(),
NWidget(NWID_VERTICAL), SetFill(0, 1),
NWidget(NWID_VSCROLLBAR, COLOUR_BROWN, WID_SB_SCROLLBAR),
NWidget(WWT_RESIZEBOX, COLOUR_BROWN),
EndContainer(),
EndContainer(),
};
static WindowDesc _story_book_desc(
WDP_CENTER, "view_story", 400, 300,
WC_STORY_BOOK, WC_NONE,
0,
_nested_story_book_widgets, lengthof(_nested_story_book_widgets)
);
/**
* Raise or create the story book window for \a company, at page \a page_id.
* @param company 'Owner' of the story book, may be #INVALID_COMPANY.
* @param page_id Page to open, may be #INVALID_STORY_PAGE.
*/
void ShowStoryBook(CompanyID company, uint16 page_id)
{
if (!Company::IsValidID(company)) company = (CompanyID)INVALID_COMPANY;
StoryBookWindow *w = AllocateWindowDescFront(&_story_book_desc, company, true);
if (page_id != INVALID_STORY_PAGE) w->SetSelectedPage(page_id);
}