TableView.cs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555
  1. using NStack;
  2. using System;
  3. using System.Collections.Generic;
  4. using System.Data;
  5. using System.Linq;
  6. namespace Terminal.Gui.Views {
  7. /// <summary>
  8. /// Defines rendering options that affect how the table is displayed
  9. /// </summary>
  10. public class TableStyle {
  11. /// <summary>
  12. /// When scrolling down always lock the column headers in place as the first row of the table
  13. /// </summary>
  14. public bool AlwaysShowHeaders {get;set;} = false;
  15. /// <summary>
  16. /// True to render a solid line above the headers
  17. /// </summary>
  18. public bool ShowHorizontalHeaderOverline {get;set;} = true;
  19. /// <summary>
  20. /// True to render a solid line under the headers
  21. /// </summary>
  22. public bool ShowHorizontalHeaderUnderline {get;set;} = true;
  23. /// <summary>
  24. /// True to render a solid line vertical line between cells
  25. /// </summary>
  26. public bool ShowVerticalCellLines {get;set;} = true;
  27. /// <summary>
  28. /// True to render a solid line vertical line between headers
  29. /// </summary>
  30. public bool ShowVerticalHeaderLines {get;set;} = true;
  31. }
  32. /// <summary>
  33. /// View for tabular data based on a <see cref="DataTable"/>
  34. /// </summary>
  35. public class TableView : View {
  36. private int columnOffset;
  37. private int rowOffset;
  38. private int selectedRow;
  39. private int selectedColumn;
  40. private DataTable table;
  41. private TableStyle style = new TableStyle();
  42. /// <summary>
  43. /// The data table to render in the view. Setting this property automatically updates and redraws the control.
  44. /// </summary>
  45. public DataTable Table { get => table; set {table = value; Update(); } }
  46. /// <summary>
  47. /// Contains options for changing how the table is rendered
  48. /// </summary>
  49. public TableStyle Style { get => style; set {style = value; Update(); } }
  50. /// <summary>
  51. /// Zero indexed offset for the upper left <see cref="DataColumn"/> to display in <see cref="Table"/>.
  52. /// </summary>
  53. /// <remarks>This property allows very wide tables to be rendered with horizontal scrolling</remarks>
  54. public int ColumnOffset {
  55. get => columnOffset;
  56. //try to prevent this being set to an out of bounds column
  57. set => columnOffset = Table == null ? 0 : Math.Min (Table.Columns.Count - 1, Math.Max (0, value));
  58. }
  59. /// <summary>
  60. /// Zero indexed offset for the <see cref="DataRow"/> to display in <see cref="Table"/> on line 2 of the control (first line being headers)
  61. /// </summary>
  62. /// <remarks>This property allows very wide tables to be rendered with horizontal scrolling</remarks>
  63. public int RowOffset {
  64. get => rowOffset;
  65. set => rowOffset = Table == null ? 0 : Math.Min (Table.Rows.Count - 1, Math.Max (0, value));
  66. }
  67. /// <summary>
  68. /// The index of <see cref="DataTable.Columns"/> in <see cref="Table"/> that the user has currently selected
  69. /// </summary>
  70. public int SelectedColumn {
  71. get => selectedColumn;
  72. //try to prevent this being set to an out of bounds column
  73. set => selectedColumn = Table == null ? 0 : Math.Min (Table.Columns.Count - 1, Math.Max (0, value));
  74. }
  75. /// <summary>
  76. /// The index of <see cref="DataTable.Rows"/> in <see cref="Table"/> that the user has currently selected
  77. /// </summary>
  78. public int SelectedRow {
  79. get => selectedRow;
  80. set => selectedRow = Table == null ? 0 : Math.Min (Table.Rows.Count - 1, Math.Max (0, value));
  81. }
  82. /// <summary>
  83. /// The maximum number of characters to render in any given column. This prevents one long column from pushing out all the others
  84. /// </summary>
  85. public int MaximumCellWidth { get; set; } = 100;
  86. /// <summary>
  87. /// The text representation that should be rendered for cells with the value <see cref="DBNull.Value"/>
  88. /// </summary>
  89. public string NullSymbol { get; set; } = "-";
  90. /// <summary>
  91. /// The symbol to add after each cell value and header value to visually seperate values (if not using vertical gridlines)
  92. /// </summary>
  93. public char SeparatorSymbol { get; set; } = ' ';
  94. /// <summary>
  95. /// Initialzies a <see cref="TableView"/> class using <see cref="LayoutStyle.Computed"/> layout.
  96. /// </summary>
  97. /// <param name="table">The table to display in the control</param>
  98. public TableView (DataTable table) : this ()
  99. {
  100. this.Table = table;
  101. }
  102. /// <summary>
  103. /// Initialzies a <see cref="TableView"/> class using <see cref="LayoutStyle.Computed"/> layout. Set the <see cref="Table"/> property to begin editing
  104. /// </summary>
  105. public TableView () : base ()
  106. {
  107. CanFocus = true;
  108. }
  109. ///<inheritdoc/>
  110. public override void Redraw (Rect bounds)
  111. {
  112. Move (0, 0);
  113. var frame = Frame;
  114. // What columns to render at what X offset in viewport
  115. Dictionary<DataColumn, int> columnsToRender = CalculateViewport (bounds);
  116. Driver.SetAttribute (ColorScheme.Normal);
  117. //invalidate current row (prevents scrolling around leaving old characters in the frame
  118. Driver.AddStr (new string (' ', bounds.Width));
  119. int line = 0;
  120. if(ShouldRenderHeaders()){
  121. // Render something like:
  122. /*
  123. ┌────────────────────┬──────────┬───────────┬──────────────┬─────────┐
  124. │ArithmeticComparator│chi │Healthboard│Interpretation│Labnumber│
  125. └────────────────────┴──────────┴───────────┴──────────────┴─────────┘
  126. */
  127. if(Style.ShowHorizontalHeaderOverline){
  128. RenderHeaderOverline(line,bounds.Width,columnsToRender);
  129. line++;
  130. }
  131. RenderHeaderMidline(line,bounds.Width,columnsToRender);
  132. line++;
  133. if(Style.ShowHorizontalHeaderUnderline){
  134. RenderHeaderUnderline(line,bounds.Width,columnsToRender);
  135. line++;
  136. }
  137. }
  138. //render the cells
  139. for (; line < frame.Height; line++) {
  140. ClearLine(line,bounds.Width);
  141. //work out what Row to render
  142. var rowToRender = RowOffset + (line - GetHeaderHeight());
  143. //if we have run off the end of the table
  144. if ( Table == null || rowToRender >= Table.Rows.Count || rowToRender < 0)
  145. continue;
  146. RenderRow(line,bounds.Width,rowToRender,columnsToRender);
  147. }
  148. }
  149. /// <summary>
  150. /// Clears a line of the console by filling it with spaces
  151. /// </summary>
  152. /// <param name="row"></param>
  153. /// <param name="width"></param>
  154. private void ClearLine(int row, int width)
  155. {
  156. Move (0, row);
  157. Driver.SetAttribute (ColorScheme.Normal);
  158. Driver.AddStr (new string (' ', width));
  159. }
  160. /// <summary>
  161. /// Returns the amount of vertical space required to display the header
  162. /// </summary>
  163. /// <returns></returns>
  164. private int GetHeaderHeight()
  165. {
  166. int heightRequired = 1;
  167. if(Style.ShowHorizontalHeaderOverline)
  168. heightRequired++;
  169. if(Style.ShowHorizontalHeaderUnderline)
  170. heightRequired++;
  171. return heightRequired;
  172. }
  173. private void RenderHeaderOverline(int row,int availableWidth, Dictionary<DataColumn, int> columnsToRender)
  174. {
  175. // Renders a line above table headers (when visible) like:
  176. // ┌────────────────────┬──────────┬───────────┬──────────────┬─────────┐
  177. for(int c = 0;c< availableWidth;c++) {
  178. var rune = Driver.HLine;
  179. if (Style.ShowVerticalHeaderLines){
  180. if(c == 0){
  181. rune = Driver.ULCorner;
  182. }
  183. // if the next column is the start of a header
  184. else if(columnsToRender.Values.Contains(c+1)){
  185. rune = Driver.TopTee;
  186. }
  187. else if(c == availableWidth -1){
  188. rune = Driver.URCorner;
  189. }
  190. }
  191. AddRuneAt(Driver,c,row,rune);
  192. }
  193. }
  194. private void RenderHeaderMidline(int row,int availableWidth, Dictionary<DataColumn, int> columnsToRender)
  195. {
  196. // Renders something like:
  197. // │ArithmeticComparator│chi │Healthboard│Interpretation│Labnumber│
  198. ClearLine(row,availableWidth);
  199. //render start of line
  200. if(style.ShowVerticalHeaderLines)
  201. AddRune(0,row,Driver.VLine);
  202. foreach (var kvp in columnsToRender) {
  203. //where the header should start
  204. var col = kvp.Value;
  205. RenderSeparator(col-1,row,true);
  206. Move (col, row);
  207. Driver.AddStr(Truncate (kvp.Key.ColumnName, availableWidth - kvp.Value));
  208. }
  209. //render end of line
  210. if(style.ShowVerticalHeaderLines)
  211. AddRune(availableWidth-1,row,Driver.VLine);
  212. }
  213. private void RenderHeaderUnderline(int row,int availableWidth, Dictionary<DataColumn, int> columnsToRender)
  214. {
  215. // Renders a line below the table headers (when visible) like:
  216. // ├──────────┼───────────┼───────────────────┼──────────┼────────┼─────────────┤
  217. for(int c = 0;c< availableWidth;c++) {
  218. var rune = Driver.HLine;
  219. if (Style.ShowVerticalHeaderLines){
  220. if(c == 0){
  221. rune = Style.ShowVerticalCellLines ? Driver.LeftTee : Driver.LLCorner;
  222. }
  223. // if the next column is the start of a header
  224. else if(columnsToRender.Values.Contains(c+1)){
  225. /*TODO: is ┼ symbol in Driver?*/
  226. rune = Style.ShowVerticalCellLines ? '┼' :Driver.BottomTee;
  227. }
  228. else if(c == availableWidth -1){
  229. rune = Style.ShowVerticalCellLines ? Driver.RightTee : Driver.LRCorner;
  230. }
  231. }
  232. AddRuneAt(Driver,c,row,rune);
  233. }
  234. }
  235. private void RenderRow(int row, int availableWidth, int rowToRender, Dictionary<DataColumn, int> columnsToRender)
  236. {
  237. //render start of line
  238. if(style.ShowVerticalCellLines)
  239. AddRune(0,row,Driver.VLine);
  240. // Render cells for each visible header for the current row
  241. foreach (var kvp in columnsToRender) {
  242. // move to start of cell (in line with header positions)
  243. Move (kvp.Value, row);
  244. // Set color scheme based on whether the current cell is the selected one
  245. bool isSelectedCell = rowToRender == SelectedRow && kvp.Key.Ordinal == SelectedColumn;
  246. Driver.SetAttribute (isSelectedCell ? ColorScheme.HotFocus : ColorScheme.Normal);
  247. // Render the (possibly truncated) cell value
  248. var valueToRender = GetRenderedVal (Table.Rows [rowToRender] [kvp.Key]);
  249. Driver.AddStr (Truncate (valueToRender, availableWidth - kvp.Value));
  250. // Reset color scheme to normal and render the vertical line (or space) at the end of the cell
  251. Driver.SetAttribute (ColorScheme.Normal);
  252. RenderSeparator(kvp.Value-1,row,false);
  253. }
  254. //render end of line
  255. if(style.ShowVerticalCellLines)
  256. AddRune(availableWidth-1,row,Driver.VLine);
  257. }
  258. private void RenderSeparator(int col, int row,bool isHeader)
  259. {
  260. if(col<0)
  261. return;
  262. var renderLines = isHeader ? style.ShowVerticalHeaderLines : style.ShowVerticalCellLines;
  263. Rune symbol = renderLines ? Driver.VLine : SeparatorSymbol;
  264. AddRune(col,row,symbol);
  265. }
  266. void AddRuneAt (ConsoleDriver d,int col, int row, Rune ch)
  267. {
  268. Move (col, row);
  269. d.AddRune (ch);
  270. }
  271. /// <summary>
  272. /// Truncates <paramref name="valueToRender"/> so that it occupies a maximum of <paramref name="availableHorizontalSpace"/>
  273. /// </summary>
  274. /// <param name="valueToRender"></param>
  275. /// <param name="availableHorizontalSpace"></param>
  276. /// <returns></returns>
  277. private ustring Truncate (string valueToRender, int availableHorizontalSpace)
  278. {
  279. if (string.IsNullOrEmpty (valueToRender) || valueToRender.Length < availableHorizontalSpace)
  280. return valueToRender;
  281. return valueToRender.Substring (0, availableHorizontalSpace);
  282. }
  283. /// <inheritdoc/>
  284. public override bool ProcessKey (KeyEvent keyEvent)
  285. {
  286. switch (keyEvent.Key) {
  287. case Key.CursorLeft:
  288. SelectedColumn--;
  289. Update ();
  290. break;
  291. case Key.CursorRight:
  292. SelectedColumn++;
  293. Update ();
  294. break;
  295. case Key.CursorDown:
  296. SelectedRow++;
  297. Update ();
  298. break;
  299. case Key.CursorUp:
  300. SelectedRow--;
  301. Update ();
  302. break;
  303. case Key.PageUp:
  304. SelectedRow -= Frame.Height;
  305. Update ();
  306. break;
  307. case Key.PageDown:
  308. SelectedRow += Frame.Height;
  309. Update ();
  310. break;
  311. case Key.Home | Key.CtrlMask:
  312. SelectedRow = 0;
  313. SelectedColumn = 0;
  314. Update ();
  315. break;
  316. case Key.Home:
  317. SelectedColumn = 0;
  318. Update ();
  319. break;
  320. case Key.End | Key.CtrlMask:
  321. //jump to end of table
  322. SelectedRow = Table == null ? 0 : Table.Rows.Count - 1;
  323. SelectedColumn = Table == null ? 0 : Table.Columns.Count - 1;
  324. Update ();
  325. break;
  326. case Key.End:
  327. //jump to end of row
  328. SelectedColumn = Table == null ? 0 : Table.Columns.Count - 1;
  329. Update ();
  330. break;
  331. default:
  332. // Not a keystroke we care about
  333. return false;
  334. }
  335. PositionCursor ();
  336. return true;
  337. }
  338. /// <summary>
  339. /// Updates the view to reflect changes to <see cref="Table"/> and to (<see cref="ColumnOffset"/> / <see cref="RowOffset"/>) etc
  340. /// </summary>
  341. /// <remarks>This always calls <see cref="View.SetNeedsDisplay()"/></remarks>
  342. public void Update()
  343. {
  344. if(Table == null) {
  345. SetNeedsDisplay ();
  346. return;
  347. }
  348. //if user opened a large table scrolled down a lot then opened a smaller table (or API deleted a bunch of columns without telling anyone)
  349. ColumnOffset = Math.Max(Math.Min(ColumnOffset,Table.Columns.Count -1),0);
  350. RowOffset = Math.Max(Math.Min(RowOffset,Table.Rows.Count -1),0);
  351. SelectedColumn = Math.Max(Math.Min(SelectedColumn,Table.Columns.Count -1),0);
  352. SelectedRow = Math.Max(Math.Min(SelectedRow,Table.Rows.Count -1),0);
  353. Dictionary<DataColumn, int> columnsToRender = CalculateViewport (Bounds);
  354. var headerHeight = GetHeaderHeight();
  355. //if we have scrolled too far to the left
  356. if (SelectedColumn < columnsToRender.Keys.Min (col => col.Ordinal)) {
  357. ColumnOffset = SelectedColumn;
  358. }
  359. //if we have scrolled too far to the right
  360. if (SelectedColumn > columnsToRender.Keys.Max (col => col.Ordinal)) {
  361. ColumnOffset = SelectedColumn;
  362. }
  363. //if we have scrolled too far down
  364. if (SelectedRow >= RowOffset + (Bounds.Height - headerHeight)) {
  365. RowOffset = SelectedRow;
  366. }
  367. //if we have scrolled too far up
  368. if (SelectedRow < RowOffset) {
  369. RowOffset = SelectedRow;
  370. }
  371. SetNeedsDisplay ();
  372. }
  373. /// <summary>
  374. /// Calculates which columns should be rendered given the <paramref name="bounds"/> in which to display and the <see cref="ColumnOffset"/>
  375. /// </summary>
  376. /// <param name="bounds"></param>
  377. /// <param name="padding"></param>
  378. /// <returns></returns>
  379. private Dictionary<DataColumn, int> CalculateViewport (Rect bounds, int padding = 1)
  380. {
  381. Dictionary<DataColumn, int> toReturn = new Dictionary<DataColumn, int> ();
  382. if(Table == null)
  383. return toReturn;
  384. int usedSpace = 0;
  385. //if horizontal space is required at the start of the line (before the first header)
  386. if(Style.ShowVerticalHeaderLines || Style.ShowVerticalCellLines)
  387. usedSpace+=1;
  388. int availableHorizontalSpace = bounds.Width;
  389. int rowsToRender = bounds.Height;
  390. // reserved for the headers row
  391. if(ShouldRenderHeaders())
  392. rowsToRender -= GetHeaderHeight();
  393. bool first = true;
  394. foreach (var col in Table.Columns.Cast<DataColumn>().Skip (ColumnOffset)) {
  395. int startingIdxForCurrentHeader = usedSpace;
  396. // is there enough space for this column (and it's data)?
  397. usedSpace += CalculateMaxRowSize (col, rowsToRender) + padding;
  398. // no (don't render it) unless its the only column we are render (that must be one massively wide column!)
  399. if (!first && usedSpace > availableHorizontalSpace)
  400. return toReturn;
  401. // there is space
  402. toReturn.Add (col, startingIdxForCurrentHeader);
  403. first=false;
  404. }
  405. return toReturn;
  406. }
  407. private bool ShouldRenderHeaders()
  408. {
  409. if(Table == null || Table.Columns.Count == 0)
  410. return false;
  411. return Style.AlwaysShowHeaders || rowOffset == 0;
  412. }
  413. /// <summary>
  414. /// Returns the maximum of the <paramref name="col"/> name and the maximum length of data that will be rendered starting at <see cref="RowOffset"/> and rendering <paramref name="rowsToRender"/>
  415. /// </summary>
  416. /// <param name="col"></param>
  417. /// <param name="rowsToRender"></param>
  418. /// <returns></returns>
  419. private int CalculateMaxRowSize (DataColumn col, int rowsToRender)
  420. {
  421. int spaceRequired = col.ColumnName.Length;
  422. // if table has no rows
  423. if(RowOffset < 0)
  424. return spaceRequired;
  425. for (int i = RowOffset; i < RowOffset + rowsToRender && i < Table.Rows.Count; i++) {
  426. //expand required space if cell is bigger than the last biggest cell or header
  427. spaceRequired = Math.Max (spaceRequired, GetRenderedVal (Table.Rows [i] [col]).Length);
  428. }
  429. return spaceRequired;
  430. }
  431. /// <summary>
  432. /// Returns the value that should be rendered to best represent a strongly typed <paramref name="value"/> read from <see cref="Table"/>
  433. /// </summary>
  434. /// <param name="value"></param>
  435. /// <returns></returns>
  436. private string GetRenderedVal (object value)
  437. {
  438. if (value == null || value == DBNull.Value) {
  439. return NullSymbol;
  440. }
  441. var representation = value.ToString ();
  442. //if it is too long to fit
  443. if (representation.Length > MaximumCellWidth)
  444. return representation.Substring (0, MaximumCellWidth);
  445. return representation;
  446. }
  447. }
  448. }