2
0
Эх сурвалжийг харах

Support for column styles (alignment and format)

tznind 4 жил өмнө
parent
commit
ace6251414

+ 219 - 46
Terminal.Gui/Views/TableView.cs

@@ -6,6 +6,59 @@ using System.Linq;
 
 namespace Terminal.Gui.Views {
 
+	public class ColumnStyle {
+		
+		/// <summary>
+		/// Defines the default alignment for all values rendered in this column.  For custom alignment based on cell contents use <see cref="AlignmentGetter"/>.
+		/// </summary>
+		public TextAlignment Alignment {get;set;}
+	
+		/// <summary>
+		/// Defines a delegate for returning custom alignment per cell based on cell values.  When specified this will override <see cref="Alignment"/>
+		/// </summary>
+		public Func<object,TextAlignment> AlignmentGetter;
+
+		/// <summary>
+		/// Defines a delegate for returning custom representations of cell values.  If not set then <see cref="object.ToString()"/> is used.  Return values from your delegate may be truncated e.g. based on <see cref="MaxWidth"/>
+		/// </summary>
+		public Func<object,string> RepresentationGetter;
+
+		/// <summary>
+		/// Set the maximum width of the column in characters.  This value will be ignored if more than the tables <see cref="TableView.MaxCellWidth"/>.  Defaults to <see cref="TableView.DefaultMaxCellWidth"/>
+		/// </summary>
+		public int MaxWidth {get;set;} = TableView.DefaultMaxCellWidth;
+
+		/// <summary>
+		/// Set the minimum width of the column in characters.  This value will be ignored if more than the tables <see cref="TableView.MaxCellWidth"/> or the <see cref="MaxWidth"/>
+		/// </summary>
+		public int MinWidth {get;set;}
+
+		/// <summary>
+		/// Returns the alignment for the cell based on <paramref name="cellValue"/> and <see cref="AlignmentGetter"/>/<see cref="Alignment"/>
+		/// </summary>
+		/// <param name="cellValue"></param>
+		/// <returns></returns>
+		public TextAlignment GetAlignment(object cellValue)
+		{
+			if(AlignmentGetter != null)
+				return AlignmentGetter(cellValue);
+
+			return Alignment;
+		}
+
+		/// <summary>
+		/// Returns the full string to render (which may be truncated if too long) that the current style says best represents the given <paramref name="value"/>
+		/// </summary>
+		/// <param name="value"></param>
+		/// <returns></returns>
+		public string GetRepresentation (object value)
+		{
+			if(RepresentationGetter != null)
+				return RepresentationGetter(value);
+
+			return value?.ToString();
+		}
+	}
 	/// <summary>
 	/// Defines rendering options that affect how the table is displayed
 	/// </summary>
@@ -35,6 +88,21 @@ namespace Terminal.Gui.Views {
 		/// True to render a solid line vertical line between headers
 		/// </summary>
 		public bool ShowVerticalHeaderLines {get;set;} = true;
+
+		/// <summary>
+		/// Collection of columns for which you want special rendering (e.g. custom column lengths, text alignment etc)
+		/// </summary>
+		public Dictionary<DataColumn,ColumnStyle> ColumnStyles {get;set; }  = new Dictionary<DataColumn, ColumnStyle>();
+
+		/// <summary>
+		/// Returns the entry from <see cref="ColumnStyles"/> for the given <paramref name="col"/> or null if no custom styling is defined for it
+		/// </summary>
+		/// <param name="col"></param>
+		/// <returns></returns>
+		public ColumnStyle GetColumnStyleIfAny (DataColumn col)
+		{
+			return ColumnStyles.TryGetValue(col,out ColumnStyle result) ? result : null;
+		}
 	}
 	
 	/// <summary>
@@ -49,6 +117,11 @@ namespace Terminal.Gui.Views {
 		private DataTable table;
 		private TableStyle style = new TableStyle();
 
+		/// <summary>
+		/// The default maximum cell width for <see cref="TableView.MaxCellWidth"/> and <see cref="ColumnStyle.MaxWidth"/>
+		/// </summary>
+		public const int DefaultMaxCellWidth = 100;
+
 		/// <summary>
 		/// The data table to render in the view.  Setting this property automatically updates and redraws the control.
 		/// </summary>
@@ -100,7 +173,7 @@ namespace Terminal.Gui.Views {
 		/// <summary>
 		/// The maximum number of characters to render in any given column.  This prevents one long column from pushing out all the others
 		/// </summary>
-		public int MaximumCellWidth { get; set; } = 100;
+		public int MaxCellWidth { get; set; } = DefaultMaxCellWidth;
 
 		/// <summary>
 		/// The text representation that should be rendered for cells with the value <see cref="DBNull.Value"/>
@@ -136,7 +209,7 @@ namespace Terminal.Gui.Views {
 			var frame = Frame;
 
 			// What columns to render at what X offset in viewport
-			Dictionary<DataColumn, int> columnsToRender = CalculateViewport (bounds);
+			var columnsToRender = CalculateViewport(bounds).ToArray();
 
 			Driver.SetAttribute (ColorScheme.Normal);
 			
@@ -211,7 +284,7 @@ namespace Terminal.Gui.Views {
 			return heightRequired;
 		}
 
-		private void RenderHeaderOverline(int row,int availableWidth, Dictionary<DataColumn, int> columnsToRender)
+		private void RenderHeaderOverline(int row,int availableWidth, ColumnToRender[] columnsToRender)
 		{
 			// Renders a line above table headers (when visible) like:
 			// ┌────────────────────┬──────────┬───────────┬──────────────┬─────────┐
@@ -226,7 +299,7 @@ namespace Terminal.Gui.Views {
 						rune = Driver.ULCorner;
 					}	
 					// if the next column is the start of a header
-					else if(columnsToRender.Values.Contains(c+1)){
+					else if(columnsToRender.Any(r=>r.X == c+1)){
 						rune = Driver.TopTee;
 					}
 					else if(c == availableWidth -1){
@@ -238,7 +311,7 @@ namespace Terminal.Gui.Views {
 			}
 		}
 
-		private void RenderHeaderMidline(int row,int availableWidth, Dictionary<DataColumn, int> columnsToRender)
+		private void RenderHeaderMidline(int row,int availableWidth, ColumnToRender[] columnsToRender)
 		{
 			// Renders something like:
 			// │ArithmeticComparator│chi       │Healthboard│Interpretation│Labnumber│
@@ -249,15 +322,19 @@ namespace Terminal.Gui.Views {
 			if(style.ShowVerticalHeaderLines)
 				AddRune(0,row,Driver.VLine);
 
-			foreach (var kvp in columnsToRender) {
+			for(int i =0 ; i<columnsToRender.Length;i++) {
 				
-				//where the header should start
-				var col = kvp.Value;
+				var current =  columnsToRender[i];
+				var availableWidthForCell = GetCellWidth(columnsToRender,i,availableWidth);
+
+				var colStyle = Style.GetColumnStyleIfAny(current.Column);
+				var colName = current.Column.ColumnName;
 
-				RenderSeparator(col-1,row,true);
+				RenderSeparator(current.X-1,row,true);
 									
-				Move (col, row);
-				Driver.AddStr(Truncate (kvp.Key.ColumnName, availableWidth - kvp.Value));
+				Move (current.X, row);
+				
+				Driver.AddStr(TruncateOrPad(colName,colName,availableWidthForCell ,colStyle));
 
 			}
 
@@ -266,7 +343,29 @@ namespace Terminal.Gui.Views {
 				AddRune(availableWidth-1,row,Driver.VLine);
 		}
 
-		private void RenderHeaderUnderline(int row,int availableWidth, Dictionary<DataColumn, int> columnsToRender)
+		/// <summary>
+		/// Calculates how much space is available to render index <paramref name="i"/> of the <paramref name="columnsToRender"/> given the remaining horizontal space
+		/// </summary>
+		/// <param name="columnsToRender"></param>
+		/// <param name="i"></param>
+		/// <param name="availableWidth"></param>
+		private int GetCellWidth (ColumnToRender [] columnsToRender, int i,int availableWidth)
+		{
+			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 availableWidth - 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:
 			// ├──────────┼───────────┼───────────────────┼──────────┼────────┼─────────────┤
@@ -280,7 +379,7 @@ namespace Terminal.Gui.Views {
 						rune = Style.ShowVerticalCellLines ? Driver.LeftTee : Driver.LLCorner;
 					}	
 					// if the next column is the start of a header
-					else if(columnsToRender.Values.Contains(c+1)){
+					else if(columnsToRender.Any(r=>r.X == c+1)){
 					
 						/*TODO: is ┼ symbol in Driver?*/ 
 						rune = Style.ShowVerticalCellLines ? '┼' :Driver.BottomTee;
@@ -294,29 +393,37 @@ namespace Terminal.Gui.Views {
 			}
 			
 		}
-		private void RenderRow(int row, int availableWidth, int rowToRender, Dictionary<DataColumn, int> columnsToRender)
+		private void RenderRow(int row, int availableWidth, 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
-			foreach (var kvp in columnsToRender) {
+			for(int i=0;i< columnsToRender.Length ;i++) {
+
+				var current = columnsToRender[i];
+				var availableWidthForCell = GetCellWidth(columnsToRender,i,availableWidth);
+
+				var colStyle = Style.GetColumnStyleIfAny(current.Column);
 
 				// move to start of cell (in line with header positions)
-				Move (kvp.Value, row);
+				Move (current.X, row);
 
 				// Set color scheme based on whether the current cell is the selected one
-				bool isSelectedCell = rowToRender == SelectedRow && kvp.Key.Ordinal == SelectedColumn;
+				bool isSelectedCell = rowToRender == SelectedRow && current.Column.Ordinal == SelectedColumn;
 				Driver.SetAttribute (isSelectedCell ? ColorScheme.HotFocus : ColorScheme.Normal);
 
+				var val = Table.Rows [rowToRender][current.Column];
+
 				// Render the (possibly truncated) cell value
-				var valueToRender = GetRenderedVal (Table.Rows [rowToRender] [kvp.Key]);
-				Driver.AddStr (Truncate (valueToRender, availableWidth - kvp.Value));
+				var representation = GetRepresentation(val,colStyle);
+				
+				Driver.AddStr (TruncateOrPad(val,representation,availableWidthForCell,colStyle));
 				
 				// Reset color scheme to normal and render the vertical line (or space) at the end of the cell
 				Driver.SetAttribute (ColorScheme.Normal);
-				RenderSeparator(kvp.Value-1,row,false);
+				RenderSeparator(current.X-1,row,false);
 			}
 
 			//render end of line
@@ -342,17 +449,49 @@ namespace Terminal.Gui.Views {
 		}
 
 		/// <summary>
-		/// Truncates <paramref name="valueToRender"/> so that it occupies a maximum of <paramref name="availableHorizontalSpace"/>
+		/// Truncates or pads <paramref name="representation"/> so that it occupies a exactly <paramref name="availableHorizontalSpace"/> using the alignment specified in <paramref name="style"/> (or left if no style is defined)
 		/// </summary>
-		/// <param name="valueToRender"></param>
+		/// <param name="originalCellValue">The object in this cell of the <see cref="Table"/></param>
+		/// <param name="representation">The string representation of <paramref name="originalCellValue"/></param>
 		/// <param name="availableHorizontalSpace"></param>
+		/// <param name="colStyle">Optional style indicating custom alignment for the cell</param>
 		/// <returns></returns>
-		private ustring Truncate (string valueToRender, int availableHorizontalSpace)
+		private ustring TruncateOrPad (object originalCellValue,string representation, int availableHorizontalSpace, ColumnStyle colStyle)
 		{
-			if (string.IsNullOrEmpty (valueToRender) || valueToRender.Length < availableHorizontalSpace)
-				return valueToRender;
+			if (string.IsNullOrEmpty (representation))
+				return representation;
+
+			// if value is not wide enough
+			if(representation.Length < availableHorizontalSpace) {
+				
+				// pad it out with spaces to the given alignment
+				int toPad = availableHorizontalSpace - representation.Length;
+
+				switch(colStyle?.GetAlignment(originalCellValue) ?? TextAlignment.Left) {
+
+					case TextAlignment.Left : 
+						representation = representation.PadRight(toPad);
+						break;
+					case TextAlignment.Right : 
+						representation = representation.PadLeft(toPad);
+						break;
+					
+					// TODO: With single line cells, centered and justified are the same right?
+					case TextAlignment.Centered : 
+					case TextAlignment.Justified : 
+						//round down
+						representation = representation.PadRight((int)Math.Floor(toPad/2.0));
+						//round up
+						representation = representation.PadLeft((int)Math.Ceiling(toPad/2.0));
+						break;
+
+				}
+				
+				return representation;
+			}
 
-			return valueToRender.Substring (0, availableHorizontalSpace);
+			// value is too wide
+			return representation.Substring (0, availableHorizontalSpace);
 		}
 
 		/// <inheritdoc/>
@@ -428,16 +567,16 @@ namespace Terminal.Gui.Views {
 			SelectedColumn = Math.Max(Math.Min(SelectedColumn,Table.Columns.Count -1),0);
 			SelectedRow = Math.Max(Math.Min(SelectedRow,Table.Rows.Count -1),0);
 
-			Dictionary<DataColumn, int> columnsToRender = CalculateViewport (Bounds);
+			var columnsToRender = CalculateViewport (Bounds).ToArray();
 			var headerHeight = GetHeaderHeight();
 
 			//if we have scrolled too far to the left 
-			if (SelectedColumn < columnsToRender.Keys.Min (col => col.Ordinal)) {
+			if (SelectedColumn < columnsToRender.Min (r => r.Column.Ordinal)) {
 				ColumnOffset = SelectedColumn;
 			}
 
 			//if we have scrolled too far to the right
-			if (SelectedColumn > columnsToRender.Keys.Max (col => col.Ordinal)) {
+			if (SelectedColumn > columnsToRender.Max (r=> r.Column.Ordinal)) {
 				ColumnOffset = SelectedColumn;
 			}
 
@@ -459,12 +598,10 @@ namespace Terminal.Gui.Views {
 		/// <param name="bounds"></param>
 		/// <param name="padding"></param>
 		/// <returns></returns>
-		private Dictionary<DataColumn, int> CalculateViewport (Rect bounds, int padding = 1)
+		private IEnumerable<ColumnToRender> CalculateViewport (Rect bounds, int padding = 1)
 		{
-			Dictionary<DataColumn, int> toReturn = new Dictionary<DataColumn, int> ();
-
 			if(Table == null)
-				return toReturn;
+				yield break;
 			
 			int usedSpace = 0;
 
@@ -484,20 +621,19 @@ namespace Terminal.Gui.Views {
 			foreach (var col in Table.Columns.Cast<DataColumn>().Skip (ColumnOffset)) {
 
 				int startingIdxForCurrentHeader = usedSpace;
+				var colStyle = Style.GetColumnStyleIfAny(col);
 
 				// is there enough space for this column (and it's data)?
-				usedSpace += CalculateMaxRowSize (col, rowsToRender) + padding;
+				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)
-					return toReturn;
+					yield break;
 
 				// there is space
-				toReturn.Add (col, startingIdxForCurrentHeader);
+				yield return new ColumnToRender(col, startingIdxForCurrentHeader);
 				first=false;
 			}
-
-			return toReturn;
 		}
 
 		private bool ShouldRenderHeaders()
@@ -513,8 +649,9 @@ namespace Terminal.Gui.Views {
 		/// </summary>
 		/// <param name="col"></param>
 		/// <param name="rowsToRender"></param>
+		/// <param name="colStyle"></param>
 		/// <returns></returns>
-		private int CalculateMaxRowSize (DataColumn col, int rowsToRender)
+		private int CalculateMaxCellWidth(DataColumn col, int rowsToRender,ColumnStyle colStyle)
 		{
 			int spaceRequired = col.ColumnName.Length;
 
@@ -526,9 +663,28 @@ namespace Terminal.Gui.Views {
 			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, GetRenderedVal (Table.Rows [i] [col]).Length);
+				spaceRequired = Math.Max (spaceRequired, GetRepresentation(Table.Rows [i][col],colStyle).Length);
 			}
 
+			// 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;
 		}
 
@@ -536,20 +692,37 @@ namespace Terminal.Gui.Views {
 		/// Returns the value that should be rendered to best represent a strongly typed <paramref name="value"/> read from <see cref="Table"/>
 		/// </summary>
 		/// <param name="value"></param>
+		/// <param name="colStyle">Optional style defining how to represent cell values</param>
 		/// <returns></returns>
-		private string GetRenderedVal (object value)
+		private string GetRepresentation(object value,ColumnStyle colStyle)
 		{
 			if (value == null || value == DBNull.Value) {
 				return NullSymbol;
 			}
 
-			var representation = value.ToString ();
+			return colStyle != null ? colStyle.GetRepresentation(value): value.ToString();
+		}
+	}
 
-			//if it is too long to fit
-			if (representation.Length > MaximumCellWidth)
-				return representation.Substring (0, MaximumCellWidth);
+	/// <summary>
+	/// Describes a desire to render a column at a given horizontal position in the UI
+	/// </summary>
+	internal class ColumnToRender {
 
-			return representation;
+		/// <summary>
+		/// The column to render
+		/// </summary>
+		public DataColumn Column {get;set;}
+
+		/// <summary>
+		/// The horizontal position to begin rendering the column at
+		/// </summary>
+		public int X{get;set;}
+
+		public ColumnToRender (DataColumn col, int x)
+		{
+			Column = col;
+			X = x;
 		}
 	}
 }

+ 46 - 3
UICatalog/Scenarios/TableEditor.cs

@@ -38,6 +38,7 @@ namespace UICatalog.Scenarios {
 					new MenuItem ("_CellLines", "", () => ToggleCellLines()),
 					new MenuItem ("_AllLines", "", () => ToggleAllCellLines()),
 					new MenuItem ("_NoLines", "", () => ToggleNoCellLines()),
+					new MenuItem ("_ClearColumnStyles", "", () => ClearColumnStyles()),
 				}),
 			});
 			Top.Add (menu);
@@ -62,7 +63,11 @@ namespace UICatalog.Scenarios {
 			Win.Add (tableView);
 		}
 
-
+		private void ClearColumnStyles ()
+		{
+			tableView.Style.ColumnStyles.Clear();
+			tableView.Update();
+		}
 
 		private void ToggleAlwaysShowHeader ()
 		{
@@ -121,10 +126,48 @@ namespace UICatalog.Scenarios {
 		private void OpenExample (bool big)
 		{
 			tableView.Table = BuildDemoDataTable(big ? 30 : 5, big ? 1000 : 5);
+			SetDemoTableStyles();
 		}
-		private void OpenSimple (bool big)
+
+		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() {
+				
+				RepresentationGetter = (v)=> v is double d ? 
+								d.ToString("0.##"):
+								v.ToString(),
+				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);
 		}
 
@@ -202,7 +245,7 @@ namespace UICatalog.Scenarios {
 					"Some long text that is super cool",
 					new DateTime(2000+i,12,25),
 					r.Next(i),
-					r.NextDouble()*i,
+					(r.NextDouble()*i)-0.5 /*add some negatives to demo styles*/,
 					DBNull.Value
 				};