1 #region Copyright & License Information
2 /*
3  * Copyright 2007-2020 The OpenRA Developers (see AUTHORS)
4  * This file is part of OpenRA, which is free software. It is made
5  * available to you under the terms of the GNU General Public License
6  * as published by the Free Software Foundation, either version 3 of
7  * the License, or (at your option) any later version. For more
8  * information, see COPYING.
9  */
10 #endregion
11 
12 using System;
13 using System.Linq;
14 using OpenRA.Graphics;
15 using OpenRA.Primitives;
16 using OpenRA.Widgets;
17 
18 namespace OpenRA.Mods.Common.Widgets
19 {
20 	public interface ILayout
21 	{
AdjustChild(Widget w)22 		void AdjustChild(Widget w);
AdjustChildren()23 		void AdjustChildren();
24 	}
25 
26 	public enum ScrollPanelAlign
27 	{
28 		Bottom,
29 		Top
30 	}
31 
32 	public enum ScrollBar
33 	{
34 		Left,
35 		Right,
36 		Hidden
37 	}
38 
39 	public class ScrollPanelWidget : Widget
40 	{
41 		readonly Ruleset modRules;
42 		public int ScrollbarWidth = 24;
43 		public int BorderWidth = 1;
44 		public int TopBottomSpacing = 2;
45 		public int ItemSpacing = 0;
46 		public int ButtonDepth = ChromeMetrics.Get<int>("ButtonDepth");
47 		public string ClickSound = ChromeMetrics.Get<string>("ClickSound");
48 		public string Background = "scrollpanel-bg";
49 		public string ScrollBarBackground = "scrollpanel-bg";
50 		public string Button = "scrollpanel-button";
51 		public int ContentHeight;
52 		public ILayout Layout;
53 		public int MinimumThumbSize = 10;
54 		public ScrollPanelAlign Align = ScrollPanelAlign.Top;
55 		public ScrollBar ScrollBar = ScrollBar.Right;
56 		public bool CollapseHiddenChildren;
57 
58 		// Fraction of the remaining scroll-delta to move in 40ms
59 		public float SmoothScrollSpeed = 0.333f;
60 
61 		protected bool upPressed;
62 		protected bool downPressed;
63 		protected bool upDisabled;
64 		protected bool downDisabled;
65 		protected bool thumbPressed;
66 		protected Rectangle upButtonRect;
67 		protected Rectangle downButtonRect;
68 		protected Rectangle backgroundRect;
69 		protected Rectangle scrollbarRect;
70 		protected Rectangle thumbRect;
71 
72 		// The target value is the list offset we're trying to reach
73 		float targetListOffset;
74 
75 		// The current value is the actual list offset at the moment
76 		float currentListOffset;
77 
78 		// The Game.Runtime value when UpdateSmoothScrolling was last called
79 		// Used for calculating the per-frame smooth-scrolling delta
80 		long lastSmoothScrollTime = 0;
81 
82 		// Setting "smooth" to true will only update the target list offset.
83 		// Setting "smooth" to false will also set the current list offset,
84 		// i.e. it will scroll immediately.
85 		//
86 		// For example, scrolling with the mouse wheel will use smooth
87 		// scrolling to give a nice visual effect that makes it easier
88 		// for the user to follow. Dragging the scrollbar's thumb, however,
89 		// will scroll to the desired position immediately.
SetListOffset(float value, bool smooth)90 		protected void SetListOffset(float value, bool smooth)
91 		{
92 			targetListOffset = value;
93 			if (!smooth)
94 			{
95 				var oldListOffset = currentListOffset;
96 				currentListOffset = value;
97 
98 				// Update mouseover
99 				if (oldListOffset != currentListOffset)
100 					Ui.ResetTooltips();
101 			}
102 		}
103 
104 		[ObjectCreator.UseCtor]
ScrollPanelWidget(ModData modData)105 		public ScrollPanelWidget(ModData modData)
106 		{
107 			modRules = modData.DefaultRules;
108 
109 			Layout = new ListLayout(this);
110 		}
111 
RemoveChildren()112 		public override void RemoveChildren()
113 		{
114 			ContentHeight = 0;
115 			base.RemoveChildren();
116 		}
117 
AddChild(Widget child)118 		public override void AddChild(Widget child)
119 		{
120 			// Initial setup of margins/height
121 			Layout.AdjustChild(child);
122 			base.AddChild(child);
123 		}
124 
RemoveChild(Widget child)125 		public override void RemoveChild(Widget child)
126 		{
127 			base.RemoveChild(child);
128 			Layout.AdjustChildren();
129 			Scroll(0);
130 		}
131 
ReplaceChild(Widget oldChild, Widget newChild)132 		public void ReplaceChild(Widget oldChild, Widget newChild)
133 		{
134 			oldChild.Removed();
135 			newChild.Parent = this;
136 			Children[Children.IndexOf(oldChild)] = newChild;
137 			Layout.AdjustChildren();
138 			Scroll(0);
139 		}
140 
DrawOuter()141 		public override void DrawOuter()
142 		{
143 			if (!IsVisible())
144 				return;
145 
146 			UpdateSmoothScrolling();
147 
148 			var rb = RenderBounds;
149 			var scrollbarHeight = rb.Height - 2 * ScrollbarWidth;
150 
151 			// Scroll thumb is only visible if the content does not fit within the panel bounds
152 			var thumbHeight = 0;
153 			var thumbOrigin = rb.Y + ScrollbarWidth;
154 			if (ContentHeight > rb.Height)
155 			{
156 				thumbHeight = Math.Max(MinimumThumbSize, scrollbarHeight * rb.Height / ContentHeight);
157 				thumbOrigin += (int)((scrollbarHeight - thumbHeight) * currentListOffset / (rb.Height - ContentHeight));
158 			}
159 
160 			switch (ScrollBar)
161 			{
162 				case ScrollBar.Left:
163 					backgroundRect = new Rectangle(rb.X + ScrollbarWidth, rb.Y, rb.Width + 1, rb.Height);
164 					upButtonRect = new Rectangle(rb.X, rb.Y, ScrollbarWidth, ScrollbarWidth);
165 					downButtonRect = new Rectangle(rb.X, rb.Bottom - ScrollbarWidth, ScrollbarWidth, ScrollbarWidth);
166 					scrollbarRect = new Rectangle(rb.X, rb.Y + ScrollbarWidth - 1, ScrollbarWidth, scrollbarHeight + 2);
167 					thumbRect = new Rectangle(rb.X, thumbOrigin, ScrollbarWidth, thumbHeight);
168 					break;
169 				case ScrollBar.Right:
170 					backgroundRect = new Rectangle(rb.X, rb.Y, rb.Width - ScrollbarWidth + 1, rb.Height);
171 					upButtonRect = new Rectangle(rb.Right - ScrollbarWidth, rb.Y, ScrollbarWidth, ScrollbarWidth);
172 					downButtonRect = new Rectangle(rb.Right - ScrollbarWidth, rb.Bottom - ScrollbarWidth, ScrollbarWidth, ScrollbarWidth);
173 					scrollbarRect = new Rectangle(rb.Right - ScrollbarWidth, rb.Y + ScrollbarWidth - 1, ScrollbarWidth, scrollbarHeight + 2);
174 					thumbRect = new Rectangle(rb.Right - ScrollbarWidth, thumbOrigin, ScrollbarWidth, thumbHeight);
175 					break;
176 				case ScrollBar.Hidden:
177 					backgroundRect = new Rectangle(rb.X, rb.Y, rb.Width + 1, rb.Height);
178 					break;
179 				default:
180 					throw new ArgumentOutOfRangeException();
181 			}
182 
183 			WidgetUtils.DrawPanel(Background, backgroundRect);
184 
185 			if (ScrollBar != ScrollBar.Hidden)
186 			{
187 				var upHover = Ui.MouseOverWidget == this && upButtonRect.Contains(Viewport.LastMousePos);
188 				upDisabled = thumbHeight == 0 || currentListOffset >= 0;
189 
190 				var downHover = Ui.MouseOverWidget == this && downButtonRect.Contains(Viewport.LastMousePos);
191 				downDisabled = thumbHeight == 0 || currentListOffset <= Bounds.Height - ContentHeight;
192 
193 				var thumbHover = Ui.MouseOverWidget == this && thumbRect.Contains(Viewport.LastMousePos);
194 				WidgetUtils.DrawPanel(ScrollBarBackground, scrollbarRect);
195 				ButtonWidget.DrawBackground(Button, upButtonRect, upDisabled, upPressed, upHover, false);
196 				ButtonWidget.DrawBackground(Button, downButtonRect, downDisabled, downPressed, downHover, false);
197 
198 				if (thumbHeight > 0)
199 					ButtonWidget.DrawBackground(Button, thumbRect, false, HasMouseFocus && thumbHover, thumbHover, false);
200 
201 				var upOffset = !upPressed || upDisabled ? 4 : 4 + ButtonDepth;
202 				var downOffset = !downPressed || downDisabled ? 4 : 4 + ButtonDepth;
203 
204 				WidgetUtils.DrawRGBA(ChromeProvider.GetImage("scrollbar", upPressed || upDisabled ? "up_pressed" : "up_arrow"),
205 					new float2(upButtonRect.Left + upOffset, upButtonRect.Top + upOffset));
206 				WidgetUtils.DrawRGBA(ChromeProvider.GetImage("scrollbar", downPressed || downDisabled ? "down_pressed" : "down_arrow"),
207 					new float2(downButtonRect.Left + downOffset, downButtonRect.Top + downOffset));
208 			}
209 
210 			var drawBounds = backgroundRect.InflateBy(-BorderWidth, -BorderWidth, -BorderWidth, -BorderWidth);
211 			Game.Renderer.EnableScissor(drawBounds);
212 
213 			// ChildOrigin enumerates the widget tree, so only evaluate it once
214 			var co = ChildOrigin;
215 			drawBounds.X -= co.X;
216 			drawBounds.Y -= co.Y;
217 
218 			foreach (var child in Children)
219 				if (child.Bounds.IntersectsWith(drawBounds))
220 					child.DrawOuter();
221 
222 			Game.Renderer.DisableScissor();
223 		}
224 
225 		public override int2 ChildOrigin
226 		{
227 			get
228 			{
229 				return RenderOrigin + new int2(ScrollBar == ScrollBar.Left ? ScrollbarWidth : 0, (int)currentListOffset);
230 			}
231 		}
232 
GetEventBounds()233 		public override Rectangle GetEventBounds()
234 		{
235 			return EventBounds;
236 		}
237 
Scroll(int amount, bool smooth = false)238 		void Scroll(int amount, bool smooth = false)
239 		{
240 			var newTarget = targetListOffset + amount * Game.Settings.Game.UIScrollSpeed;
241 			newTarget = Math.Min(0, Math.Max(Bounds.Height - ContentHeight, newTarget));
242 
243 			SetListOffset(newTarget, smooth);
244 		}
245 
ScrollToBottom(bool smooth = false)246 		public void ScrollToBottom(bool smooth = false)
247 		{
248 			var value = Align == ScrollPanelAlign.Top ?
249 				Math.Min(0, Bounds.Height - ContentHeight) :
250 				Bounds.Height - ContentHeight;
251 
252 			SetListOffset(value, smooth);
253 		}
254 
ScrollToTop(bool smooth = false)255 		public void ScrollToTop(bool smooth = false)
256 		{
257 			var value = Align == ScrollPanelAlign.Top ? 0 :
258 				Math.Max(0, Bounds.Height - ContentHeight);
259 
260 			SetListOffset(value, smooth);
261 		}
262 
263 		public bool ScrolledToBottom
264 		{
265 			get { return targetListOffset == Math.Min(0, Bounds.Height - ContentHeight) || ContentHeight <= Bounds.Height; }
266 		}
267 
ScrollToItem(Widget item, bool smooth = false)268 		void ScrollToItem(Widget item, bool smooth = false)
269 		{
270 			// Scroll the item to be visible
271 			float? newOffset = null;
272 			if (item.Bounds.Top + currentListOffset < 0)
273 				newOffset = ItemSpacing - item.Bounds.Top;
274 
275 			if (item.Bounds.Bottom + currentListOffset > RenderBounds.Height)
276 				newOffset = RenderBounds.Height - item.Bounds.Bottom - ItemSpacing;
277 
278 			if (newOffset.HasValue)
279 				SetListOffset(newOffset.Value, smooth);
280 		}
281 
ScrollToItem(string itemKey, bool smooth = false)282 		public void ScrollToItem(string itemKey, bool smooth = false)
283 		{
284 			var item = Children.FirstOrDefault(c =>
285 			{
286 				var si = c as ScrollItemWidget;
287 				return si != null && si.ItemKey == itemKey;
288 			});
289 
290 			if (item != null)
291 				ScrollToItem(item, smooth);
292 		}
293 
ScrollToSelectedItem()294 		public void ScrollToSelectedItem()
295 		{
296 			var item = Children.FirstOrDefault(c =>
297 			{
298 				var si = c as ScrollItemWidget;
299 				return si != null && si.IsSelected();
300 			});
301 
302 			if (item != null)
303 				ScrollToItem(item);
304 		}
305 
UpdateSmoothScrolling()306 		void UpdateSmoothScrolling()
307 		{
308 			if (lastSmoothScrollTime == 0)
309 			{
310 				lastSmoothScrollTime = Game.RunTime;
311 				return;
312 			}
313 
314 			var offsetDiff = targetListOffset - currentListOffset;
315 			var absOffsetDiff = Math.Abs(offsetDiff);
316 			if (absOffsetDiff > 1f)
317 			{
318 				var dt = Game.RunTime - lastSmoothScrollTime;
319 				currentListOffset += offsetDiff * SmoothScrollSpeed.Clamp(0.1f, 1.0f) * dt / 40;
320 
321 				Ui.ResetTooltips();
322 			}
323 			else
324 				SetListOffset(targetListOffset, false);
325 
326 			lastSmoothScrollTime = Game.RunTime;
327 		}
328 
Tick()329 		public override void Tick()
330 		{
331 			if (upPressed)
332 				Scroll(1);
333 
334 			if (downPressed)
335 				Scroll(-1);
336 		}
337 
YieldMouseFocus(MouseInput mi)338 		public override bool YieldMouseFocus(MouseInput mi)
339 		{
340 			upPressed = downPressed = thumbPressed = false;
341 			return base.YieldMouseFocus(mi);
342 		}
343 
344 		int2 lastMouseLocation;
345 
HandleMouseInput(MouseInput mi)346 		public override bool HandleMouseInput(MouseInput mi)
347 		{
348 			if (mi.Event == MouseInputEvent.Scroll)
349 			{
350 				Scroll(mi.Delta.Y, true);
351 				return true;
352 			}
353 
354 			if (mi.Button != MouseButton.Left)
355 				return false;
356 
357 			if (mi.Event == MouseInputEvent.Down && !TakeMouseFocus(mi))
358 				return false;
359 
360 			if (!HasMouseFocus)
361 				return false;
362 
363 			if (HasMouseFocus && mi.Event == MouseInputEvent.Up)
364 				return YieldMouseFocus(mi);
365 
366 			if (thumbPressed && mi.Event == MouseInputEvent.Move)
367 			{
368 				var rb = RenderBounds;
369 				var scrollbarHeight = rb.Height - 2 * ScrollbarWidth;
370 				var thumbHeight = ContentHeight == 0 ? 0 : Math.Max(MinimumThumbSize, (int)(scrollbarHeight * Math.Min(rb.Height * 1f / ContentHeight, 1f)));
371 				var oldOffset = currentListOffset;
372 
373 				var newOffset = currentListOffset + ((int)((lastMouseLocation.Y - mi.Location.Y) * (ContentHeight - rb.Height) * 1f / (scrollbarHeight - thumbHeight)));
374 				newOffset = Math.Min(0, Math.Max(rb.Height - ContentHeight, newOffset));
375 				SetListOffset(newOffset, false);
376 
377 				if (oldOffset != newOffset)
378 					lastMouseLocation = mi.Location;
379 			}
380 			else
381 			{
382 				upPressed = upButtonRect.Contains(mi.Location);
383 				downPressed = downButtonRect.Contains(mi.Location);
384 				thumbPressed = thumbRect.Contains(mi.Location);
385 				if (thumbPressed)
386 					lastMouseLocation = mi.Location;
387 
388 				if (mi.Event == MouseInputEvent.Down && ((upPressed && !upDisabled) || (downPressed && !downDisabled) || thumbPressed))
389 					Game.Sound.PlayNotification(modRules, null, "Sounds", ClickSound, null);
390 			}
391 
392 			return upPressed || downPressed || thumbPressed;
393 		}
394 
395 		IObservableCollection collection;
396 		Func<object, Widget> makeWidget;
397 		Func<Widget, object, bool> widgetItemEquals;
398 		bool autoScroll;
399 
Unbind()400 		public void Unbind()
401 		{
402 			Bind(null, null, null, false);
403 		}
404 
Bind(IObservableCollection c, Func<object, Widget> makeWidget, Func<Widget, object, bool> widgetItemEquals, bool autoScroll)405 		public void Bind(IObservableCollection c, Func<object, Widget> makeWidget, Func<Widget, object, bool> widgetItemEquals, bool autoScroll)
406 		{
407 			this.autoScroll = autoScroll;
408 
409 			Game.RunAfterTick(() =>
410 			{
411 				if (collection != null)
412 				{
413 					collection.OnAdd -= BindingAdd;
414 					collection.OnRemove -= BindingRemove;
415 					collection.OnRemoveAt -= BindingRemoveAt;
416 					collection.OnSet -= BindingSet;
417 					collection.OnRefresh -= BindingRefresh;
418 				}
419 
420 				this.makeWidget = makeWidget;
421 				this.widgetItemEquals = widgetItemEquals;
422 
423 				RemoveChildren();
424 				collection = c;
425 
426 				if (c != null)
427 				{
428 					foreach (var item in c.ObservedItems)
429 						BindingAddImpl(item);
430 
431 					c.OnAdd += BindingAdd;
432 					c.OnRemove += BindingRemove;
433 					c.OnRemoveAt += BindingRemoveAt;
434 					c.OnSet += BindingSet;
435 					c.OnRefresh += BindingRefresh;
436 				}
437 			});
438 		}
439 
BindingAdd(IObservableCollection col, object item)440 		void BindingAdd(IObservableCollection col, object item)
441 		{
442 			Game.RunAfterTick(() =>
443 			{
444 				if (collection != col)
445 					return;
446 
447 				BindingAddImpl(item);
448 			});
449 		}
450 
BindingAddImpl(object item)451 		void BindingAddImpl(object item)
452 		{
453 			if (makeWidget == null)
454 				return;
455 
456 			var widget = makeWidget(item);
457 			var scrollToBottom = autoScroll && ScrolledToBottom;
458 
459 			AddChild(widget);
460 
461 			if (scrollToBottom)
462 				ScrollToBottom();
463 		}
464 
BindingRemove(IObservableCollection col, object item)465 		void BindingRemove(IObservableCollection col, object item)
466 		{
467 			Game.RunAfterTick(() =>
468 			{
469 				if (collection != col)
470 					return;
471 
472 				var widget = Children.FirstOrDefault(w => widgetItemEquals(w, item));
473 				if (widget != null)
474 					RemoveChild(widget);
475 			});
476 		}
477 
BindingRemoveAt(IObservableCollection col, int index)478 		void BindingRemoveAt(IObservableCollection col, int index)
479 		{
480 			Game.RunAfterTick(() =>
481 			{
482 				if (collection != col)
483 					return;
484 
485 				if (index < 0 || index >= Children.Count)
486 					return;
487 
488 				RemoveChild(Children[index]);
489 			});
490 		}
491 
BindingSet(IObservableCollection col, object oldItem, object newItem)492 		void BindingSet(IObservableCollection col, object oldItem, object newItem)
493 		{
494 			Game.RunAfterTick(() =>
495 			{
496 				if (collection != col)
497 					return;
498 
499 				var newWidget = makeWidget(newItem);
500 				newWidget.Parent = this;
501 
502 				var i = Children.FindIndex(w => widgetItemEquals(w, oldItem));
503 				if (i >= 0)
504 				{
505 					var oldWidget = Children[i];
506 					oldWidget.Removed();
507 					Children[i] = newWidget;
508 					Layout.AdjustChildren();
509 				}
510 				else
511 					AddChild(newWidget);
512 			});
513 		}
514 
BindingRefresh(IObservableCollection col)515 		void BindingRefresh(IObservableCollection col)
516 		{
517 			Game.RunAfterTick(() =>
518 			{
519 				if (collection != col)
520 					return;
521 
522 				RemoveChildren();
523 				foreach (var item in collection.ObservedItems)
524 					BindingAddImpl(item);
525 			});
526 		}
527 	}
528 }
529