Scroll.cs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. namespace Terminal.Gui;
  2. /// <summary>
  3. /// Provides a proportional control for scrolling through content. Used within a <see cref="ScrollBar"/>.
  4. /// </summary>
  5. public class Scroll : View
  6. {
  7. /// <inheritdoc/>
  8. public Scroll ()
  9. {
  10. WantContinuousButtonPressed = true;
  11. CanFocus = false;
  12. Orientation = Orientation.Vertical;
  13. Width = Dim.Auto (DimAutoStyle.Content, 1);
  14. Height = Dim.Auto (DimAutoStyle.Content, 1);
  15. _slider = new ()
  16. {
  17. Id = "slider",
  18. Width = Dim.Auto (DimAutoStyle.Content),
  19. Height = Dim.Auto (DimAutoStyle.Content),
  20. WantMousePositionReports = true
  21. };
  22. Add (_slider);
  23. Added += Scroll_Added;
  24. Removed += Scroll_Removed;
  25. Initialized += Scroll_Initialized;
  26. MouseEvent += Scroll_MouseEvent;
  27. _slider.MouseEvent += Slider_MouseEvent;
  28. _slider.MouseEnter += Slider_MouseEnter;
  29. _slider.MouseLeave += Slider_MouseLeave;
  30. }
  31. private readonly View _slider;
  32. private int _lastLocation = -1;
  33. private bool _wasSliderMouse;
  34. private int _barSize => Orientation == Orientation.Vertical ? GetContentSize ().Height : GetContentSize ().Width;
  35. private Orientation _orientation;
  36. /// <summary>
  37. /// Gets or sets if the Scroll is oriented vertically or horizontally.
  38. /// </summary>
  39. public Orientation Orientation
  40. {
  41. get => _orientation;
  42. set
  43. {
  44. _orientation = value;
  45. AdjustSlider();
  46. }
  47. }
  48. private int _position;
  49. /// <summary>
  50. /// Gets or sets the position of the start of the Scroll slider, relative to <see cref="Size"/>.
  51. /// </summary>
  52. public int Position
  53. {
  54. get => _position;
  55. set
  56. {
  57. if (value == _position || value < 0 || value + _barSize > Size)
  58. {
  59. return;
  60. }
  61. StateEventArgs<int> args = OnPositionChanging (_position, value);
  62. if (args.Cancel)
  63. {
  64. return;
  65. }
  66. if (!_wasSliderMouse)
  67. {
  68. AdjustSlider ();
  69. }
  70. int oldPos = _position;
  71. _position = value;
  72. OnPositionChanged (oldPos);
  73. }
  74. }
  75. /// <summary>Raised when the <see cref="Position"/> has changed.</summary>
  76. public event EventHandler<StateEventArgs<int>> PositionChanged;
  77. /// <summary>Raised when the <see cref="Position"/> is changing. Set <see cref="StateEventArgs{T}.Cancel"/> to <see langword="true"/> to prevent the position from being changed.</summary>
  78. public event EventHandler<StateEventArgs<int>> PositionChanging;
  79. /// <summary>Virtual method called when <see cref="Position"/> has changed. Fires <see cref="PositionChanged"/>.</summary>
  80. protected virtual void OnPositionChanged (int oldPos) { PositionChanged?.Invoke (this, new (oldPos, Position)); }
  81. /// <summary>Virtual method called when <see cref="Position"/> is changing. Fires <see cref="PositionChanging"/>, which is cancelable.</summary>
  82. protected virtual StateEventArgs<int> OnPositionChanging (int oldPos, int newPos)
  83. {
  84. StateEventArgs<int> args = new (oldPos, newPos);
  85. PositionChanging?.Invoke (this, args);
  86. return args;
  87. }
  88. private int _size;
  89. /// <summary>
  90. /// Gets or sets the size of the Scroll. This is the total size of the content that can be scrolled through.
  91. /// </summary>
  92. public int Size
  93. {
  94. get => _size;
  95. set
  96. {
  97. int oldSize = _size;
  98. _size = value;
  99. OnSizeChanged (oldSize);
  100. AdjustSlider ();
  101. }
  102. }
  103. /// <summary>Raised when <see cref="Size"/> has changed.</summary>
  104. public event EventHandler<StateEventArgs<int>> SizeChanged;
  105. /// <summary>Virtual method called when <see cref="Size"/> has changed. Fires <see cref="SizeChanged"/>.</summary>
  106. protected void OnSizeChanged (int oldSize) { SizeChanged?.Invoke (this, new (oldSize, Size)); }
  107. private int GetPositionFromSliderLocation (int location)
  108. {
  109. if (GetContentSize ().Height == 0 || GetContentSize ().Width == 0)
  110. {
  111. return 0;
  112. }
  113. int scrollSize = Orientation == Orientation.Vertical ? GetContentSize ().Height : GetContentSize ().Width;
  114. // Ensure the Position is valid if the slider is at end
  115. // We use Frame here instead of ContentSize because even if the slider has a margin or border, Frame indicates the actual size
  116. if ((Orientation == Orientation.Vertical && location + _slider.Frame.Height == scrollSize)
  117. || (Orientation == Orientation.Horizontal && location + _slider.Frame.Width == scrollSize))
  118. {
  119. return Size - scrollSize;
  120. }
  121. return Math.Min (location * Size / scrollSize, Size - scrollSize);
  122. }
  123. // QUESTION: This method is only called from one place. Should it be inlined? Or, should it be made internal and unit tests be provided?
  124. private (int Location, int Dimension) GetSliderLocationDimensionFromPosition ()
  125. {
  126. if (GetContentSize ().Height == 0 || GetContentSize ().Width == 0)
  127. {
  128. return new (0, 0);
  129. }
  130. int scrollSize = Orientation == Orientation.Vertical ? GetContentSize ().Height : GetContentSize ().Width;
  131. int location;
  132. int dimension;
  133. if (Size > 0)
  134. {
  135. dimension = Math.Min (Math.Max (scrollSize * scrollSize / Size, 1), scrollSize);
  136. // Ensure the Position is valid
  137. if (Position > 0 && Position + scrollSize > Size)
  138. {
  139. Position = Size - scrollSize;
  140. }
  141. location = Math.Min (Position * scrollSize / Size, scrollSize - dimension);
  142. if (Position == Size - scrollSize && location + dimension < scrollSize)
  143. {
  144. location = scrollSize - dimension;
  145. }
  146. }
  147. else
  148. {
  149. location = 0;
  150. dimension = scrollSize;
  151. }
  152. return new (location, dimension);
  153. }
  154. // TODO: This is unnecessary. If Scroll.Width/Height is Dim.Auto, the Superview will get resized automatically.
  155. private void SuperView_LayoutComplete (object sender, LayoutEventArgs e)
  156. {
  157. if (!_wasSliderMouse)
  158. {
  159. AdjustSlider ();
  160. }
  161. else
  162. {
  163. _wasSliderMouse = false;
  164. }
  165. }
  166. private void SuperView_MouseEnter (object sender, MouseEventEventArgs e) { OnMouseEnter (e.MouseEvent); }
  167. private void SuperView_MouseLeave (object sender, MouseEventEventArgs e) { OnMouseLeave (e.MouseEvent); }
  168. private void Scroll_Added (object sender, SuperViewChangedEventArgs e)
  169. {
  170. View parent = e.SuperView is Adornment adornment ? adornment.Parent : e.SuperView;
  171. parent.LayoutComplete += SuperView_LayoutComplete;
  172. // QUESTION: I really don't like this. It feels like a hack that a subview needs to track its parent's mouse events.
  173. // QUESTION: Can we figure out a way to do this without tracking the parent's mouse events?
  174. parent.MouseEnter += SuperView_MouseEnter;
  175. parent.MouseLeave += SuperView_MouseLeave;
  176. _slider.ColorScheme = new () { Normal = new (parent.ColorScheme.HotNormal.Foreground, parent.ColorScheme.HotNormal.Foreground) };
  177. }
  178. // TODO: Just override GetNormalColor instead of having this method (make Slider a View sub-class that overrides GetNormalColor)
  179. private void Scroll_Initialized (object sender, EventArgs e)
  180. {
  181. AdjustSlider ();
  182. }
  183. // TODO: I think you should create a new `internal` view named "ScrollSlider" with an `Orientation` property. It should inherit from View and override GetNormalColor and the mouse events
  184. // that can be moved within it's Superview, constrained to move only horizontally or vertically depending on Orientation.
  185. // This will really simplify a lot of this.
  186. private void Scroll_MouseEvent (object sender, MouseEventEventArgs e)
  187. {
  188. MouseEvent me = e.MouseEvent;
  189. int location = Orientation == Orientation.Vertical ? me.Position.Y : me.Position.X;
  190. (int topLeft, int bottomRight) sliderPos = _orientation == Orientation.Vertical
  191. ? new (_slider.Frame.Y, _slider.Frame.Bottom - 1)
  192. : new (_slider.Frame.X, _slider.Frame.Right - 1);
  193. if (me.Flags == MouseFlags.Button1Pressed && location < sliderPos.topLeft)
  194. {
  195. Position = Math.Max (Position - _barSize, 0);
  196. }
  197. else if (me.Flags == MouseFlags.Button1Pressed && location > sliderPos.bottomRight)
  198. {
  199. Position = Math.Min (Position + _barSize, Size - _barSize);
  200. }
  201. else if ((me.Flags == MouseFlags.WheeledDown && Orientation == Orientation.Vertical)
  202. || (me.Flags == MouseFlags.WheeledRight && Orientation == Orientation.Horizontal))
  203. {
  204. Position = Math.Min (Position + 1, Size - _barSize);
  205. }
  206. else if ((me.Flags == MouseFlags.WheeledUp && Orientation == Orientation.Vertical)
  207. || (me.Flags == MouseFlags.WheeledLeft && Orientation == Orientation.Horizontal))
  208. {
  209. Position = Math.Max (Position - 1, 0);
  210. }
  211. else if (me.Flags == MouseFlags.Button1Clicked)
  212. {
  213. if (_slider.Frame.Contains (me.Position))
  214. {
  215. Slider_MouseEnter (_slider, e);
  216. }
  217. }
  218. }
  219. private void Scroll_Removed (object sender, SuperViewChangedEventArgs e)
  220. {
  221. if (e.SuperView is { })
  222. {
  223. View parent = e.SuperView is Adornment adornment ? adornment.Parent : e.SuperView;
  224. parent.LayoutComplete -= SuperView_LayoutComplete;
  225. parent.MouseEnter -= SuperView_MouseEnter;
  226. parent.MouseLeave -= SuperView_MouseLeave;
  227. }
  228. }
  229. private void SetSliderText ()
  230. {
  231. TextDirection = Orientation == Orientation.Vertical ? TextDirection.TopBottom_LeftRight : TextDirection.LeftRight_TopBottom;
  232. // QUESTION: Should these Glyphs be configurable via CM?
  233. Text = string.Concat (
  234. Enumerable.Repeat (
  235. Glyphs.Stipple.ToString (),
  236. GetContentSize ().Width * GetContentSize ().Height));
  237. _slider.TextDirection = Orientation == Orientation.Vertical ? TextDirection.TopBottom_LeftRight : TextDirection.LeftRight_TopBottom;
  238. _slider.Text = string.Concat (
  239. Enumerable.Repeat (
  240. Glyphs.ContinuousMeterSegment.ToString (),
  241. _slider.GetContentSize ().Width * _slider.GetContentSize ().Height));
  242. }
  243. private void AdjustSlider ()
  244. {
  245. if (!IsInitialized)
  246. {
  247. return;
  248. }
  249. (int Location, int Dimension) slider = GetSliderLocationDimensionFromPosition ();
  250. _slider.X = Orientation == Orientation.Vertical ? 0 : slider.Location;
  251. _slider.Y = Orientation == Orientation.Vertical ? slider.Location : 0;
  252. _slider.SetContentSize (
  253. new (
  254. Orientation == Orientation.Vertical ? GetContentSize ().Width : slider.Dimension,
  255. Orientation == Orientation.Vertical ? slider.Dimension : GetContentSize ().Height
  256. ));
  257. SetSliderText ();
  258. }
  259. // TODO: Move this into "ScrollSlider" and override it there. Scroll can then subscribe to _slider.LayoutComplete and call AdjustSlider.
  260. // QUESTION: I've been meaning to add a "View.FrameChanged" event (fired from LayoutComplete only if Frame has changed). Should we do that as part of this PR?
  261. // QUESTION: Note I *did* add "View.ViewportChanged" in a previous PR.
  262. private void Slider_MouseEvent (object sender, MouseEventEventArgs e)
  263. {
  264. MouseEvent me = e.MouseEvent;
  265. int location = Orientation == Orientation.Vertical ? me.Position.Y : me.Position.X;
  266. int offset = _lastLocation > -1 ? location - _lastLocation : 0;
  267. if (me.Flags == MouseFlags.Button1Pressed)
  268. {
  269. if (Application.MouseGrabView != sender as View)
  270. {
  271. Application.GrabMouse (sender as View);
  272. _lastLocation = location;
  273. }
  274. }
  275. else if (me.Flags == (MouseFlags.Button1Pressed | MouseFlags.ReportMousePosition))
  276. {
  277. if (Orientation == Orientation.Vertical)
  278. {
  279. if (_slider.Frame.Y + offset >= 0 && _slider.Frame.Y + offset + _slider.Frame.Height <= _barSize)
  280. {
  281. _wasSliderMouse = true;
  282. _slider.Y = _slider.Frame.Y + offset;
  283. Position = GetPositionFromSliderLocation (_slider.Frame.Y);
  284. }
  285. }
  286. else
  287. {
  288. if (_slider.Frame.X + offset >= 0 && _slider.Frame.X + offset + _slider.Frame.Width <= _barSize)
  289. {
  290. _wasSliderMouse = true;
  291. _slider.X = _slider.Frame.X + offset;
  292. Position = GetPositionFromSliderLocation (_slider.Frame.X);
  293. }
  294. }
  295. }
  296. else if (me.Flags == MouseFlags.Button1Released)
  297. {
  298. _lastLocation = -1;
  299. if (Application.MouseGrabView == sender as View)
  300. {
  301. Application.UngrabMouse ();
  302. }
  303. }
  304. else if ((me.Flags == MouseFlags.WheeledDown && Orientation == Orientation.Vertical)
  305. || (me.Flags == MouseFlags.WheeledRight && Orientation == Orientation.Horizontal))
  306. {
  307. Position = Math.Min (Position + 1, Size - _barSize);
  308. }
  309. else if ((me.Flags == MouseFlags.WheeledUp && Orientation == Orientation.Vertical)
  310. || (me.Flags == MouseFlags.WheeledLeft && Orientation == Orientation.Horizontal))
  311. {
  312. Position = Math.Max (Position - 1, 0);
  313. }
  314. else if (me.Flags != MouseFlags.ReportMousePosition)
  315. {
  316. return;
  317. }
  318. e.Handled = true;
  319. }
  320. [CanBeNull]
  321. private ColorScheme _savedColorScheme;
  322. private void Slider_MouseEnter (object sender, MouseEventEventArgs e)
  323. {
  324. _savedColorScheme ??= _slider.ColorScheme;
  325. _slider.ColorScheme = new () { Normal = new (_savedColorScheme.HotNormal.Foreground, _savedColorScheme.HotNormal.Foreground) };
  326. }
  327. private void Slider_MouseLeave (object sender, MouseEventEventArgs e)
  328. {
  329. if (_savedColorScheme is { } && !e.MouseEvent.Flags.HasFlag (MouseFlags.Button1Pressed))
  330. {
  331. _slider.ColorScheme = _savedColorScheme;
  332. _savedColorScheme = null;
  333. }
  334. }
  335. /// <inheritdoc/>
  336. protected override void Dispose (bool disposing)
  337. {
  338. Added -= Scroll_Added;
  339. Initialized -= Scroll_Initialized;
  340. MouseEvent -= Scroll_MouseEvent;
  341. _slider.MouseEvent -= Slider_MouseEvent;
  342. _slider.MouseEnter -= Slider_MouseEnter;
  343. _slider.MouseLeave -= Slider_MouseLeave;
  344. base.Dispose (disposing);
  345. }
  346. }