diff --git a/Terminal.Gui/Views/TableView.cs b/Terminal.Gui/Views/TableView.cs new file mode 100644 index 000000000..256dafc18 --- /dev/null +++ b/Terminal.Gui/Views/TableView.cs @@ -0,0 +1,1341 @@ +using NStack; +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; + +namespace Terminal.Gui { + + /// + /// Describes how to render a given column in a including and textual representation of cells (e.g. date formats) + /// + public class ColumnStyle { + + /// + /// Defines the default alignment for all values rendered in this column. For custom alignment based on cell contents use . + /// + public TextAlignment Alignment {get;set;} + + /// + /// Defines a delegate for returning custom alignment per cell based on cell values. When specified this will override + /// + public Func AlignmentGetter; + + /// + /// Defines a delegate for returning custom representations of cell values. If not set then is used. Return values from your delegate may be truncated e.g. based on + /// + public Func RepresentationGetter; + + /// + /// Defines the format for values e.g. "yyyy-MM-dd" for dates + /// + public string Format{get;set;} + + /// + /// Set the maximum width of the column in characters. This value will be ignored if more than the tables . Defaults to + /// + public int MaxWidth {get;set;} = TableView.DefaultMaxCellWidth; + + /// + /// Set the minimum width of the column in characters. This value will be ignored if more than the tables or the + /// + public int MinWidth {get;set;} + + /// + /// Returns the alignment for the cell based on and / + /// + /// + /// + public TextAlignment GetAlignment(object cellValue) + { + if(AlignmentGetter != null) + return AlignmentGetter(cellValue); + + return Alignment; + } + + /// + /// Returns the full string to render (which may be truncated if too long) that the current style says best represents the given + /// + /// + /// + public string GetRepresentation (object value) + { + if(!string.IsNullOrWhiteSpace(Format)) { + + if(value is IFormattable f) + return f.ToString(Format,null); + } + + + if(RepresentationGetter != null) + return RepresentationGetter(value); + + return value?.ToString(); + } + } + /// + /// Defines rendering options that affect how the table is displayed + /// + public class TableStyle { + + /// + /// When scrolling down always lock the column headers in place as the first row of the table + /// + public bool AlwaysShowHeaders {get;set;} = false; + + /// + /// True to render a solid line above the headers + /// + public bool ShowHorizontalHeaderOverline {get;set;} = true; + + /// + /// True to render a solid line under the headers + /// + public bool ShowHorizontalHeaderUnderline {get;set;} = true; + + /// + /// True to render a solid line vertical line between cells + /// + public bool ShowVerticalCellLines {get;set;} = true; + + /// + /// True to render a solid line vertical line between headers + /// + public bool ShowVerticalHeaderLines {get;set;} = true; + + /// + /// Collection of columns for which you want special rendering (e.g. custom column lengths, text alignment etc) + /// + public Dictionary ColumnStyles {get;set; } = new Dictionary(); + + /// + /// Returns the entry from for the given or null if no custom styling is defined for it + /// + /// + /// + public ColumnStyle GetColumnStyleIfAny (DataColumn col) + { + return ColumnStyles.TryGetValue(col,out ColumnStyle result) ? result : null; + } + + /// + /// Returns an existing for the given or creates a new one with default options + /// + /// + /// + public ColumnStyle GetOrCreateColumnStyle (DataColumn col) + { + if(!ColumnStyles.ContainsKey(col)) + ColumnStyles.Add(col,new ColumnStyle()); + + return ColumnStyles[col]; + } + } + + /// + /// View for tabular data based on a + /// + public class TableView : View { + + private int columnOffset; + private int rowOffset; + private int selectedRow; + private int selectedColumn; + private DataTable table; + private TableStyle style = new TableStyle(); + + /// + /// The default maximum cell width for and + /// + public const int DefaultMaxCellWidth = 100; + + /// + /// The data table to render in the view. Setting this property automatically updates and redraws the control. + /// + public DataTable Table { get => table; set {table = value; Update(); } } + + /// + /// Contains options for changing how the table is rendered + /// + public TableStyle Style { get => style; set {style = value; Update(); } } + + /// + /// True to select the entire row at once. False to select individual cells. Defaults to false + /// + public bool FullRowSelect {get;set;} + + /// + /// True to allow regions to be selected + /// + /// + public bool MultiSelect {get;set;} = true; + + /// + /// When is enabled this property contain all rectangles of selected cells. Rectangles describe column/rows selected in (not screen coordinates) + /// + /// + public Stack MultiSelectedRegions {get;} = new Stack(); + + /// + /// Horizontal scroll offset. The index of the first column in to display when when rendering the view. + /// + /// This property allows very wide tables to be rendered with horizontal scrolling + public int ColumnOffset { + get => columnOffset; + + //try to prevent this being set to an out of bounds column + set => columnOffset = Table == null ? 0 :Math.Max (0,Math.Min (Table.Columns.Count - 1, value)); + } + + /// + /// Vertical scroll offset. The index of the first row in to display in the first non header line of the control when rendering the view. + /// + public int RowOffset { + get => rowOffset; + set => rowOffset = Table == null ? 0 : Math.Max (0,Math.Min (Table.Rows.Count - 1, value)); + } + + /// + /// The index of in that the user has currently selected + /// + public int SelectedColumn { + get => selectedColumn; + + set { + var oldValue = selectedColumn; + + //try to prevent this being set to an out of bounds column + selectedColumn = Table == null ? 0 : Math.Min (Table.Columns.Count - 1, Math.Max (0, value)); + + if(oldValue != selectedColumn) + OnSelectedCellChanged(new SelectedCellChangedEventArgs(Table,oldValue,SelectedColumn,SelectedRow,SelectedRow)); + } + } + + /// + /// The index of in that the user has currently selected + /// + public int SelectedRow { + get => selectedRow; + set { + + var oldValue = selectedRow; + + selectedRow = Table == null ? 0 : Math.Min (Table.Rows.Count - 1, Math.Max (0, value)); + + if(oldValue != selectedRow) + OnSelectedCellChanged(new SelectedCellChangedEventArgs(Table,SelectedColumn,SelectedColumn,oldValue,selectedRow)); + } + } + + /// + /// The maximum number of characters to render in any given column. This prevents one long column from pushing out all the others + /// + public int MaxCellWidth { get; set; } = DefaultMaxCellWidth; + + /// + /// The text representation that should be rendered for cells with the value + /// + public string NullSymbol { get; set; } = "-"; + + /// + /// The symbol to add after each cell value and header value to visually seperate values (if not using vertical gridlines) + /// + public char SeparatorSymbol { get; set; } = ' '; + + /// + /// This event is raised when the selected cell in the table changes. + /// + public event Action SelectedCellChanged; + + /// + /// This event is raised when a cell is activated e.g. by double clicking or pressing + /// + public event Action CellActivated; + + /// + /// The key which when pressed should trigger event. Defaults to Enter. + /// + public Key CellActivationKey {get;set;} = Key.Enter; + + /// + /// Initialzies a class using layout. + /// + /// The table to display in the control + public TableView (DataTable table) : this () + { + this.Table = table; + } + + /// + /// Initialzies a class using layout. Set the property to begin editing + /// + public TableView () : base () + { + CanFocus = true; + } + + /// + public override void Redraw (Rect bounds) + { + Move (0, 0); + var frame = Frame; + + // What columns to render at what X offset in viewport + var columnsToRender = CalculateViewport(bounds).ToArray(); + + Driver.SetAttribute (ColorScheme.Normal); + + //invalidate current row (prevents scrolling around leaving old characters in the frame + Driver.AddStr (new string (' ', bounds.Width)); + + int line = 0; + + if(ShouldRenderHeaders()){ + // Render something like: + /* + ┌────────────────────┬──────────┬───────────┬──────────────┬─────────┐ + │ArithmeticComparator│chi │Healthboard│Interpretation│Labnumber│ + └────────────────────┴──────────┴───────────┴──────────────┴─────────┘ + */ + if(Style.ShowHorizontalHeaderOverline){ + RenderHeaderOverline(line,bounds.Width,columnsToRender); + line++; + } + + RenderHeaderMidline(line,columnsToRender); + line++; + + if(Style.ShowHorizontalHeaderUnderline){ + RenderHeaderUnderline(line,bounds.Width,columnsToRender); + line++; + } + } + + int headerLinesConsumed = line; + + //render the cells + for (; line < frame.Height; line++) { + + ClearLine(line,bounds.Width); + + //work out what Row to render + var rowToRender = RowOffset + (line - headerLinesConsumed); + + //if we have run off the end of the table + if ( Table == null || rowToRender >= Table.Rows.Count || rowToRender < 0) + continue; + + RenderRow(line,rowToRender,columnsToRender); + } + } + + /// + /// Clears a line of the console by filling it with spaces + /// + /// + /// + private void ClearLine(int row, int width) + { + Move (0, row); + Driver.SetAttribute (ColorScheme.Normal); + Driver.AddStr (new string (' ', width)); + } + + /// + /// Returns the amount of vertical space currently occupied by the header or 0 if it is not visible. + /// + /// + private int GetHeaderHeightIfAny() + { + return ShouldRenderHeaders()? GetHeaderHeight():0; + } + + /// + /// Returns the amount of vertical space required to display the header + /// + /// + private int GetHeaderHeight() + { + int heightRequired = 1; + + if(Style.ShowHorizontalHeaderOverline) + heightRequired++; + + if(Style.ShowHorizontalHeaderUnderline) + heightRequired++; + + return heightRequired; + } + + private void RenderHeaderOverline(int row,int availableWidth, ColumnToRender[] columnsToRender) + { + // Renders a line above table headers (when visible) like: + // ┌────────────────────┬──────────┬───────────┬──────────────┬─────────┐ + + for(int c = 0;c< availableWidth;c++) { + + var rune = Driver.HLine; + + if (Style.ShowVerticalHeaderLines){ + + if(c == 0){ + rune = Driver.ULCorner; + } + // if the next column is the start of a header + else if(columnsToRender.Any(r=>r.X == c+1)){ + rune = Driver.TopTee; + } + else if(c == availableWidth -1){ + rune = Driver.URCorner; + } + } + + AddRuneAt(Driver,c,row,rune); + } + } + + private void RenderHeaderMidline(int row, ColumnToRender[] columnsToRender) + { + // Renders something like: + // │ArithmeticComparator│chi │Healthboard│Interpretation│Labnumber│ + + ClearLine(row,Bounds.Width); + + //render start of line + if(style.ShowVerticalHeaderLines) + AddRune(0,row,Driver.VLine); + + for(int i =0 ; i + /// Calculates how much space is available to render index of the given the remaining horizontal space + /// + /// + /// + private int GetCellWidth (ColumnToRender [] columnsToRender, int i) + { + var current = columnsToRender[i]; + var next = i+1 < columnsToRender.Length ? columnsToRender[i+1] : null; + + if(next == null) { + // cell can fill to end of the line + return Bounds.Width - current.X; + } + else { + // cell can fill up to next cell start + return next.X - current.X; + } + + } + + private void RenderHeaderUnderline(int row,int availableWidth, ColumnToRender[] columnsToRender) + { + // Renders a line below the table headers (when visible) like: + // ├──────────┼───────────┼───────────────────┼──────────┼────────┼─────────────┤ + + for(int c = 0;c< availableWidth;c++) { + + var rune = Driver.HLine; + + if (Style.ShowVerticalHeaderLines){ + if(c == 0){ + rune = Style.ShowVerticalCellLines ? Driver.LeftTee : Driver.LLCorner; + } + // if the next column is the start of a header + else if(columnsToRender.Any(r=>r.X == c+1)){ + + /*TODO: is ┼ symbol in Driver?*/ + rune = Style.ShowVerticalCellLines ? '┼' :Driver.BottomTee; + } + else if(c == availableWidth -1){ + rune = Style.ShowVerticalCellLines ? Driver.RightTee : Driver.LRCorner; + } + } + + AddRuneAt(Driver,c,row,rune); + } + + } + private void RenderRow(int row, int rowToRender, ColumnToRender[] columnsToRender) + { + //render start of line + if(style.ShowVerticalCellLines) + AddRune(0,row,Driver.VLine); + + // Render cells for each visible header for the current row + for(int i=0;i< columnsToRender.Length ;i++) { + + var current = columnsToRender[i]; + var availableWidthForCell = GetCellWidth(columnsToRender,i); + + var colStyle = Style.GetColumnStyleIfAny(current.Column); + + // move to start of cell (in line with header positions) + Move (current.X, row); + + // Set color scheme based on whether the current cell is the selected one + bool isSelectedCell = IsSelected(current.Column.Ordinal,rowToRender); + + Driver.SetAttribute (isSelectedCell ? ColorScheme.HotFocus : ColorScheme.Normal); + + var val = Table.Rows [rowToRender][current.Column]; + + // Render the (possibly truncated) cell value + var representation = GetRepresentation(val,colStyle); + + Driver.AddStr (TruncateOrPad(val,representation,availableWidthForCell,colStyle)); + + // If not in full row select mode always, reset color scheme to normal and render the vertical line (or space) at the end of the cell + if(!FullRowSelect) + Driver.SetAttribute (ColorScheme.Normal); + + RenderSeparator(current.X-1,row,false); + } + + //render end of line + if(style.ShowVerticalCellLines) + AddRune(Bounds.Width-1,row,Driver.VLine); + } + + private void RenderSeparator(int col, int row,bool isHeader) + { + if(col<0) + return; + + var renderLines = isHeader ? style.ShowVerticalHeaderLines : style.ShowVerticalCellLines; + + Rune symbol = renderLines ? Driver.VLine : SeparatorSymbol; + AddRune(col,row,symbol); + } + + void AddRuneAt (ConsoleDriver d,int col, int row, Rune ch) + { + Move (col, row); + d.AddRune (ch); + } + + /// + /// Truncates or pads so that it occupies a exactly using the alignment specified in (or left if no style is defined) + /// + /// The object in this cell of the + /// The string representation of + /// + /// Optional style indicating custom alignment for the cell + /// + private string TruncateOrPad (object originalCellValue,string representation, int availableHorizontalSpace, ColumnStyle colStyle) + { + if (string.IsNullOrEmpty (representation)) + return representation; + + // if value is not wide enough + if(representation.Sum(c=>Rune.ColumnWidth(c)) < availableHorizontalSpace) { + + // pad it out with spaces to the given alignment + int toPad = availableHorizontalSpace - (representation.Sum(c=>Rune.ColumnWidth(c)) +1 /*leave 1 space for cell boundary*/); + + switch(colStyle?.GetAlignment(originalCellValue) ?? TextAlignment.Left) { + + case TextAlignment.Left : + return representation + new string(' ',toPad); + case TextAlignment.Right : + return new string(' ',toPad) + representation; + + // TODO: With single line cells, centered and justified are the same right? + case TextAlignment.Centered : + case TextAlignment.Justified : + return + new string(' ',(int)Math.Floor(toPad/2.0)) + // round down + representation + + new string(' ',(int)Math.Ceiling(toPad/2.0)) ; // round up + } + } + + // value is too wide + return new string(representation.TakeWhile(c=>(availableHorizontalSpace-= Rune.ColumnWidth(c))>0).ToArray()); + } + + /// + public override bool ProcessKey (KeyEvent keyEvent) + { + if(Table == null){ + PositionCursor (); + return false; + } + + if(keyEvent.Key == CellActivationKey && Table != null) { + OnCellActivated(new CellActivatedEventArgs(Table,SelectedColumn,SelectedRow)); + return true; + } + + switch (keyEvent.Key) { + case Key.CursorLeft: + case Key.CursorLeft | Key.ShiftMask: + ChangeSelectionByOffset(-1,0,keyEvent.Key.HasFlag(Key.ShiftMask)); + Update (); + break; + case Key.CursorRight: + case Key.CursorRight | Key.ShiftMask: + ChangeSelectionByOffset(1,0,keyEvent.Key.HasFlag(Key.ShiftMask)); + Update (); + break; + case Key.CursorDown: + case Key.CursorDown | Key.ShiftMask: + ChangeSelectionByOffset(0,1,keyEvent.Key.HasFlag(Key.ShiftMask)); + Update (); + break; + case Key.CursorUp: + case Key.CursorUp | Key.ShiftMask: + ChangeSelectionByOffset(0,-1,keyEvent.Key.HasFlag(Key.ShiftMask)); + Update (); + break; + case Key.PageUp: + case Key.PageUp | Key.ShiftMask: + ChangeSelectionByOffset(0,-(Bounds.Height - GetHeaderHeightIfAny()),keyEvent.Key.HasFlag(Key.ShiftMask)); + Update (); + break; + case Key.PageDown: + case Key.PageDown | Key.ShiftMask: + ChangeSelectionByOffset(0,Bounds.Height - GetHeaderHeightIfAny(),keyEvent.Key.HasFlag(Key.ShiftMask)); + Update (); + break; + case Key.Home | Key.CtrlMask: + case Key.Home | Key.CtrlMask | Key.ShiftMask: + // jump to table origin + SetSelection(0,0,keyEvent.Key.HasFlag(Key.ShiftMask)); + Update (); + break; + case Key.Home: + case Key.Home | Key.ShiftMask: + // jump to start of line + SetSelection(0,SelectedRow,keyEvent.Key.HasFlag(Key.ShiftMask)); + Update (); + break; + case Key.End | Key.CtrlMask: + case Key.End | Key.CtrlMask | Key.ShiftMask: + // jump to end of table + SetSelection(Table.Columns.Count - 1, Table.Rows.Count - 1, keyEvent.Key.HasFlag(Key.ShiftMask)); + Update (); + break; + case Key.A | Key.CtrlMask: + SelectAll(); + Update (); + break; + case Key.End: + case Key.End | Key.ShiftMask: + //jump to end of row + SetSelection(Table.Columns.Count - 1,SelectedRow, keyEvent.Key.HasFlag(Key.ShiftMask)); + Update (); + break; + default: + // Not a keystroke we care about + return false; + } + PositionCursor (); + return true; + } + + /// + /// Moves the and to the given col/row in . Optionally starting a box selection (see ) + /// + /// + /// + /// True to create a multi cell selection or adjust an existing one + public void SetSelection (int col, int row, bool extendExistingSelection) + { + if(!MultiSelect || !extendExistingSelection) + MultiSelectedRegions.Clear(); + + if(extendExistingSelection) + { + // If we are extending current selection but there isn't one + if(MultiSelectedRegions.Count == 0) + { + // Create a new region between the old active cell and the new cell + var rect = CreateTableSelection(SelectedColumn,SelectedRow,col,row); + MultiSelectedRegions.Push(rect); + } + else + { + // Extend the current head selection to include the new cell + var head = MultiSelectedRegions.Pop(); + var newRect = CreateTableSelection(head.Origin.X,head.Origin.Y,col,row); + MultiSelectedRegions.Push(newRect); + } + } + + SelectedColumn = col; + SelectedRow = row; + } + + /// + /// Moves the and by the provided offsets. Optionally starting a box selection (see ) + /// + /// Offset in number of columns + /// Offset in number of rows + /// True to create a multi cell selection or adjust an existing one + public void ChangeSelectionByOffset (int offsetX, int offsetY, bool extendExistingSelection) + { + SetSelection(SelectedColumn + offsetX, SelectedRow + offsetY,extendExistingSelection); + } + + /// + /// When is on, creates selection over all cells in the table (replacing any old selection regions) + /// + public void SelectAll() + { + if(Table == null || !MultiSelect || Table.Rows.Count == 0) + return; + + MultiSelectedRegions.Clear(); + + // Create a single region over entire table, set the origin of the selection to the active cell so that a followup spread selection e.g. shift-right behaves properly + MultiSelectedRegions.Push(new TableSelection(new Point(SelectedColumn,SelectedRow), new Rect(0,0,Table.Columns.Count,table.Rows.Count))); + Update(); + } + + /// + /// Returns all cells in any (if is enabled) and the selected cell + /// + /// + public IEnumerable GetAllSelectedCells() + { + if(Table == null || Table.Rows.Count == 0) + yield break; + + EnsureValidSelection(); + + // If there are one or more rectangular selections + if(MultiSelect && MultiSelectedRegions.Any()){ + + // Quiz any cells for whether they are selected. For performance we only need to check those between the top left and lower right vertex of selection regions + var yMin = MultiSelectedRegions.Min(r=>r.Rect.Top); + var yMax = MultiSelectedRegions.Max(r=>r.Rect.Bottom); + + var xMin = FullRowSelect ? 0 : MultiSelectedRegions.Min(r=>r.Rect.Left); + var xMax = FullRowSelect ? Table.Columns.Count : MultiSelectedRegions.Max(r=>r.Rect.Right); + + for(int y = yMin ; y < yMax ; y++) + { + for(int x = xMin ; x < xMax ; x++) + { + if(IsSelected(x,y)){ + yield return new Point(x,y); + } + } + } + } + else{ + + // if there are no region selections then it is just the active cell + + // if we are selecting the full row + if(FullRowSelect) + { + // all cells in active row are selected + for(int x =0;x + /// Returns a new rectangle between the two points with positive width/height regardless of relative positioning of the points. pt1 is always considered the point + /// + /// Origin point for the selection in X + /// Origin point for the selection in Y + /// End point for the selection in X + /// End point for the selection in Y + /// + private TableSelection CreateTableSelection (int pt1X, int pt1Y, int pt2X, int pt2Y) + { + var top = Math.Min(pt1Y,pt2Y); + var bot = Math.Max(pt1Y,pt2Y); + + var left = Math.Min(pt1X,pt2X); + var right = Math.Max(pt1X,pt2X); + + // Rect class is inclusive of Top Left but exclusive of Bottom Right so extend by 1 + return new TableSelection(new Point(pt1X,pt1Y),new Rect(left,top,right-left + 1,bot-top + 1)); + } + + /// + /// Returns true if the given cell is selected either because it is the active cell or part of a multi cell selection (e.g. ) + /// + /// + /// + /// + public bool IsSelected(int col, int row) + { + // Cell is also selected if in any multi selection region + if(MultiSelect && MultiSelectedRegions.Any(r=>r.Rect.Contains(col,row))) + return true; + + // Cell is also selected if Y axis appears in any region (when FullRowSelect is enabled) + if(FullRowSelect && MultiSelect && MultiSelectedRegions.Any(r=>r.Rect.Bottom> row && r.Rect.Top <= row)) + return true; + + return row == SelectedRow && + (col == SelectedColumn || FullRowSelect); + } + + /// + /// Positions the cursor in the area of the screen in which the start of the active cell is rendered. Calls base implementation if active cell is not visible due to scrolling or table is loaded etc + /// + public override void PositionCursor() + { + if(Table == null) { + base.PositionCursor(); + return; + } + + var screenPoint = CellToScreen(SelectedColumn,SelectedRow); + + if(screenPoint != null) + Move(screenPoint.Value.X,screenPoint.Value.Y); + } + + /// + public override bool MouseEvent (MouseEvent me) + { + if (!me.Flags.HasFlag (MouseFlags.Button1Clicked) && !me.Flags.HasFlag (MouseFlags.Button1DoubleClicked) && + me.Flags != MouseFlags.WheeledDown && me.Flags != MouseFlags.WheeledUp && + me.Flags != MouseFlags.WheeledLeft && me.Flags != MouseFlags.WheeledRight) + return false; + + if (!HasFocus && CanFocus) { + SetFocus (); + } + + if (Table == null) { + return false; + } + + // Scroll wheel flags + switch(me.Flags) + { + case MouseFlags.WheeledDown: + RowOffset++; + EnsureValidScrollOffsets(); + SetNeedsDisplay(); + return true; + + case MouseFlags.WheeledUp: + RowOffset--; + EnsureValidScrollOffsets(); + SetNeedsDisplay(); + return true; + + case MouseFlags.WheeledRight: + ColumnOffset++; + EnsureValidScrollOffsets(); + SetNeedsDisplay(); + return true; + + case MouseFlags.WheeledLeft: + ColumnOffset--; + EnsureValidScrollOffsets(); + SetNeedsDisplay(); + return true; + } + + if(me.Flags.HasFlag(MouseFlags.Button1Clicked)) { + + var hit = ScreenToCell(me.X,me.Y); + if(hit != null) { + + SetSelection(hit.Value.X,hit.Value.Y,me.Flags.HasFlag(MouseFlags.ButtonShift)); + Update(); + } + } + + // Double clicking a cell activates + if(me.Flags == MouseFlags.Button1DoubleClicked) { + var hit = ScreenToCell(me.X,me.Y); + if(hit!= null) { + OnCellActivated(new CellActivatedEventArgs(Table,hit.Value.X,hit.Value.Y)); + } + } + + return false; + } + + /// + /// Returns the column and row of that corresponds to a given point on the screen (relative to the control client area). Returns null if the point is in the header, no table is loaded or outside the control bounds + /// + /// X offset from the top left of the control + /// Y offset from the top left of the control + /// + public Point? ScreenToCell (int clientX, int clientY) + { + if(Table == null) + return null; + + var viewPort = CalculateViewport(Bounds); + + var headerHeight = GetHeaderHeightIfAny(); + + var col = viewPort.LastOrDefault(c=>c.X <= clientX); + + // Click is on the header section of rendered UI + if(clientY < headerHeight) + return null; + + var rowIdx = RowOffset - headerHeight + clientY; + + if(col != null && rowIdx >= 0) { + + return new Point(col.Column.Ordinal,rowIdx); + } + + return null; + } + + /// + /// Returns the screen position (relative to the control client area) that the given cell is rendered or null if it is outside the current scroll area or no table is loaded + /// + /// The index of the column you are looking for, use + /// The index of the row in that you are looking for + /// + public Point? CellToScreen (int tableColumn, int tableRow) + { + if(Table == null) + return null; + + var viewPort = CalculateViewport(Bounds); + + var headerHeight = GetHeaderHeightIfAny(); + + var colHit = viewPort.FirstOrDefault(c=>c.Column.Ordinal == tableColumn); + + // current column is outside the scroll area + if(colHit == null) + return null; + + // the cell is too far up above the current scroll area + if(RowOffset > tableRow) + return null; + + // the cell is way down below the scroll area and off the screen + if(tableRow > RowOffset + (Bounds.Height - headerHeight)) + return null; + + return new Point(colHit.X,tableRow + headerHeight - RowOffset); + } + /// + /// Updates the view to reflect changes to and to ( / ) etc + /// + /// This always calls + public void Update() + { + if(Table == null) { + SetNeedsDisplay (); + return; + } + + EnsureValidScrollOffsets(); + EnsureValidSelection(); + + EnsureSelectedCellIsVisible(); + + SetNeedsDisplay (); + } + + /// + /// Updates and where they are outside the bounds of the table (by adjusting them to the nearest existing cell). Has no effect if has not been set. + /// + /// Changes will not be immediately visible in the display until you call + public void EnsureValidScrollOffsets () + { + if(Table == null){ + return; + } + + ColumnOffset = Math.Max(Math.Min(ColumnOffset,Table.Columns.Count -1),0); + RowOffset = Math.Max(Math.Min(RowOffset,Table.Rows.Count -1),0); + } + + + /// + /// Updates , and where they are outside the bounds of the table (by adjusting them to the nearest existing cell). Has no effect if has not been set. + /// + /// Changes will not be immediately visible in the display until you call + public void EnsureValidSelection() + { + if(Table == null){ + + // Table doesn't exist, we should probably clear those selections + MultiSelectedRegions.Clear(); + return; + } + + SelectedColumn = Math.Max(Math.Min(SelectedColumn,Table.Columns.Count -1),0); + SelectedRow = Math.Max(Math.Min(SelectedRow,Table.Rows.Count -1),0); + + var oldRegions = MultiSelectedRegions.ToArray().Reverse(); + + MultiSelectedRegions.Clear(); + + // evaluate + foreach(var region in oldRegions) + { + // ignore regions entirely below current table state + if(region.Rect.Top >= Table.Rows.Count) + continue; + + // ignore regions entirely too far right of table columns + if(region.Rect.Left >= Table.Columns.Count) + continue; + + // ensure region's origin exists + region.Origin = new Point( + Math.Max(Math.Min(region.Origin.X,Table.Columns.Count -1),0), + Math.Max(Math.Min(region.Origin.Y,Table.Rows.Count -1),0)); + + // ensure regions do not go over edge of table bounds + region.Rect = Rect.FromLTRB(region.Rect.Left, + region.Rect.Top, + Math.Max(Math.Min(region.Rect.Right, Table.Columns.Count ),0), + Math.Max(Math.Min(region.Rect.Bottom,Table.Rows.Count),0) + ); + + MultiSelectedRegions.Push(region); + } + + } + + /// + /// Updates scroll offsets to ensure that the selected cell is visible. Has no effect if has not been set. + /// + /// Changes will not be immediately visible in the display until you call + public void EnsureSelectedCellIsVisible () + { + if(Table == null || Table.Columns.Count <= 0){ + return; + } + + var columnsToRender = CalculateViewport (Bounds).ToArray(); + var headerHeight = GetHeaderHeightIfAny(); + + //if we have scrolled too far to the left + if (SelectedColumn < columnsToRender.Min (r => r.Column.Ordinal)) { + ColumnOffset = SelectedColumn; + } + + //if we have scrolled too far to the right + if (SelectedColumn > columnsToRender.Max (r=> r.Column.Ordinal)) { + ColumnOffset = SelectedColumn; + } + + //if we have scrolled too far down + if (SelectedRow >= RowOffset + (Bounds.Height - headerHeight)) { + RowOffset = SelectedRow; + } + //if we have scrolled too far up + if (SelectedRow < RowOffset) { + RowOffset = SelectedRow; + } + } + + /// + /// Invokes the event + /// + protected virtual void OnSelectedCellChanged(SelectedCellChangedEventArgs args) + { + SelectedCellChanged?.Invoke(args); + } + + /// + /// Invokes the event + /// + /// + protected virtual void OnCellActivated (CellActivatedEventArgs args) + { + CellActivated?.Invoke(args); + } + + /// + /// Calculates which columns should be rendered given the in which to display and the + /// + /// + /// + /// + private IEnumerable CalculateViewport (Rect bounds, int padding = 1) + { + if(Table == null) + yield break; + + int usedSpace = 0; + + //if horizontal space is required at the start of the line (before the first header) + if(Style.ShowVerticalHeaderLines || Style.ShowVerticalCellLines) + usedSpace+=1; + + int availableHorizontalSpace = bounds.Width; + int rowsToRender = bounds.Height; + + // reserved for the headers row + if(ShouldRenderHeaders()) + rowsToRender -= GetHeaderHeight(); + + bool first = true; + + foreach (var col in Table.Columns.Cast().Skip (ColumnOffset)) { + + int startingIdxForCurrentHeader = usedSpace; + var colStyle = Style.GetColumnStyleIfAny(col); + + // is there enough space for this column (and it's data)? + usedSpace += CalculateMaxCellWidth (col, rowsToRender,colStyle) + padding; + + // no (don't render it) unless its the only column we are render (that must be one massively wide column!) + if (!first && usedSpace > availableHorizontalSpace) + yield break; + + // there is space + yield return new ColumnToRender(col, startingIdxForCurrentHeader); + first=false; + } + } + + private bool ShouldRenderHeaders() + { + if(Table == null || Table.Columns.Count == 0) + return false; + + return Style.AlwaysShowHeaders || rowOffset == 0; + } + + /// + /// Returns the maximum of the name and the maximum length of data that will be rendered starting at and rendering + /// + /// + /// + /// + /// + private int CalculateMaxCellWidth(DataColumn col, int rowsToRender,ColumnStyle colStyle) + { + int spaceRequired = col.ColumnName.Sum(c=>Rune.ColumnWidth(c)); + + // if table has no rows + if(RowOffset < 0) + return spaceRequired; + + + for (int i = RowOffset; i < RowOffset + rowsToRender && i < Table.Rows.Count; i++) { + + //expand required space if cell is bigger than the last biggest cell or header + spaceRequired = Math.Max (spaceRequired, GetRepresentation(Table.Rows [i][col],colStyle).Sum(c=>Rune.ColumnWidth(c))); + } + + // Don't require more space than the style allows + if(colStyle != null){ + + // enforce maximum cell width based on style + if(spaceRequired > colStyle.MaxWidth) { + spaceRequired = colStyle.MaxWidth; + } + + // enforce minimum cell width based on style + if(spaceRequired < colStyle.MinWidth) { + spaceRequired = colStyle.MinWidth; + } + } + + // enforce maximum cell width based on global table style + if(spaceRequired > MaxCellWidth) + spaceRequired = MaxCellWidth; + + + return spaceRequired; + } + + /// + /// Returns the value that should be rendered to best represent a strongly typed read from + /// + /// + /// Optional style defining how to represent cell values + /// + private string GetRepresentation(object value,ColumnStyle colStyle) + { + if (value == null || value == DBNull.Value) { + return NullSymbol; + } + + return colStyle != null ? colStyle.GetRepresentation(value): value.ToString(); + } + } + + /// + /// Describes a desire to render a column at a given horizontal position in the UI + /// + internal class ColumnToRender { + + /// + /// The column to render + /// + public DataColumn Column {get;set;} + + /// + /// The horizontal position to begin rendering the column at + /// + public int X{get;set;} + + public ColumnToRender (DataColumn col, int x) + { + Column = col; + X = x; + } + } + + /// + /// Defines the event arguments for + /// + public class SelectedCellChangedEventArgs : EventArgs + { + /// + /// The current table to which the new indexes refer. May be null e.g. if selection change is the result of clearing the table from the view + /// + /// + public DataTable Table {get;} + + + /// + /// The previous selected column index. May be invalid e.g. when the selection has been changed as a result of replacing the existing Table with a smaller one + /// + /// + public int OldCol {get;} + + + /// + /// The newly selected column index. + /// + /// + public int NewCol {get;} + + + /// + /// The previous selected row index. May be invalid e.g. when the selection has been changed as a result of deleting rows from the table + /// + /// + public int OldRow {get;} + + + /// + /// The newly selected row index. + /// + /// + public int NewRow {get;} + + /// + /// Creates a new instance of arguments describing a change in selected cell in a + /// + /// + /// + /// + /// + /// + public SelectedCellChangedEventArgs(DataTable t, int oldCol, int newCol, int oldRow, int newRow) + { + Table = t; + OldCol = oldCol; + NewCol = newCol; + OldRow = oldRow; + NewRow = newRow; + } + } + + /// + /// Describes a selected region of the table + /// + public class TableSelection + { + + /// + /// Corner of the where selection began + /// + /// + public Point Origin{get;set;} + + /// + /// Area selected + /// + /// + public Rect Rect { get; set;} + + /// + /// Creates a new selected area starting at the origin corner and covering the provided rectangular area + /// + /// + /// + public TableSelection(Point origin, Rect rect) + { + Origin = origin; + Rect = rect; + } + } + + /// + /// Defines the event arguments for event + /// + public class CellActivatedEventArgs : EventArgs + { + /// + /// The current table to which the new indexes refer. May be null e.g. if selection change is the result of clearing the table from the view + /// + /// + public DataTable Table {get;} + + + /// + /// The column index of the cell that is being activated + /// + /// + public int Col {get;} + + /// + /// The row index of the cell that is being activated + /// + /// + public int Row {get;} + + /// + /// Creates a new instance of arguments describing a cell being activated in + /// + /// + /// + /// + public CellActivatedEventArgs(DataTable t, int col, int row) + { + Table = t; + Col = col; + Row = row; + } + } +} diff --git a/UICatalog/Scenarios/CsvEditor.cs b/UICatalog/Scenarios/CsvEditor.cs new file mode 100644 index 000000000..56f9d3e7c --- /dev/null +++ b/UICatalog/Scenarios/CsvEditor.cs @@ -0,0 +1,526 @@ +using System; +using System.Collections.Generic; +using System.Data; +using Terminal.Gui; +using System.Linq; +using System.Globalization; +using System.IO; +using System.Text; +using NStack; + +namespace UICatalog.Scenarios { + + [ScenarioMetadata (Name: "Csv Editor", Description: "Open and edit simple CSV files")] + [ScenarioCategory ("Controls")] + [ScenarioCategory ("Dialogs")] + [ScenarioCategory ("Text")] + [ScenarioCategory ("Dialogs")] + [ScenarioCategory ("TopLevel")] + public class CsvEditor : Scenario + { + TableView tableView; + private string currentFile; + private MenuItem miLeft; + private MenuItem miRight; + private MenuItem miCentered; + private Label selectedCellLabel; + + public override void Setup () + { + Win.Title = this.GetName(); + Win.Y = 1; // menu + Win.Height = Dim.Fill (1); // status bar + Top.LayoutSubviews (); + + this.tableView = new TableView () { + X = 0, + Y = 0, + Width = Dim.Fill (), + Height = Dim.Fill (1), + }; + + var menu = new MenuBar (new MenuBarItem [] { + new MenuBarItem ("_File", new MenuItem [] { + new MenuItem ("_Open CSV", "", () => Open()), + new MenuItem ("_Save", "", () => Save()), + new MenuItem ("_Quit", "", () => Quit()), + }), + new MenuBarItem ("_Edit", new MenuItem [] { + new MenuItem ("_New Column", "", () => AddColumn()), + new MenuItem ("_New Row", "", () => AddRow()), + new MenuItem ("_Rename Column", "", () => RenameColumn()), + new MenuItem ("_Delete Column", "", () => DeleteColum()), + new MenuItem ("_Move Column", "", () => MoveColumn()), + new MenuItem ("_Move Row", "", () => MoveRow()), + new MenuItem ("_Sort Asc", "", () => Sort(true)), + new MenuItem ("_Sort Desc", "", () => Sort(false)), + }), + new MenuBarItem ("_View", new MenuItem [] { + miLeft = new MenuItem ("_Align Left", "", () => Align(TextAlignment.Left)), + miRight = new MenuItem ("_Align Right", "", () => Align(TextAlignment.Right)), + miCentered = new MenuItem ("_Align Centered", "", () => Align(TextAlignment.Centered)), + + // Format requires hard typed data table, when we read a CSV everything is untyped (string) so this only works for new columns in this demo + miCentered = new MenuItem ("_Set Format Pattern", "", () => SetFormat()), + }) + }); + Top.Add (menu); + + var statusBar = new StatusBar (new StatusItem [] { + new StatusItem(Key.CtrlMask | Key.O, "~^O~ Open", () => Open()), + new StatusItem(Key.CtrlMask | Key.S, "~^S~ Save", () => Save()), + new StatusItem(Key.CtrlMask | Key.Q, "~^Q~ Quit", () => Quit()), + }); + Top.Add (statusBar); + + Win.Add (tableView); + + selectedCellLabel = new Label(){ + X = 0, + Y = Pos.Bottom(tableView), + Text = "0,0", + Width = Dim.Fill(), + TextAlignment = TextAlignment.Right + + }; + + Win.Add(selectedCellLabel); + + tableView.SelectedCellChanged += OnSelectedCellChanged; + tableView.CellActivated += EditCurrentCell; + tableView.KeyPress += TableViewKeyPress; + + SetupScrollBar(); + } + + + private void OnSelectedCellChanged (SelectedCellChangedEventArgs e) + { + selectedCellLabel.Text = $"{tableView.SelectedRow},{tableView.SelectedColumn}"; + + if(tableView.Table == null || tableView.SelectedColumn == -1) + return; + + var col = tableView.Table.Columns[tableView.SelectedColumn]; + + var style = tableView.Style.GetColumnStyleIfAny(col); + + miLeft.Checked = style?.Alignment == TextAlignment.Left; + miRight.Checked = style?.Alignment == TextAlignment.Right; + miCentered.Checked = style?.Alignment == TextAlignment.Centered; + } + + private void RenameColumn () + { + if(NoTableLoaded()) { + return; + } + + var currentCol = tableView.Table.Columns[tableView.SelectedColumn]; + + if(GetText("Rename Column","Name:",currentCol.ColumnName,out string newName)) { + currentCol.ColumnName = newName; + tableView.Update(); + } + } + + private void DeleteColum() + { + if(NoTableLoaded()) { + return; + } + + if(tableView.SelectedColumn == -1) { + + MessageBox.ErrorQuery("No Column","No column selected", "Ok"); + return; + } + + + try { + tableView.Table.Columns.RemoveAt(tableView.SelectedColumn); + tableView.Update(); + + } catch (Exception ex) { + MessageBox.ErrorQuery("Could not remove column",ex.Message, "Ok"); + } + } + + private void MoveColumn () + { + if(NoTableLoaded()) { + return; + } + + if(tableView.SelectedColumn == -1) { + + MessageBox.ErrorQuery("No Column","No column selected", "Ok"); + return; + } + + try{ + + var currentCol = tableView.Table.Columns[tableView.SelectedColumn]; + + if(GetText("Move Column","New Index:",currentCol.Ordinal.ToString(),out string newOrdinal)) { + + var newIdx = Math.Min(Math.Max(0,int.Parse(newOrdinal)),tableView.Table.Columns.Count-1); + + currentCol.SetOrdinal(newIdx); + + tableView.SetSelection(newIdx,tableView.SelectedRow,false); + tableView.EnsureSelectedCellIsVisible(); + tableView.SetNeedsDisplay(); + } + + }catch(Exception ex) + { + MessageBox.ErrorQuery("Error moving column",ex.Message, "Ok"); + } + } + private void Sort (bool asc) + { + + if(NoTableLoaded()) { + return; + } + + if(tableView.SelectedColumn == -1) { + + MessageBox.ErrorQuery("No Column","No column selected", "Ok"); + return; + } + + var colName = tableView.Table.Columns[tableView.SelectedColumn].ColumnName; + + tableView.Table.DefaultView.Sort = colName + (asc ? " asc" : " desc"); + tableView.Table = tableView.Table.DefaultView.ToTable(); + } + + private void MoveRow () + { + if(NoTableLoaded()) { + return; + } + + if(tableView.SelectedRow == -1) { + + MessageBox.ErrorQuery("No Rows","No row selected", "Ok"); + return; + } + + try{ + + int oldIdx = tableView.SelectedRow; + + var currentRow = tableView.Table.Rows[oldIdx]; + + if(GetText("Move Row","New Row:",oldIdx.ToString(),out string newOrdinal)) { + + var newIdx = Math.Min(Math.Max(0,int.Parse(newOrdinal)),tableView.Table.Rows.Count-1); + + + if(newIdx == oldIdx) + return; + + var arrayItems = currentRow.ItemArray; + tableView.Table.Rows.Remove(currentRow); + + // Removing and Inserting the same DataRow seems to result in it loosing its values so we have to create a new instance + var newRow = tableView.Table.NewRow(); + newRow.ItemArray = arrayItems; + + tableView.Table.Rows.InsertAt(newRow,newIdx); + + tableView.SetSelection(tableView.SelectedColumn,newIdx,false); + tableView.EnsureSelectedCellIsVisible(); + tableView.SetNeedsDisplay(); + } + + }catch(Exception ex) + { + MessageBox.ErrorQuery("Error moving column",ex.Message, "Ok"); + } + } + + private void Align (TextAlignment newAlignment) + { + if (NoTableLoaded ()) { + return; + } + + var col = tableView.Table.Columns[tableView.SelectedColumn]; + + var style = tableView.Style.GetOrCreateColumnStyle(col); + style.Alignment = newAlignment; + + miLeft.Checked = style.Alignment == TextAlignment.Left; + miRight.Checked = style.Alignment == TextAlignment.Right; + miCentered.Checked = style.Alignment == TextAlignment.Centered; + + tableView.Update(); + } + + private void SetFormat() + { + if (NoTableLoaded ()) { + return; + } + + var col = tableView.Table.Columns[tableView.SelectedColumn]; + + if(col.DataType == typeof(string)) { + MessageBox.ErrorQuery("Cannot Format Column","String columns cannot be Formatted, try adding a new column to the table with a date/numerical Type","Ok"); + return; + } + + var style = tableView.Style.GetOrCreateColumnStyle(col); + + if(GetText("Format","Pattern:",style.Format ?? "",out string newPattern)) { + style.Format = newPattern; + tableView.Update(); + } + } + + private bool NoTableLoaded () + { + if(tableView.Table == null) { + MessageBox.ErrorQuery("No Table Loaded","No table has currently be opened","Ok"); + return true; + } + + return false; + } + + private void AddRow () + { + if(NoTableLoaded()) { + return; + } + + var newRow = tableView.Table.NewRow(); + + var newRowIdx = Math.Min(Math.Max(0,tableView.SelectedRow+1),tableView.Table.Rows.Count); + + tableView.Table.Rows.InsertAt(newRow,newRowIdx); + tableView.Update(); + } + + private void AddColumn () + { + if(NoTableLoaded()) { + return; + } + + if(GetText("Enter column name","Name:","",out string colName)) { + + var col = new DataColumn(colName); + + var newColIdx = Math.Min(Math.Max(0,tableView.SelectedColumn + 1),tableView.Table.Columns.Count); + + int result = MessageBox.Query(40,15,"Column Type","Pick a data type for the column",new ustring[]{"Date","Integer","Double","Text","Cancel"}); + + if(result <= -1 || result >= 4) + return; + switch(result) { + case 0: col.DataType = typeof(DateTime); + break; + case 1: col.DataType = typeof(int); + break; + case 2: col.DataType = typeof(double); + break; + case 3: col.DataType = typeof(string); + break; + } + + tableView.Table.Columns.Add(col); + col.SetOrdinal(newColIdx); + tableView.Update(); + } + + + + } + + private void Save() + { + if(tableView.Table == null || string.IsNullOrWhiteSpace(currentFile)) { + MessageBox.ErrorQuery("No file loaded","No file is currently loaded","Ok"); + return; + } + + var sb = new StringBuilder(); + + sb.AppendLine(string.Join(",",tableView.Table.Columns.Cast().Select(c=>c.ColumnName))); + + foreach(DataRow row in tableView.Table.Rows) { + sb.AppendLine(string.Join(",",row.ItemArray)); + } + + File.WriteAllText(currentFile,sb.ToString()); + } + + private void Open() + { + var ofd = new FileDialog("Select File","Open","File","Select a CSV file to open (does not support newlines, escaping etc)"); + ofd.AllowedFileTypes = new string[]{".csv" }; + + Application.Run(ofd); + + if(!string.IsNullOrWhiteSpace(ofd.FilePath?.ToString())) + { + Open(ofd.FilePath.ToString()); + } + } + + private void Open(string filename) + { + + int lineNumber = 0; + currentFile = null; + + try { + var dt = new DataTable(); + var lines = File.ReadAllLines(filename); + + foreach(var h in lines[0].Split(',')){ + dt.Columns.Add(h); + } + + + foreach(var line in lines.Skip(1)) { + lineNumber++; + dt.Rows.Add(line.Split(',')); + } + + tableView.Table = dt; + + // Only set the current filename if we succesfully loaded the entire file + currentFile = filename; + } + catch(Exception ex) { + MessageBox.ErrorQuery("Open Failed",$"Error on line {lineNumber}{Environment.NewLine}{ex.Message}","Ok"); + } + } + private void SetupScrollBar () + { + var _scrollBar = new ScrollBarView (tableView, true); + + _scrollBar.ChangedPosition += () => { + tableView.RowOffset = _scrollBar.Position; + if (tableView.RowOffset != _scrollBar.Position) { + _scrollBar.Position = tableView.RowOffset; + } + tableView.SetNeedsDisplay (); + }; + /* + _scrollBar.OtherScrollBarView.ChangedPosition += () => { + _listView.LeftItem = _scrollBar.OtherScrollBarView.Position; + if (_listView.LeftItem != _scrollBar.OtherScrollBarView.Position) { + _scrollBar.OtherScrollBarView.Position = _listView.LeftItem; + } + _listView.SetNeedsDisplay (); + };*/ + + tableView.DrawContent += (e) => { + _scrollBar.Size = tableView.Table?.Rows?.Count ??0; + _scrollBar.Position = tableView.RowOffset; + // _scrollBar.OtherScrollBarView.Size = _listView.Maxlength - 1; + // _scrollBar.OtherScrollBarView.Position = _listView.LeftItem; + _scrollBar.Refresh (); + }; + + } + + private void TableViewKeyPress (View.KeyEventEventArgs e) + { + if(e.KeyEvent.Key == Key.DeleteChar){ + + if(tableView.FullRowSelect) + { + // Delete button deletes all rows when in full row mode + foreach(int toRemove in tableView.GetAllSelectedCells().Select(p=>p.Y).Distinct().OrderByDescending(i=>i)) + tableView.Table.Rows.RemoveAt(toRemove); + } + else{ + + // otherwise set all selected cells to null + foreach(var pt in tableView.GetAllSelectedCells()) + { + tableView.Table.Rows[pt.Y][pt.X] = DBNull.Value; + } + } + + tableView.Update(); + e.Handled = true; + } + } + + private void ClearColumnStyles () + { + tableView.Style.ColumnStyles.Clear(); + tableView.Update(); + } + + + private void CloseExample () + { + tableView.Table = null; + } + + private void Quit () + { + Application.RequestStop (); + } + private bool GetText(string title, string label, string initialText, out string enteredText) + { + bool okPressed = false; + + var ok = new Button ("Ok", is_default: true); + ok.Clicked += () => { okPressed = true; Application.RequestStop (); }; + var cancel = new Button ("Cancel"); + cancel.Clicked += () => { Application.RequestStop (); }; + var d = new Dialog (title, 60, 20, ok, cancel); + + var lbl = new Label() { + X = 0, + Y = 1, + Text = label + }; + + var tf = new TextField() + { + Text = initialText, + X = 0, + Y = 2, + Width = Dim.Fill() + }; + + d.Add (lbl,tf); + tf.SetFocus(); + + Application.Run (d); + + enteredText = okPressed? tf.Text.ToString() : null; + return okPressed; + } + private void EditCurrentCell (CellActivatedEventArgs e) + { + if(e.Table == null) + return; + + var oldValue = e.Table.Rows[e.Row][e.Col].ToString(); + + if(GetText("Enter new value",e.Table.Columns[e.Col].ColumnName,oldValue, out string newText)) { + try { + e.Table.Rows[e.Row][e.Col] = string.IsNullOrWhiteSpace(newText) ? DBNull.Value : (object)newText; + } + catch(Exception ex) { + MessageBox.ErrorQuery(60,20,"Failed to set text", ex.Message,"Ok"); + } + + tableView.Update(); + } + } + } +} diff --git a/UICatalog/Scenarios/TableEditor.cs b/UICatalog/Scenarios/TableEditor.cs new file mode 100644 index 000000000..9e32e9ca9 --- /dev/null +++ b/UICatalog/Scenarios/TableEditor.cs @@ -0,0 +1,394 @@ +using System; +using System.Collections.Generic; +using System.Data; +using Terminal.Gui; +using System.Linq; +using System.Globalization; + +namespace UICatalog.Scenarios { + + [ScenarioMetadata (Name: "TableEditor", Description: "A Terminal.Gui DataTable editor via TableView")] + [ScenarioCategory ("Controls")] + [ScenarioCategory ("Dialogs")] + [ScenarioCategory ("Text")] + [ScenarioCategory ("Dialogs")] + [ScenarioCategory ("TopLevel")] + public class TableEditor : Scenario + { + TableView tableView; + private MenuItem miAlwaysShowHeaders; + private MenuItem miHeaderOverline; + private MenuItem miHeaderMidline; + private MenuItem miHeaderUnderline; + private MenuItem miCellLines; + private MenuItem miFullRowSelect; + + public override void Setup () + { + Win.Title = this.GetName(); + Win.Y = 1; // menu + Win.Height = Dim.Fill (1); // status bar + Top.LayoutSubviews (); + + this.tableView = new TableView () { + X = 0, + Y = 0, + Width = Dim.Fill (), + Height = Dim.Fill (1), + }; + + var menu = new MenuBar (new MenuBarItem [] { + new MenuBarItem ("_File", new MenuItem [] { + new MenuItem ("_OpenBigExample", "", () => OpenExample(true)), + new MenuItem ("_OpenSmallExample", "", () => OpenExample(false)), + new MenuItem ("_CloseExample", "", () => CloseExample()), + new MenuItem ("_Quit", "", () => Quit()), + }), + new MenuBarItem ("_View", new MenuItem [] { + miAlwaysShowHeaders = new MenuItem ("_AlwaysShowHeaders", "", () => ToggleAlwaysShowHeader()){Checked = tableView.Style.AlwaysShowHeaders, CheckType = MenuItemCheckStyle.Checked }, + miHeaderOverline = new MenuItem ("_HeaderOverLine", "", () => ToggleOverline()){Checked = tableView.Style.ShowHorizontalHeaderOverline, CheckType = MenuItemCheckStyle.Checked }, + miHeaderMidline = new MenuItem ("_HeaderMidLine", "", () => ToggleHeaderMidline()){Checked = tableView.Style.ShowVerticalHeaderLines, CheckType = MenuItemCheckStyle.Checked }, + miHeaderUnderline =new MenuItem ("_HeaderUnderLine", "", () => ToggleUnderline()){Checked = tableView.Style.ShowHorizontalHeaderUnderline, CheckType = MenuItemCheckStyle.Checked }, + miFullRowSelect =new MenuItem ("_FullRowSelect", "", () => ToggleFullRowSelect()){Checked = tableView.FullRowSelect, CheckType = MenuItemCheckStyle.Checked }, + miCellLines =new MenuItem ("_CellLines", "", () => ToggleCellLines()){Checked = tableView.Style.ShowVerticalCellLines, CheckType = MenuItemCheckStyle.Checked }, + new MenuItem ("_AllLines", "", () => ToggleAllCellLines()), + new MenuItem ("_NoLines", "", () => ToggleNoCellLines()), + new MenuItem ("_ClearColumnStyles", "", () => ClearColumnStyles()), + }), + }); + Top.Add (menu); + + + + var statusBar = new StatusBar (new StatusItem [] { + new StatusItem(Key.F2, "~F2~ OpenExample", () => OpenExample(true)), + new StatusItem(Key.F3, "~F3~ CloseExample", () => CloseExample()), + new StatusItem(Key.F4, "~F4~ OpenSimple", () => OpenSimple(true)), + new StatusItem(Key.CtrlMask | Key.Q, "~^Q~ Quit", () => Quit()), + }); + Top.Add (statusBar); + + Win.Add (tableView); + + var selectedCellLabel = new Label(){ + X = 0, + Y = Pos.Bottom(tableView), + Text = "0,0", + Width = Dim.Fill(), + TextAlignment = TextAlignment.Right + + }; + + Win.Add(selectedCellLabel); + + tableView.SelectedCellChanged += (e)=>{selectedCellLabel.Text = $"{tableView.SelectedRow},{tableView.SelectedColumn}";}; + tableView.CellActivated += EditCurrentCell; + tableView.KeyPress += TableViewKeyPress; + + SetupScrollBar(); + } + + private void SetupScrollBar () + { + var _scrollBar = new ScrollBarView (tableView, true); + + _scrollBar.ChangedPosition += () => { + tableView.RowOffset = _scrollBar.Position; + if (tableView.RowOffset != _scrollBar.Position) { + _scrollBar.Position = tableView.RowOffset; + } + tableView.SetNeedsDisplay (); + }; + /* + _scrollBar.OtherScrollBarView.ChangedPosition += () => { + _listView.LeftItem = _scrollBar.OtherScrollBarView.Position; + if (_listView.LeftItem != _scrollBar.OtherScrollBarView.Position) { + _scrollBar.OtherScrollBarView.Position = _listView.LeftItem; + } + _listView.SetNeedsDisplay (); + };*/ + + tableView.DrawContent += (e) => { + _scrollBar.Size = tableView.Table?.Rows?.Count ??0; + _scrollBar.Position = tableView.RowOffset; + // _scrollBar.OtherScrollBarView.Size = _listView.Maxlength - 1; + // _scrollBar.OtherScrollBarView.Position = _listView.LeftItem; + _scrollBar.Refresh (); + }; + + } + + private void TableViewKeyPress (View.KeyEventEventArgs e) + { + if(e.KeyEvent.Key == Key.DeleteChar){ + + if(tableView.FullRowSelect) + { + // Delete button deletes all rows when in full row mode + foreach(int toRemove in tableView.GetAllSelectedCells().Select(p=>p.Y).Distinct().OrderByDescending(i=>i)) + tableView.Table.Rows.RemoveAt(toRemove); + } + else{ + + // otherwise set all selected cells to null + foreach(var pt in tableView.GetAllSelectedCells()) + { + tableView.Table.Rows[pt.Y][pt.X] = DBNull.Value; + } + } + + tableView.Update(); + e.Handled = true; + } + + + } + + private void ClearColumnStyles () + { + tableView.Style.ColumnStyles.Clear(); + tableView.Update(); + } + + private void ToggleAlwaysShowHeader () + { + miAlwaysShowHeaders.Checked = !miAlwaysShowHeaders.Checked; + tableView.Style.AlwaysShowHeaders = miAlwaysShowHeaders.Checked; + tableView.Update(); + } + + private void ToggleOverline () + { + miHeaderOverline.Checked = !miHeaderOverline.Checked; + tableView.Style.ShowHorizontalHeaderOverline = miHeaderOverline.Checked; + tableView.Update(); + } + private void ToggleHeaderMidline () + { + miHeaderMidline.Checked = !miHeaderMidline.Checked; + tableView.Style.ShowVerticalHeaderLines = miHeaderMidline.Checked; + tableView.Update(); + } + private void ToggleUnderline () + { + miHeaderUnderline.Checked = !miHeaderUnderline.Checked; + tableView.Style.ShowHorizontalHeaderUnderline = miHeaderUnderline.Checked; + tableView.Update(); + } + private void ToggleFullRowSelect () + { + miFullRowSelect.Checked = !miFullRowSelect.Checked; + tableView.FullRowSelect= miFullRowSelect.Checked; + tableView.Update(); + } + private void ToggleCellLines() + { + miCellLines.Checked = !miCellLines.Checked; + tableView.Style.ShowVerticalCellLines = miCellLines.Checked; + tableView.Update(); + } + private void ToggleAllCellLines() + { + tableView.Style.ShowHorizontalHeaderOverline = true; + tableView.Style.ShowVerticalHeaderLines = true; + tableView.Style.ShowHorizontalHeaderUnderline = true; + tableView.Style.ShowVerticalCellLines = true; + + miHeaderOverline.Checked = true; + miHeaderMidline.Checked = true; + miHeaderUnderline.Checked = true; + miCellLines.Checked = true; + + tableView.Update(); + } + private void ToggleNoCellLines() + { + tableView.Style.ShowHorizontalHeaderOverline = false; + tableView.Style.ShowVerticalHeaderLines = false; + tableView.Style.ShowHorizontalHeaderUnderline = false; + tableView.Style.ShowVerticalCellLines = false; + + miHeaderOverline.Checked = false; + miHeaderMidline.Checked = false; + miHeaderUnderline.Checked = false; + miCellLines.Checked = false; + + tableView.Update(); + } + + + private void CloseExample () + { + tableView.Table = null; + } + + private void Quit () + { + Application.RequestStop (); + } + + private void OpenExample (bool big) + { + tableView.Table = BuildDemoDataTable(big ? 30 : 5, big ? 1000 : 5); + SetDemoTableStyles(); + } + + private void SetDemoTableStyles () + { + var alignMid = new ColumnStyle() { + Alignment = TextAlignment.Centered + }; + var alignRight = new ColumnStyle() { + Alignment = TextAlignment.Right + }; + + var dateFormatStyle = new ColumnStyle() { + Alignment = TextAlignment.Right, + RepresentationGetter = (v)=> v is DateTime d ? d.ToString("yyyy-MM-dd"):v.ToString() + }; + + var negativeRight = new ColumnStyle() { + + Format = "0.##", + MinWidth = 10, + AlignmentGetter = (v)=>v is double d ? + // align negative values right + d < 0 ? TextAlignment.Right : + // align positive values left + TextAlignment.Left: + // not a double + TextAlignment.Left + }; + + tableView.Style.ColumnStyles.Add(tableView.Table.Columns["DateCol"],dateFormatStyle); + tableView.Style.ColumnStyles.Add(tableView.Table.Columns["DoubleCol"],negativeRight); + tableView.Style.ColumnStyles.Add(tableView.Table.Columns["NullsCol"],alignMid); + tableView.Style.ColumnStyles.Add(tableView.Table.Columns["IntCol"],alignRight); + + tableView.Update(); + } + + private void OpenSimple (bool big) + { + tableView.Table = BuildSimpleDataTable(big ? 30 : 5, big ? 1000 : 5); + } + + private void EditCurrentCell (CellActivatedEventArgs e) + { + if(e.Table == null) + return; + + var oldValue = e.Table.Rows[e.Row][e.Col].ToString(); + bool okPressed = false; + + var ok = new Button ("Ok", is_default: true); + ok.Clicked += () => { okPressed = true; Application.RequestStop (); }; + var cancel = new Button ("Cancel"); + cancel.Clicked += () => { Application.RequestStop (); }; + var d = new Dialog ("Enter new value", 60, 20, ok, cancel); + + var lbl = new Label() { + X = 0, + Y = 1, + Text = e.Table.Columns[e.Col].ColumnName + }; + + var tf = new TextField() + { + Text = oldValue, + X = 0, + Y = 2, + Width = Dim.Fill() + }; + + d.Add (lbl,tf); + tf.SetFocus(); + + Application.Run (d); + + if(okPressed) { + + try { + e.Table.Rows[e.Row][e.Col] = string.IsNullOrWhiteSpace(tf.Text.ToString()) ? DBNull.Value : (object)tf.Text; + } + catch(Exception ex) { + MessageBox.ErrorQuery(60,20,"Failed to set text", ex.Message,"Ok"); + } + + tableView.Update(); + } + } + + /// + /// Generates a new demo with the given number of (min 5) and + /// + /// + /// + /// + public static DataTable BuildDemoDataTable(int cols, int rows) + { + var dt = new DataTable(); + + int explicitCols = 6; + dt.Columns.Add(new DataColumn("StrCol",typeof(string))); + dt.Columns.Add(new DataColumn("DateCol",typeof(DateTime))); + dt.Columns.Add(new DataColumn("IntCol",typeof(int))); + dt.Columns.Add(new DataColumn("DoubleCol",typeof(double))); + dt.Columns.Add(new DataColumn("NullsCol",typeof(string))); + dt.Columns.Add(new DataColumn("Unicode",typeof(string))); + + for(int i=0;i< cols -explicitCols; i++) { + dt.Columns.Add("Column" + (i+explicitCols)); + } + + var r = new Random(100); + + for(int i=0;i< rows;i++) { + + List row = new List(){ + "Some long text that is super cool", + new DateTime(2000+i,12,25), + r.Next(i), + (r.NextDouble()*i)-0.5 /*add some negatives to demo styles*/, + DBNull.Value, + "Les Mise" + Char.ConvertFromUtf32(Int32.Parse("0301", NumberStyles.HexNumber)) + "rables" + }; + + for(int j=0;j< cols -explicitCols; j++) { + row.Add("SomeValue" + r.Next(100)); + } + + dt.Rows.Add(row.ToArray()); + } + + return dt; + } + + /// + /// Builds a simple table in which cell values contents are the index of the cell. This helps testing that scrolling etc is working correctly and not skipping out any rows/columns when paging + /// + /// + /// + /// + public static DataTable BuildSimpleDataTable(int cols, int rows) + { + var dt = new DataTable(); + + for(int c = 0; c < cols; c++) { + dt.Columns.Add("Col"+c); + } + + for(int r = 0; r < rows; r++) { + var newRow = dt.NewRow(); + + for(int c = 0; c < cols; c++) { + newRow[c] = $"R{r}C{c}"; + } + + dt.Rows.Add(newRow); + } + + return dt; + } + } +} diff --git a/UnitTests/TableViewTests.cs b/UnitTests/TableViewTests.cs new file mode 100644 index 000000000..fa39627fd --- /dev/null +++ b/UnitTests/TableViewTests.cs @@ -0,0 +1,447 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading.Tasks; +using Terminal.Gui; +using Xunit; +using System.Globalization; + +namespace UnitTests { + public class TableViewTests + { + + [Fact] + public void EnsureValidScrollOffsets_WithNoCells() + { + var tableView = new TableView(); + + Assert.Equal(0,tableView.RowOffset); + Assert.Equal(0,tableView.ColumnOffset); + + // Set empty table + tableView.Table = new DataTable(); + + // Since table has no rows or columns scroll offset should default to 0 + tableView.EnsureValidScrollOffsets(); + Assert.Equal(0,tableView.RowOffset); + Assert.Equal(0,tableView.ColumnOffset); + } + + + + [Fact] + public void EnsureValidScrollOffsets_LoadSmallerTable() + { + var tableView = new TableView(); + tableView.Bounds = new Rect(0,0,25,10); + + Assert.Equal(0,tableView.RowOffset); + Assert.Equal(0,tableView.ColumnOffset); + + // Set big table + tableView.Table = BuildTable(25,50); + + // Scroll down and along + tableView.RowOffset = 20; + tableView.ColumnOffset = 10; + + tableView.EnsureValidScrollOffsets(); + + // The scroll should be valid at the moment + Assert.Equal(20,tableView.RowOffset); + Assert.Equal(10,tableView.ColumnOffset); + + // Set small table + tableView.Table = BuildTable(2,2); + + // Setting a small table should automatically trigger fixing the scroll offsets to ensure valid cells + Assert.Equal(0,tableView.RowOffset); + Assert.Equal(0,tableView.ColumnOffset); + + + // Trying to set invalid indexes should not be possible + tableView.RowOffset = 20; + tableView.ColumnOffset = 10; + + Assert.Equal(1,tableView.RowOffset); + Assert.Equal(1,tableView.ColumnOffset); + } + + [Fact] + public void SelectedCellChanged_NotFiredForSameValue() + { + var tableView = new TableView(){ + Table = BuildTable(25,50) + }; + + bool called = false; + tableView.SelectedCellChanged += (e)=>{called=true;}; + + Assert.Equal(0,tableView.SelectedColumn); + Assert.False(called); + + // Changing value to same as it already was should not raise an event + tableView.SelectedColumn = 0; + + Assert.False(called); + + tableView.SelectedColumn = 10; + Assert.True(called); + } + + + + [Fact] + public void SelectedCellChanged_SelectedColumnIndexesCorrect() + { + var tableView = new TableView(){ + Table = BuildTable(25,50) + }; + + bool called = false; + tableView.SelectedCellChanged += (e)=>{ + called=true; + Assert.Equal(0,e.OldCol); + Assert.Equal(10,e.NewCol); + }; + + tableView.SelectedColumn = 10; + Assert.True(called); + } + + [Fact] + public void SelectedCellChanged_SelectedRowIndexesCorrect() + { + var tableView = new TableView(){ + Table = BuildTable(25,50) + }; + + bool called = false; + tableView.SelectedCellChanged += (e)=>{ + called=true; + Assert.Equal(0,e.OldRow); + Assert.Equal(10,e.NewRow); + }; + + tableView.SelectedRow = 10; + Assert.True(called); + } + + [Fact] + public void Test_SumColumnWidth_UnicodeLength() + { + Assert.Equal(11,"hello there".Sum(c=>Rune.ColumnWidth(c))); + + // Creates a string with the peculiar (french?) r symbol + String surrogate = "Les Mise" + Char.ConvertFromUtf32(Int32.Parse("0301", NumberStyles.HexNumber)) + "rables"; + + // The unicode width of this string is shorter than the string length! + Assert.Equal(14,surrogate.Sum(c=>Rune.ColumnWidth(c))); + Assert.Equal(15,surrogate.Length); + } + + [Fact] + public void IsSelected_MultiSelectionOn_Vertical() + { + var tableView = new TableView(){ + Table = BuildTable(25,50), + MultiSelect = true + }; + + // 3 cell vertical selection + tableView.SetSelection(1,1,false); + tableView.SetSelection(1,3,true); + + Assert.False(tableView.IsSelected(0,0)); + Assert.False(tableView.IsSelected(1,0)); + Assert.False(tableView.IsSelected(2,0)); + + Assert.False(tableView.IsSelected(0,1)); + Assert.True(tableView.IsSelected(1,1)); + Assert.False(tableView.IsSelected(2,1)); + + Assert.False(tableView.IsSelected(0,2)); + Assert.True(tableView.IsSelected(1,2)); + Assert.False(tableView.IsSelected(2,2)); + + Assert.False(tableView.IsSelected(0,3)); + Assert.True(tableView.IsSelected(1,3)); + Assert.False(tableView.IsSelected(2,3)); + + Assert.False(tableView.IsSelected(0,4)); + Assert.False(tableView.IsSelected(1,4)); + Assert.False(tableView.IsSelected(2,4)); + } + + + [Fact] + public void IsSelected_MultiSelectionOn_Horizontal() + { + var tableView = new TableView(){ + Table = BuildTable(25,50), + MultiSelect = true + }; + + // 2 cell horizontal selection + tableView.SetSelection(1,0,false); + tableView.SetSelection(2,0,true); + + Assert.False(tableView.IsSelected(0,0)); + Assert.True(tableView.IsSelected(1,0)); + Assert.True(tableView.IsSelected(2,0)); + Assert.False(tableView.IsSelected(3,0)); + + Assert.False(tableView.IsSelected(0,1)); + Assert.False(tableView.IsSelected(1,1)); + Assert.False(tableView.IsSelected(2,1)); + Assert.False(tableView.IsSelected(3,1)); + } + + + + [Fact] + public void IsSelected_MultiSelectionOn_BoxSelection() + { + var tableView = new TableView(){ + Table = BuildTable(25,50), + MultiSelect = true + }; + + // 4 cell horizontal in box 2x2 + tableView.SetSelection(0,0,false); + tableView.SetSelection(1,1,true); + + Assert.True(tableView.IsSelected(0,0)); + Assert.True(tableView.IsSelected(1,0)); + Assert.False(tableView.IsSelected(2,0)); + + Assert.True(tableView.IsSelected(0,1)); + Assert.True(tableView.IsSelected(1,1)); + Assert.False(tableView.IsSelected(2,1)); + + Assert.False(tableView.IsSelected(0,2)); + Assert.False(tableView.IsSelected(1,2)); + Assert.False(tableView.IsSelected(2,2)); + } + + [Fact] + public void PageDown_ExcludesHeaders() + { + + var driver = new FakeDriver (); + Application.Init (driver, new FakeMainLoop (() => FakeConsole.ReadKey (true))); + driver.Init (() => { }); + + + var tableView = new TableView(){ + Table = BuildTable(25,50), + MultiSelect = true, + Bounds = new Rect(0,0,10,5) + }; + + // Header should take up 2 lines + tableView.Style.ShowHorizontalHeaderOverline = false; + tableView.Style.ShowHorizontalHeaderUnderline = true; + tableView.Style.AlwaysShowHeaders = false; + + Assert.Equal(0,tableView.RowOffset); + + tableView.ProcessKey(new KeyEvent(Key.PageDown,new KeyModifiers())); + + // window height is 5 rows 2 are header so page down should give 3 new rows + Assert.Equal(3,tableView.RowOffset); + + // header is no longer visible so page down should give 5 new rows + tableView.ProcessKey(new KeyEvent(Key.PageDown,new KeyModifiers())); + + Assert.Equal(8,tableView.RowOffset); + } + + [Fact] + public void DeleteRow_SelectAll_AdjustsSelectionToPreventOverrun() + { + // create a 4 by 4 table + var tableView = new TableView(){ + Table = BuildTable(4,4), + MultiSelect = true, + Bounds = new Rect(0,0,10,5) + }; + + tableView.SelectAll(); + Assert.Equal(16,tableView.GetAllSelectedCells().Count()); + + // delete one of the columns + tableView.Table.Columns.RemoveAt(2); + + // table should now be 3x4 + Assert.Equal(12,tableView.GetAllSelectedCells().Count()); + + // remove a row + tableView.Table.Rows.RemoveAt(1); + + // table should now be 3x3 + Assert.Equal(9,tableView.GetAllSelectedCells().Count()); + } + + + [Fact] + public void DeleteRow_SelectLastRow_AdjustsSelectionToPreventOverrun() + { + // create a 4 by 4 table + var tableView = new TableView(){ + Table = BuildTable(4,4), + MultiSelect = true, + Bounds = new Rect(0,0,10,5) + }; + + // select the last row + tableView.MultiSelectedRegions.Clear(); + tableView.MultiSelectedRegions.Push(new TableSelection(new Point(0,3), new Rect(0,3,4,1))); + + Assert.Equal(4,tableView.GetAllSelectedCells().Count()); + + // remove a row + tableView.Table.Rows.RemoveAt(0); + + tableView.EnsureValidSelection(); + + // since the selection no longer exists it should be removed + Assert.Empty(tableView.MultiSelectedRegions); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void GetAllSelectedCells_SingleCellSelected_ReturnsOne(bool multiSelect) + { + var tableView = new TableView(){ + Table = BuildTable(3,3), + MultiSelect = multiSelect, + Bounds = new Rect(0,0,10,5) + }; + + tableView.SetSelection(1,1,false); + + Assert.Single(tableView.GetAllSelectedCells()); + Assert.Equal(new Point(1,1),tableView.GetAllSelectedCells().Single()); + } + + + [Fact] + public void GetAllSelectedCells_SquareSelection_ReturnsFour() + { + var tableView = new TableView(){ + Table = BuildTable(3,3), + MultiSelect = true, + Bounds = new Rect(0,0,10,5) + }; + + // move cursor to 1,1 + tableView.SetSelection(1,1,false); + // spread selection across to 2,2 (e.g. shift+right then shift+down) + tableView.SetSelection(2,2,true); + + var selected = tableView.GetAllSelectedCells().ToArray(); + + Assert.Equal(4,selected.Length); + Assert.Equal(new Point(1,1),selected[0]); + Assert.Equal(new Point(2,1),selected[1]); + Assert.Equal(new Point(1,2),selected[2]); + Assert.Equal(new Point(2,2),selected[3]); + } + + + [Fact] + public void GetAllSelectedCells_SquareSelection_FullRowSelect() + { + var tableView = new TableView(){ + Table = BuildTable(3,3), + MultiSelect = true, + FullRowSelect = true, + Bounds = new Rect(0,0,10,5) + }; + + // move cursor to 1,1 + tableView.SetSelection(1,1,false); + // spread selection across to 2,2 (e.g. shift+right then shift+down) + tableView.SetSelection(2,2,true); + + var selected = tableView.GetAllSelectedCells().ToArray(); + + Assert.Equal(6,selected.Length); + Assert.Equal(new Point(0,1),selected[0]); + Assert.Equal(new Point(1,1),selected[1]); + Assert.Equal(new Point(2,1),selected[2]); + Assert.Equal(new Point(0,2),selected[3]); + Assert.Equal(new Point(1,2),selected[4]); + Assert.Equal(new Point(2,2),selected[5]); + } + + + [Fact] + public void GetAllSelectedCells_TwoIsolatedSelections_ReturnsSix() + { + var tableView = new TableView(){ + Table = BuildTable(20,20), + MultiSelect = true, + Bounds = new Rect(0,0,10,5) + }; + + /* + Sets up disconnected selections like: + + 00000000000 + 01100000000 + 01100000000 + 00000001100 + 00000000000 + */ + + tableView.MultiSelectedRegions.Clear(); + tableView.MultiSelectedRegions.Push(new TableSelection(new Point(1,1),new Rect(1,1,2,2))); + tableView.MultiSelectedRegions.Push(new TableSelection(new Point(7,3),new Rect(7,3,2,1))); + + tableView.SelectedColumn = 8; + tableView.SelectedRow = 3; + + var selected = tableView.GetAllSelectedCells().ToArray(); + + Assert.Equal(6,selected.Length); + + Assert.Equal(new Point(1,1),selected[0]); + Assert.Equal(new Point(2,1),selected[1]); + Assert.Equal(new Point(1,2),selected[2]); + Assert.Equal(new Point(2,2),selected[3]); + Assert.Equal(new Point(7,3),selected[4]); + Assert.Equal(new Point(8,3),selected[5]); + } + + /// + /// Builds a simple table of string columns with the requested number of columns and rows + /// + /// + /// + /// + public static DataTable BuildTable(int cols, int rows) + { + var dt = new DataTable(); + + for(int c = 0; c < cols; c++) { + dt.Columns.Add("Col"+c); + } + + for(int r = 0; r < rows; r++) { + var newRow = dt.NewRow(); + + for(int c = 0; c < cols; c++) { + newRow[c] = $"R{r}C{c}"; + } + + dt.Rows.Add(newRow); + } + + return dt; + } + } +} \ No newline at end of file diff --git a/docfx/articles/tableview.md b/docfx/articles/tableview.md new file mode 100644 index 000000000..5bb34fa4b --- /dev/null +++ b/docfx/articles/tableview.md @@ -0,0 +1,56 @@ +# Table View + +This control supports viewing and editing tabular data. It provides a view of a [System.DataTable](https://docs.microsoft.com/en-us/dotnet/api/system.data.datatable?view=net-5.0). + +System.DataTable is a core class of .net standard and can be created very easily + +## Csv Example + +You can create a DataTable from a CSV file by creating a new instance and adding columns and rows as you read them. For a robust solution however you might want to look into a CSV parser library that deals with escaping, multi line rows etc. + +```csharp +var dt = new DataTable(); +var lines = File.ReadAllLines(filename); + +foreach(var h in lines[0].Split(',')){ + dt.Columns.Add(h); +} + + +foreach(var line in lines.Skip(1)) { + dt.Rows.Add(line.Split(',')); +} +``` + +## Database Example + +All Ado.net database providers (Oracle, MySql, SqlServer etc) support reading data as DataTables for example: + +```csharp +var dt = new DataTable(); + +using(var con = new SqlConnection("Server=myServerAddress;Database=myDataBase;Trusted_Connection=True;")) +{ + con.Open(); + var cmd = new SqlCommand("select * from myTable;",con); + var adapter = new SqlDataAdapter(cmd); + + adapter.Fill(dt); +} +``` + +## Displaying the table + +Once you have set up your data table set it in the view: + +```csharp +tableView = new TableView () { + X = 0, + Y = 0, + Width = 50, + Height = 10, +}; + +tableView.Table = yourDataTable; +``` +