1 /*
2  * Copyright (C) 2016-2020 by the Widelands Development Team
3  *
4  * This program is free software; you can redistribute it and/or
5  * modify it under the terms of the GNU General Public License
6  * as published by the Free Software Foundation; either version 2
7  * of the License, or (at your option) any later version.
8  *
9  * This program is distributed in the hope that it will be useful,
10  * but WITHOUT ANY WARRANTY; without even the implied warranty of
11  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12  * GNU General Public License for more details.
13  *
14  * You should have received a copy of the GNU General Public License
15  * along with this program; if not, write to the Free Software
16  * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
17  *
18  */
19 
20 #include "ui_basic/dropdown.h"
21 
22 #include "base/i18n.h"
23 #include "graphic/font_handler.h"
24 #include "graphic/rendertarget.h"
25 #include "graphic/text_layout.h"
26 #include "ui_basic/tabpanel.h"
27 #include "ui_basic/window.h"
28 
29 namespace {
base_height(int button_dimension,UI::PanelStyle style)30 int base_height(int button_dimension, UI::PanelStyle style) {
31 	int result =
32 	   std::max(button_dimension, text_height(g_gr->styles().table_style(style).enabled()) + 2);
33 	return result;
34 }
35 }  // namespace
36 
37 namespace UI {
38 
39 int BaseDropdown::next_id_ = 0;
40 
41 // Dropdowns hook into parent elements to be notified of layouting changes. We need to keep track of
42 // whether a dropdown actually still exists when notified to avoid heap-use-after-free's.
43 static std::map<int, BaseDropdown*> living_dropdowns_;
44 // static
layout_if_alive(int id)45 void BaseDropdown::layout_if_alive(int id) {
46 	auto it = living_dropdowns_.find(id);
47 	if (it != living_dropdowns_.end()) {
48 		it->second->layout();
49 	}
50 }
51 
BaseDropdown(UI::Panel * parent,const std::string & name,int32_t x,int32_t y,uint32_t w,uint32_t max_list_items,int button_dimension,const std::string & label,const DropdownType type,UI::PanelStyle style,ButtonStyle button_style)52 BaseDropdown::BaseDropdown(UI::Panel* parent,
53                            const std::string& name,
54                            int32_t x,
55                            int32_t y,
56                            uint32_t w,
57                            uint32_t max_list_items,
58                            int button_dimension,
59                            const std::string& label,
60                            const DropdownType type,
61                            UI::PanelStyle style,
62                            ButtonStyle button_style)
63    : UI::NamedPanel(parent,
64                     name,
65                     x,
66                     y,
67                     (type == DropdownType::kPictorial || type == DropdownType::kPictorialMenu) ?
68                        button_dimension :
69                        w,
70                     // Height only to fit the button, so we can use this in Box layout.
71                     base_height(button_dimension, style)),
72      id_(next_id_++),
73      max_list_items_(max_list_items),
74      max_list_height_(std::numeric_limits<uint32_t>::max()),
75      list_offset_x_(0),
76      list_offset_y_(0),
77      base_height_(base_height(button_dimension, style)),
78      mouse_tolerance_(50),
79      button_box_(this, 0, 0, UI::Box::Horizontal, w, get_h()),
80      push_button_(type == DropdownType::kTextual ?
81                      new UI::Button(&button_box_,
82                                     "dropdown_select",
83                                     0,
84                                     0,
85                                     button_dimension,
86                                     get_h(),
87                                     button_style,
88                                     g_gr->images().get("images/ui_basic/scrollbar_down.png")) :
89                      nullptr),
90      display_button_(&button_box_,
91                      "dropdown_label",
92                      0,
93                      0,
94                      type == DropdownType::kTextual ?
95                         w - button_dimension :
96                         type == DropdownType::kTextualNarrow ? w : button_dimension,
97                      get_h(),
98                      type == DropdownType::kTextual ?
99                         (style == UI::PanelStyle::kFsMenu ? UI::ButtonStyle::kFsMenuSecondary :
100                                                             UI::ButtonStyle::kWuiSecondary) :
101                         button_style,
102                      label),
103      label_(label),
104      type_(type),
105      is_enabled_(true),
106      button_style_(button_style),
107      autoexpand_display_button_(false) {
108 	if (label.empty()) {
109 		set_tooltip(pgettext("dropdown", "Select Item"));
110 	} else {
111 		set_tooltip(label);
112 	}
113 
114 	// Close whenever another dropdown is opened
115 	dropdown_subscriber_ = Notifications::subscribe<NoteDropdown>([this](const NoteDropdown& note) {
116 		if (id_ != note.id) {
117 			close();
118 		}
119 	});
120 	graphic_resolution_changed_subscriber_ = Notifications::subscribe<GraphicResolutionChanged>(
121 	   [this](const GraphicResolutionChanged&) { layout(); });
122 
123 	assert(max_list_items_ > 0);
124 	// Hook into highest parent that we can get so that we can drop down outside the panel.
125 	UI::Panel* list_parent = &display_button_;
126 	while (list_parent->get_parent()) {
127 		list_parent = list_parent->get_parent();
128 	}
129 	list_ =
130 	   new UI::Listselect<uintptr_t>(list_parent, 0, 0, w, 0, style, ListselectLayout::kDropdown);
131 	list_->set_notify_on_delete(this);
132 
133 	list_->set_visible(false);
134 	button_box_.add(&display_button_, UI::Box::Resizing::kExpandBoth);
135 	display_button_.sigclicked.connect([this]() { toggle_list(); });
136 	if (push_button_ != nullptr) {
137 		display_button_.set_perm_pressed(true);
138 		button_box_.add(push_button_, UI::Box::Resizing::kFullSize);
139 		push_button_->sigclicked.connect([this]() { toggle_list(); });
140 	}
141 	button_box_.set_size(w, get_h());
142 	list_->clicked.connect([this]() { set_value(); });
143 	list_->clicked.connect([this]() { toggle_list(); });
144 	set_can_focus(true);
145 	set_value();
146 
147 	const int serial = id_;  // Not a member variable, because when the lambda below is triggered we
148 	                         // might no longer exist
149 	living_dropdowns_.insert(std::make_pair(serial, this));
150 	// Find parent windows, boxes etc. so that we can move the list along with them
151 	UI::Panel* ancestor = this;
152 	while ((ancestor = ancestor->get_parent()) != nullptr) {
153 		ancestor->position_changed.connect([serial] { layout_if_alive(serial); });
154 	}
155 	layout();
156 }
157 
~BaseDropdown()158 BaseDropdown::~BaseDropdown() {
159 	// The list needs to be able to drop outside of windows, so it won't close with the window.
160 	// So, we tell it to die.
161 	if (list_) {
162 		list_->set_notify_on_delete(nullptr);
163 		list_->die();
164 	}
165 
166 	// Unsubscribe from layouting hooks
167 	assert(living_dropdowns_.find(id_) != living_dropdowns_.end());
168 	living_dropdowns_.erase(living_dropdowns_.find(id_));
169 }
170 
set_height(int height)171 void BaseDropdown::set_height(int height) {
172 	max_list_height_ = height - base_height_;
173 	layout();
174 }
175 
layout()176 void BaseDropdown::layout() {
177 	int list_width = list_->calculate_desired_width();
178 
179 	const int new_list_height = std::min(max_list_height_ / list_->get_lineheight(),
180 	                                     std::min(list_->size(), max_list_items_)) *
181 	                            list_->get_lineheight();
182 	list_->set_size(std::max(list_width, button_box_.get_w()), new_list_height);
183 
184 	// Update list position. The list is hooked into the highest parent that we can get so that we
185 	// can drop down outside the panel.
186 	UI::Panel* parent = &display_button_;
187 	int new_list_x = display_button_.get_x();
188 	int new_list_y = display_button_.get_y();
189 	while (parent->get_parent()) {
190 		parent = parent->get_parent();
191 		new_list_x += parent->get_x() + parent->get_lborder();
192 		new_list_y += parent->get_y() + parent->get_tborder();
193 	}
194 
195 	// Drop up instead of down if it doesn't fit
196 	if (new_list_y + list_->get_h() > g_gr->get_yres()) {
197 		list_offset_y_ = -list_->get_h();
198 	} else {
199 		list_offset_y_ = display_button_.get_h();
200 	}
201 
202 	// Right align instead of left align if it doesn't fit
203 	if (new_list_x + list_->get_w() > g_gr->get_xres()) {
204 		list_offset_x_ = display_button_.get_w() - list_->get_w();
205 		if (push_button_ != nullptr) {
206 			list_offset_x_ += push_button_->get_w();
207 		}
208 	}
209 
210 	list_->set_pos(Vector2i(new_list_x + list_offset_x_, new_list_y + list_offset_y_));
211 
212 	// Keep open list on top while dragging
213 	// TODO(GunChleoc): It would be better to close the list if any other panel is clicked,
214 	// but we'd need a global "clicked" signal in the Panel class for that.
215 	// This will imply a complete overhaul of the signal names.
216 	if (list_->is_visible()) {
217 		list_->move_to_top();
218 	}
219 }
220 
set_size(int nw,int nh)221 void BaseDropdown::set_size(int nw, int nh) {
222 	button_box_.set_size(nw, nh);
223 	Panel::set_size(nw, nh);
224 	layout();
225 }
set_desired_size(int nw,int nh)226 void BaseDropdown::set_desired_size(int nw, int nh) {
227 	button_box_.set_desired_size(nw, nh);
228 	Panel::set_desired_size(nw, nh);
229 	layout();
230 }
231 
set_autoexpand_display_button()232 void BaseDropdown::set_autoexpand_display_button() {
233 	autoexpand_display_button_ = true;
234 }
235 
add(const std::string & name,const uint32_t value,const Image * pic,const bool select_this,const std::string & tooltip_text,const std::string & hotkey)236 void BaseDropdown::add(const std::string& name,
237                        const uint32_t value,
238                        const Image* pic,
239                        const bool select_this,
240                        const std::string& tooltip_text,
241                        const std::string& hotkey) {
242 	assert(pic != nullptr || type_ != DropdownType::kPictorial);
243 	list_->add(name, value, pic, select_this, tooltip_text, hotkey);
244 	if (select_this) {
245 		set_value();
246 	}
247 
248 	if (autoexpand_display_button_) {
249 		/// Fit width of display button to make enough room for the entry's text
250 		const std::string fitme =
251 		   label_.empty() ? name : (boost::format(_("%1%: %2%")) % label_ % name).str();
252 		const int new_width =
253 		   text_width(fitme, g_gr->styles().button_style(button_style_).enabled().font()) + 8;
254 		if (new_width > display_button_.get_w()) {
255 			set_desired_size(get_w() + new_width - display_button_.get_w(), get_h());
256 			set_size(get_w() + new_width - display_button_.get_w(), get_h());
257 		}
258 	}
259 	layout();
260 }
261 
has_selection() const262 bool BaseDropdown::has_selection() const {
263 	return list_->has_selection();
264 }
265 
get_selected() const266 uint32_t BaseDropdown::get_selected() const {
267 	assert(has_selection());
268 	return list_->get_selected();
269 }
270 
select(uint32_t entry)271 void BaseDropdown::select(uint32_t entry) {
272 	assert(entry < list_->size());
273 	list_->select(entry);
274 	current_selection_ = list_->selection_index();
275 	update();
276 }
277 
set_label(const std::string & text)278 void BaseDropdown::set_label(const std::string& text) {
279 	label_ = text;
280 	if (type_ != DropdownType::kPictorial && type_ != DropdownType::kPictorialMenu) {
281 		display_button_.set_title(label_);
282 	}
283 }
284 
set_image(const Image * image)285 void BaseDropdown::set_image(const Image* image) {
286 	display_button_.set_pic(image);
287 }
288 
set_tooltip(const std::string & text)289 void BaseDropdown::set_tooltip(const std::string& text) {
290 	tooltip_ = text;
291 	display_button_.set_tooltip(tooltip_);
292 	if (push_button_) {
293 		push_button_->set_tooltip(push_button_->enabled() ? tooltip_ : "");
294 	}
295 }
296 
set_errored(const std::string & error_message)297 void BaseDropdown::set_errored(const std::string& error_message) {
298 	set_tooltip((boost::format(_("%1%: %2%")) % _("Error") % error_message).str());
299 	if (type_ != DropdownType::kPictorial && type_ != DropdownType::kPictorialMenu) {
300 		set_label(_("Error"));
301 	} else {
302 		set_image(g_gr->images().get("images/ui_basic/different.png"));
303 	}
304 }
305 
set_enabled(bool on)306 void BaseDropdown::set_enabled(bool on) {
307 	is_enabled_ = on;
308 	set_can_focus(on);
309 	if (push_button_ != nullptr) {
310 		push_button_->set_enabled(on);
311 		push_button_->set_tooltip(on ? tooltip_ : "");
312 	}
313 	display_button_.set_enabled(on);
314 	list_->set_visible(false);
315 }
316 
set_disable_style(UI::ButtonDisableStyle disable_style)317 void BaseDropdown::set_disable_style(UI::ButtonDisableStyle disable_style) {
318 	display_button_.set_disable_style(disable_style);
319 }
320 
is_expanded() const321 bool BaseDropdown::is_expanded() const {
322 	return list_->is_visible();
323 }
324 
set_pos(Vector2i point)325 void BaseDropdown::set_pos(Vector2i point) {
326 	UI::Panel::set_pos(point);
327 	layout();
328 }
329 
clear()330 void BaseDropdown::clear() {
331 	close();
332 	list_->clear();
333 	current_selection_ = list_->selection_index();
334 	list_->set_size(list_->get_w(), 0);
335 	list_->set_visible(false);
336 	set_layout_toplevel(false);
337 }
338 
think()339 void BaseDropdown::think() {
340 	if (list_->is_visible()) {
341 		// Autocollapse with a bit of tolerance for the mouse movement to make it less fiddly.
342 		if (!(has_focus() || list_->has_focus()) || is_mouse_away()) {
343 			toggle_list();
344 		}
345 	}
346 }
347 
size() const348 uint32_t BaseDropdown::size() const {
349 	return list_->size();
350 }
351 
update()352 void BaseDropdown::update() {
353 	if (type_ == DropdownType::kPictorialMenu) {
354 		// Menus never change their main image and text
355 		return;
356 	}
357 
358 	const std::string name = list_->has_selection() ?
359 	                            list_->get_selected_name() :
360 	                            /** TRANSLATORS: Selection in Dropdown menus. */
361 	                            pgettext("dropdown", "Not Selected");
362 
363 	if (type_ != DropdownType::kPictorial) {
364 		if (label_.empty()) {
365 			display_button_.set_title(name);
366 		} else {
367 			/** TRANSLATORS: Label: Value. */
368 			display_button_.set_title((boost::format(_("%1%: %2%")) % label_ % (name)).str());
369 		}
370 		display_button_.set_tooltip(list_->has_selection() ? list_->get_selected_tooltip() :
371 		                                                     tooltip_);
372 	} else {
373 		display_button_.set_pic(list_->has_selection() ?
374 		                           list_->get_selected_image() :
375 		                           g_gr->images().get("images/ui_basic/different.png"));
376 		display_button_.set_tooltip((boost::format(_("%1%: %2%")) % label_ % name).str());
377 	}
378 }
379 
set_value()380 void BaseDropdown::set_value() {
381 	update();
382 	selected();
383 	current_selection_ = list_->selection_index();
384 }
385 
toggle()386 void BaseDropdown::toggle() {
387 	set_list_visibility(!list_->is_visible());
388 }
389 
set_list_visibility(bool open)390 void BaseDropdown::set_list_visibility(bool open) {
391 	if (!is_enabled_) {
392 		list_->set_visible(false);
393 		return;
394 	}
395 	list_->set_visible(open);
396 	if (list_->is_visible()) {
397 		list_->move_to_top();
398 		focus();
399 		set_mouse_pos(Vector2i(display_button_.get_x() + (display_button_.get_w() * 3 / 5),
400 		                       display_button_.get_y() + (display_button_.get_h() * 2 / 5)));
401 		if (type_ == DropdownType::kPictorialMenu && !has_selection() && !list_->empty()) {
402 			select(0);
403 		}
404 	}
405 	if (type_ != DropdownType::kTextual) {
406 		display_button_.set_perm_pressed(list_->is_visible());
407 	}
408 	// Make sure that the list covers and deactivates the elements below it
409 	set_layout_toplevel(list_->is_visible());
410 }
411 
toggle_list()412 void BaseDropdown::toggle_list() {
413 	if (!is_enabled_) {
414 		list_->set_visible(false);
415 		return;
416 	}
417 	list_->set_visible(!list_->is_visible());
418 	if (type_ != DropdownType::kTextual) {
419 		display_button_.set_perm_pressed(list_->is_visible());
420 	}
421 	if (list_->is_visible()) {
422 		list_->move_to_top();
423 		focus();
424 		Notifications::publish(NoteDropdown(id_));
425 	}
426 	// Make sure that the list covers and deactivates the elements below it
427 	set_layout_toplevel(list_->is_visible());
428 }
429 
close()430 void BaseDropdown::close() {
431 	if (is_expanded()) {
432 		toggle_list();
433 	}
434 }
435 
is_mouse_away() const436 bool BaseDropdown::is_mouse_away() const {
437 	return (get_mouse_position().x + mouse_tolerance_) < list_offset_x_ ||
438 	       get_mouse_position().x > (list_offset_x_ + list_->get_w() + mouse_tolerance_) ||
439 	       (get_mouse_position().y + mouse_tolerance_) < list_offset_y_ ||
440 	       get_mouse_position().y > (list_offset_y_ + get_h() + list_->get_h() + mouse_tolerance_);
441 }
442 
handle_key(bool down,SDL_Keysym code)443 bool BaseDropdown::handle_key(bool down, SDL_Keysym code) {
444 	if (down) {
445 		switch (code.sym) {
446 		case SDLK_KP_ENTER:
447 		case SDLK_RETURN:
448 			if (list_->is_visible()) {
449 				set_value();
450 				return true;
451 			}
452 			break;
453 		case SDLK_ESCAPE:
454 			if (list_->is_visible()) {
455 				list_->select(current_selection_);
456 				toggle_list();
457 				return true;
458 			}
459 			break;
460 		default:
461 			break;  // not handled
462 		}
463 	}
464 	if (list_->is_visible()) {
465 		return list_->handle_key(down, code);
466 	}
467 	return false;
468 }
469 
470 }  // namespace UI
471