SplitContainer.cs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  1. using System;
  2. using Terminal.Gui.Graphs;
  3. namespace Terminal.Gui {
  4. /// <summary>
  5. /// A <see cref="View"/> consisting of a moveable bar that divides
  6. /// the display area into 2 resizeable panels.
  7. /// </summary>
  8. public class SplitContainer : View {
  9. private LineView splitterLine;
  10. private bool panel1Collapsed;
  11. private bool panel2Collapsed;
  12. private Pos splitterDistance = Pos.Percent (50);
  13. private Orientation orientation = Orientation.Vertical;
  14. private Pos panel1MinSize = 0;
  15. private Pos panel2MinSize = 0;
  16. /// <summary>
  17. /// Creates a new instance of the SplitContainer class.
  18. /// </summary>
  19. public SplitContainer ()
  20. {
  21. splitterLine = new SplitContainerLineView (this);
  22. this.Add (Panel1);
  23. this.Add (splitterLine);
  24. this.Add (Panel2);
  25. Setup ();
  26. CanFocus = false;
  27. }
  28. /// <summary>
  29. /// The left or top panel of the <see cref="SplitContainer"/>
  30. /// (depending on <see cref="Orientation"/>). Add panel contents
  31. /// to this <see cref="View"/> using <see cref="View.Add(View)"/>.
  32. /// </summary>
  33. public View Panel1 { get; } = new View ();
  34. /// <summary>
  35. /// The minimum size <see cref="Panel1"/> can be when adjusting
  36. /// <see cref="SplitterDistance"/>.
  37. /// </summary>
  38. public Pos Panel1MinSize {
  39. get { return panel1MinSize; }
  40. set {
  41. panel1MinSize = value;
  42. Setup ();
  43. }
  44. }
  45. /// <summary>
  46. /// Invoked when the <see cref="SplitterDistance"/> is changed
  47. /// </summary>
  48. public event SplitterEventHandler SplitterMoved;
  49. /// <summary>
  50. /// Raises the <see cref="SplitterMoved"/> event
  51. /// </summary>
  52. protected virtual void OnSplitterMoved ()
  53. {
  54. SplitterMoved?.Invoke (this, new SplitterEventArgs (this, splitterDistance));
  55. }
  56. /// <summary>
  57. /// This determines if <see cref="Panel1"/> is collapsed.
  58. /// </summary>
  59. public bool Panel1Collapsed {
  60. get { return panel1Collapsed; }
  61. set {
  62. panel1Collapsed = value;
  63. if (value && panel2Collapsed) {
  64. panel2Collapsed = false;
  65. }
  66. Setup ();
  67. }
  68. }
  69. /// <summary>
  70. /// The right or bottom panel of the <see cref="SplitContainer"/>
  71. /// (depending on <see cref="Orientation"/>). Add panel contents
  72. /// to this <see cref="View"/> using <see cref="View.Add(View)"/>
  73. /// </summary>
  74. public View Panel2 { get; } = new View ();
  75. /// <summary>
  76. /// The minimum size <see cref="Panel2"/> can be when adjusting
  77. /// <see cref="SplitterDistance"/>.
  78. /// </summary>
  79. public Pos Panel2MinSize {
  80. get {
  81. return panel2MinSize;
  82. }
  83. set {
  84. panel2MinSize = value;
  85. Setup ();
  86. }
  87. }
  88. /// <summary>
  89. /// This determines if <see cref="Panel2"/> is collapsed.
  90. /// </summary>
  91. public bool Panel2Collapsed {
  92. get { return panel2Collapsed; }
  93. set {
  94. panel2Collapsed = value;
  95. if (value && panel1Collapsed) {
  96. panel1Collapsed = false;
  97. }
  98. Setup ();
  99. }
  100. }
  101. /// <summary>
  102. /// Orientation of the dividing line (Horizontal or Vertical).
  103. /// </summary>
  104. public Orientation Orientation {
  105. get { return orientation; }
  106. set {
  107. orientation = value;
  108. Setup ();
  109. }
  110. }
  111. /// <summary>
  112. /// <para>Distance Horizontally or Vertically to the splitter line when
  113. /// neither panel is collapsed.
  114. /// </para>
  115. /// <para>Only absolute values (e.g. 10) and percent values (i.e. <see cref="Pos.Percent(float)"/>)
  116. /// are supported for this property.</para>
  117. /// </summary>
  118. public Pos SplitterDistance {
  119. get { return splitterDistance; }
  120. set {
  121. if (!(value is Pos.PosAbsolute) && !(value is Pos.PosFactor)) {
  122. throw new ArgumentException ($"Only Percent and Absolute values are supported for {nameof (SplitterDistance)} property. Passed value was {value.GetType ().Name}");
  123. }
  124. splitterDistance = value;
  125. Setup ();
  126. OnSplitterMoved ();
  127. }
  128. }
  129. /// <inheritdoc/>
  130. public override bool OnEnter (View view)
  131. {
  132. Driver.SetCursorVisibility (CursorVisibility.Invisible);
  133. return base.OnEnter (view);
  134. }
  135. /// <inheritdoc/>
  136. public override void Redraw (Rect bounds)
  137. {
  138. Driver.SetAttribute (ColorScheme.Normal);
  139. Clear ();
  140. base.Redraw (bounds);
  141. }
  142. private void Setup ()
  143. {
  144. splitterLine.Orientation = Orientation;
  145. if (panel1Collapsed || panel2Collapsed) {
  146. SetupForCollapsedPanel ();
  147. } else {
  148. SetupForNormal ();
  149. }
  150. }
  151. private void SetupForNormal ()
  152. {
  153. // Ensure all our component views are here
  154. // (e.g. if we are transitioning from a collapsed state)
  155. if (!this.Subviews.Contains (splitterLine)) {
  156. this.Add (splitterLine);
  157. }
  158. if (!this.Subviews.Contains (Panel1)) {
  159. this.Add (Panel1);
  160. }
  161. if (!this.Subviews.Contains (Panel2)) {
  162. this.Add (Panel2);
  163. }
  164. splitterDistance = BoundByMinimumSizes (splitterDistance);
  165. switch (Orientation) {
  166. case Orientation.Horizontal:
  167. splitterLine.X = 0;
  168. splitterLine.Y = splitterDistance;
  169. splitterLine.Width = Dim.Fill ();
  170. splitterLine.Height = 1;
  171. splitterLine.LineRune = Driver.HLine;
  172. this.Panel1.X = 0;
  173. this.Panel1.Y = 0;
  174. this.Panel1.Width = Dim.Fill ();
  175. this.Panel1.Height = new Dim.DimFunc (() =>
  176. splitterDistance.Anchor (Bounds.Height));
  177. this.Panel2.Y = Pos.Bottom (splitterLine);
  178. this.Panel2.X = 0;
  179. this.Panel2.Width = Dim.Fill ();
  180. this.Panel2.Height = Dim.Fill ();
  181. break;
  182. case Orientation.Vertical:
  183. splitterLine.X = splitterDistance;
  184. splitterLine.Y = 0;
  185. splitterLine.Width = 1;
  186. splitterLine.Height = Dim.Fill ();
  187. splitterLine.LineRune = Driver.VLine;
  188. this.Panel1.X = 0;
  189. this.Panel1.Y = 0;
  190. this.Panel1.Height = Dim.Fill ();
  191. this.Panel1.Width = new Dim.DimFunc (() =>
  192. splitterDistance.Anchor (Bounds.Width));
  193. this.Panel2.X = Pos.Right (splitterLine);
  194. this.Panel2.Y = 0;
  195. this.Panel2.Width = Dim.Fill ();
  196. this.Panel2.Height = Dim.Fill ();
  197. break;
  198. default: throw new ArgumentOutOfRangeException (nameof (orientation));
  199. };
  200. this.LayoutSubviews ();
  201. }
  202. /// <summary>
  203. /// Considers <paramref name="pos"/> as a candidate for <see cref="splitterDistance"/>
  204. /// then either returns (if valid) or returns adjusted if invalid with respect to
  205. /// <see cref="Panel1MinSize"/> or <see cref="Panel2MinSize"/>.
  206. /// </summary>
  207. /// <param name="pos"></param>
  208. /// <returns></returns>
  209. private Pos BoundByMinimumSizes (Pos pos)
  210. {
  211. // if we are not yet initialized then we don't know
  212. // how big we are and therefore cannot sensibly calculate
  213. // how big the panels will be with a given SplitterDistance
  214. if (!IsInitialized) {
  215. return pos;
  216. }
  217. var availableSpace = Orientation == Orientation.Horizontal ? this.Bounds.Height : this.Bounds.Width;
  218. var idealPosition = pos.Anchor (availableSpace);
  219. var panel1MinSizeAbs = panel1MinSize.Anchor (availableSpace);
  220. var panel2MinSizeAbs = panel2MinSize.Anchor (availableSpace);
  221. // bad position because not enough space for panel1
  222. if (idealPosition < panel1MinSizeAbs) {
  223. // TODO: we should preserve Absolute/Percent status here not just force it to absolute
  224. return (Pos)Math.Min (panel1MinSizeAbs, availableSpace);
  225. }
  226. // bad position because not enough space for panel2
  227. if (availableSpace - idealPosition <= panel2MinSizeAbs) {
  228. // TODO: we should preserve Absolute/Percent status here not just force it to absolute
  229. // +1 is to allow space for the splitter
  230. return (Pos)Math.Max (availableSpace - (panel2MinSizeAbs + 1), 0);
  231. }
  232. // this splitter position is fine, there is enough space for everyone
  233. return pos;
  234. }
  235. private void SetupForCollapsedPanel ()
  236. {
  237. View toRemove = panel1Collapsed ? Panel1 : Panel2;
  238. View toFullSize = panel1Collapsed ? Panel2 : Panel1;
  239. if (this.Subviews.Contains (splitterLine)) {
  240. this.Remove (splitterLine);
  241. }
  242. if (this.Subviews.Contains (toRemove)) {
  243. this.Remove (toRemove);
  244. }
  245. if (!this.Subviews.Contains (toFullSize)) {
  246. this.Add (toFullSize);
  247. }
  248. toFullSize.X = 0;
  249. toFullSize.Y = 0;
  250. toFullSize.Width = Dim.Fill ();
  251. toFullSize.Height = Dim.Fill ();
  252. }
  253. private class SplitContainerLineView : LineView {
  254. private SplitContainer parent;
  255. Point? dragPosition;
  256. Pos dragOrignalPos;
  257. Point? moveRuneRenderLocation;
  258. // TODO: Make focusable and allow moving with keyboard
  259. public SplitContainerLineView (SplitContainer parent)
  260. {
  261. CanFocus = true;
  262. TabStop = true;
  263. this.parent = parent;
  264. base.AddCommand (Command.Right, () => {
  265. return MoveSplitter (1, 0);
  266. });
  267. base.AddCommand (Command.Left, () => {
  268. return MoveSplitter (-1, 0);
  269. });
  270. base.AddCommand (Command.LineUp, () => {
  271. return MoveSplitter (0, -1);
  272. });
  273. base.AddCommand (Command.LineDown, () => {
  274. return MoveSplitter (0, 1);
  275. });
  276. AddKeyBinding (Key.CursorRight, Command.Right);
  277. AddKeyBinding (Key.CursorLeft, Command.Left);
  278. AddKeyBinding (Key.CursorUp, Command.LineUp);
  279. AddKeyBinding (Key.CursorDown, Command.LineDown);
  280. }
  281. ///<inheritdoc/>
  282. public override bool ProcessKey (KeyEvent kb)
  283. {
  284. if (!CanFocus || !HasFocus) {
  285. return base.ProcessKey (kb);
  286. }
  287. var result = InvokeKeybindings (kb);
  288. if (result != null)
  289. return (bool)result;
  290. return base.ProcessKey (kb);
  291. }
  292. public override void PositionCursor ()
  293. {
  294. base.PositionCursor ();
  295. Move (this.Bounds.Width / 2, this.Bounds.Height / 2);
  296. }
  297. public override bool OnEnter (View view)
  298. {
  299. Driver.SetCursorVisibility (CursorVisibility.Default);
  300. PositionCursor ();
  301. return base.OnEnter (view);
  302. }
  303. public override void Redraw (Rect bounds)
  304. {
  305. base.Redraw (bounds);
  306. if (CanFocus && HasFocus) {
  307. var location = moveRuneRenderLocation ??
  308. new Point (Bounds.Width / 2, Bounds.Height / 2);
  309. AddRune (location.X, location.Y, Driver.Diamond);
  310. }
  311. }
  312. ///<inheritdoc/>
  313. public override bool MouseEvent (MouseEvent mouseEvent)
  314. {
  315. if (!CanFocus) {
  316. return true;
  317. }
  318. if (!dragPosition.HasValue && (mouseEvent.Flags == MouseFlags.Button1Pressed)) {
  319. // Start a Drag
  320. SetFocus ();
  321. Application.EnsuresTopOnFront ();
  322. if (mouseEvent.Flags == MouseFlags.Button1Pressed) {
  323. dragPosition = new Point (mouseEvent.X, mouseEvent.Y);
  324. dragOrignalPos = Orientation == Orientation.Horizontal ? Y : X;
  325. Application.GrabMouse (this);
  326. }
  327. return true;
  328. } else if (
  329. dragPosition.HasValue &&
  330. (mouseEvent.Flags == (MouseFlags.Button1Pressed | MouseFlags.ReportMousePosition))) {
  331. // Continue Drag
  332. // how far has user dragged from original location?
  333. if (Orientation == Orientation.Horizontal) {
  334. int dy = mouseEvent.Y - dragPosition.Value.Y;
  335. parent.SplitterDistance = Offset (Y, dy);
  336. moveRuneRenderLocation = new Point (mouseEvent.X, 0);
  337. } else {
  338. int dx = mouseEvent.X - dragPosition.Value.X;
  339. parent.SplitterDistance = Offset (X, dx);
  340. moveRuneRenderLocation = new Point (0, mouseEvent.Y);
  341. }
  342. parent.SetNeedsDisplay ();
  343. return true;
  344. }
  345. if (mouseEvent.Flags.HasFlag (MouseFlags.Button1Released) && dragPosition.HasValue) {
  346. // End Drag
  347. Application.UngrabMouse ();
  348. Driver.UncookMouse ();
  349. FinalisePosition (
  350. dragOrignalPos,
  351. Orientation == Orientation.Horizontal ? Y : X);
  352. dragPosition = null;
  353. moveRuneRenderLocation = null;
  354. }
  355. return false;
  356. }
  357. private bool MoveSplitter (int distanceX, int distanceY)
  358. {
  359. if (Orientation == Orientation.Vertical) {
  360. // Cannot move in this direction
  361. if (distanceX == 0) {
  362. return false;
  363. }
  364. var oldX = X;
  365. FinalisePosition (oldX, (Pos)Offset (X, distanceX));
  366. return true;
  367. } else {
  368. // Cannot move in this direction
  369. if (distanceY == 0) {
  370. return false;
  371. }
  372. var oldY = Y;
  373. FinalisePosition (oldY, (Pos)Offset (Y, distanceY));
  374. return true;
  375. }
  376. }
  377. private Pos Offset (Pos pos, int delta)
  378. {
  379. var posAbsolute = pos.Anchor (Orientation == Orientation.Horizontal ?
  380. parent.Bounds.Height : parent.Bounds.Width);
  381. return posAbsolute + delta;
  382. }
  383. /// <summary>
  384. /// <para>
  385. /// Moves <see cref="parent"/> <see cref="SplitContainer.SplitterDistance"/> to
  386. /// <see cref="Pos"/> <paramref name="newValue"/> preserving <see cref="Pos"/> format
  387. /// (absolute / relative) that <paramref name="oldValue"/> had.
  388. /// </para>
  389. /// <remarks>This ensures that if splitter location was e.g. 50% before and you move it
  390. /// to absolute 5 then you end up with 10% (assuming a parent had 50 width). </remarks>
  391. /// </summary>
  392. /// <param name="oldValue"></param>
  393. /// <param name="newValue"></param>
  394. private void FinalisePosition (Pos oldValue, Pos newValue)
  395. {
  396. if (oldValue is Pos.PosFactor) {
  397. if (Orientation == Orientation.Horizontal) {
  398. parent.SplitterDistance = ConvertToPosFactor (newValue, parent.Bounds.Height);
  399. } else {
  400. parent.SplitterDistance = ConvertToPosFactor (newValue, parent.Bounds.Width);
  401. }
  402. } else {
  403. parent.SplitterDistance = newValue;
  404. }
  405. }
  406. /// <summary>
  407. /// <para>
  408. /// Determines the absolute position of <paramref name="p"/> and
  409. /// returns a <see cref="Pos.PosFactor"/> that describes the percentage of that.
  410. /// </para>
  411. /// <para>Effectively turning any <see cref="Pos"/> into a <see cref="Pos.PosFactor"/>
  412. /// (as if created with <see cref="Pos.Percent(float)"/>)</para>
  413. /// </summary>
  414. /// <param name="p">The <see cref="Pos"/> to convert to <see cref="Pos.Percent(float)"/></param>
  415. /// <param name="parentLength">The Height/Width that <paramref name="p"/> lies within</param>
  416. /// <returns></returns>
  417. private Pos ConvertToPosFactor (Pos p, int parentLength)
  418. {
  419. // calculate position in the 'middle' of the cell at p distance along parentLength
  420. float position = p.Anchor (parentLength) + 0.5f;
  421. return new Pos.PosFactor (position / parentLength);
  422. }
  423. }
  424. }
  425. /// <summary>
  426. /// Provides data for <see cref="SplitContainer"/> events.
  427. /// </summary>
  428. public class SplitterEventArgs : EventArgs {
  429. /// <summary>
  430. /// Creates a new instance of the <see cref="SplitterEventArgs"/> class.
  431. /// </summary>
  432. /// <param name="splitContainer"></param>
  433. /// <param name="splitterDistance"></param>
  434. public SplitterEventArgs (SplitContainer splitContainer, Pos splitterDistance)
  435. {
  436. SplitterDistance = splitterDistance;
  437. SplitContainer = splitContainer;
  438. }
  439. /// <summary>
  440. /// New position of the <see cref="SplitContainer.SplitterDistance"/>
  441. /// </summary>
  442. public Pos SplitterDistance { get; }
  443. /// <summary>
  444. /// Container (sender) of the event.
  445. /// </summary>
  446. public SplitContainer SplitContainer { get; }
  447. }
  448. /// <summary>
  449. /// Represents a method that will handle splitter events.
  450. /// </summary>
  451. public delegate void SplitterEventHandler (object sender, SplitterEventArgs e);
  452. }