package renderer import ( "io" "slices" "strings" "github.com/olekukonko/tablewriter/pkg/twwidth" "github.com/olekukonko/ll" "github.com/olekukonko/tablewriter/tw" ) // OceanConfig defines configuration specific to the Ocean renderer. type OceanConfig struct{} // Ocean is a streaming table renderer that writes ASCII tables. type Ocean struct { config tw.Rendition oceanConfig OceanConfig fixedWidths tw.Mapper[int, int] widthsFinalized bool tableOutputStarted bool headerContentRendered bool // True if actual header *content* has been rendered by Ocean.Header logger *ll.Logger w io.Writer } func NewOcean(oceanConfig ...OceanConfig) *Ocean { cfg := defaultOceanRendererConfig() oCfg := OceanConfig{} if len(oceanConfig) > 0 { // Apply user-provided OceanConfig if necessary } r := &Ocean{ config: cfg, oceanConfig: oCfg, fixedWidths: tw.NewMapper[int, int](), logger: ll.New("ocean").Disable(), } r.resetState() return r } func (o *Ocean) resetState() { o.fixedWidths = tw.NewMapper[int, int]() o.widthsFinalized = false o.tableOutputStarted = false o.headerContentRendered = false o.logger.Debug("State reset.") } func (o *Ocean) Logger(logger *ll.Logger) { o.logger = logger.Namespace("ocean") } func (o *Ocean) Config() tw.Rendition { return o.config } func (o *Ocean) tryFinalizeWidths(ctx tw.Formatting) { if o.widthsFinalized { return } if ctx.Row.Widths != nil && ctx.Row.Widths.Len() > 0 { o.fixedWidths = ctx.Row.Widths.Clone() o.widthsFinalized = true o.logger.Debugf("Widths finalized from context: %v", o.fixedWidths) } else { o.logger.Warn("Attempted to finalize widths, but no width data in context.") } } func (o *Ocean) Start(w io.Writer) error { o.w = w o.logger.Debug("Start() called.") o.resetState() // Top border is drawn by the first component (Header or Row) that has widths // OR by an explicit Line() call from table.go's batch renderer. return nil } // renderTopBorderIfNeeded is called by Header or Row if it's the first to render // and tableOutputStarted is false. func (o *Ocean) renderTopBorderIfNeeded(currentPosition tw.Position, ctx tw.Formatting) { if !o.tableOutputStarted && o.widthsFinalized { // This renderer's config for Top border if o.config.Borders.Top.Enabled() && o.config.Settings.Lines.ShowTop.Enabled() { o.logger.Debugf("Ocean itself rendering top border (triggered by %s)", currentPosition) lineCtx := tw.Formatting{ // Construct specific context for this line Row: tw.RowContext{ Widths: o.fixedWidths, Location: tw.LocationFirst, Position: currentPosition, Next: ctx.Row.Current, // The actual first content is "Next" to the top border }, Level: tw.LevelHeader, } o.Line(lineCtx) o.tableOutputStarted = true } } } func (o *Ocean) Header(headers [][]string, ctx tw.Formatting) { o.logger.Debugf("Ocean.Header called: IsSubRow=%v, Location=%v, NumLines=%d", ctx.IsSubRow, ctx.Row.Location, len(headers)) if !o.widthsFinalized { o.tryFinalizeWidths(ctx) } if !o.widthsFinalized { o.logger.Error("Ocean.Header: Cannot render content, widths are not finalized.") return } if len(headers) > 0 && len(headers[0]) > 0 { for i, headerLineData := range headers { currentLineCtx := ctx currentLineCtx.Row.Widths = o.fixedWidths if i > 0 { currentLineCtx.IsSubRow = true } o.renderContentLine(currentLineCtx, headerLineData) o.tableOutputStarted = true // Content was written } o.headerContentRendered = true } else { o.logger.Debug("Ocean.Header: No actual header content lines to render.") } } func (o *Ocean) Row(row []string, ctx tw.Formatting) { o.logger.Debugf("Ocean.Row called: IsSubRow=%v, Location=%v, DataItems=%d", ctx.IsSubRow, ctx.Row.Location, len(row)) if !o.widthsFinalized { o.tryFinalizeWidths(ctx) } if !o.widthsFinalized { o.logger.Error("Ocean.Row: Cannot render content, widths are not finalized.") return } ctx.Row.Widths = o.fixedWidths o.renderContentLine(ctx, row) o.tableOutputStarted = true } func (o *Ocean) Footer(footers [][]string, ctx tw.Formatting) { o.logger.Debugf("Ocean.Footer called: IsSubRow=%v, Location=%v, NumLines=%d", ctx.IsSubRow, ctx.Row.Location, len(footers)) if !o.widthsFinalized { o.tryFinalizeWidths(ctx) o.logger.Warn("Ocean.Footer: Widths finalized at Footer stage (unusual).") } if !o.widthsFinalized { o.logger.Error("Ocean.Footer: Cannot render content, widths are not finalized.") return } if len(footers) > 0 && len(footers[0]) > 0 { for i, footerLineData := range footers { currentLineCtx := ctx currentLineCtx.Row.Widths = o.fixedWidths if i > 0 { currentLineCtx.IsSubRow = true } o.renderContentLine(currentLineCtx, footerLineData) o.tableOutputStarted = true } } else { o.logger.Debug("Ocean.Footer: No actual footer content lines to render.") } } func (o *Ocean) Line(ctx tw.Formatting) { if !o.widthsFinalized { o.tryFinalizeWidths(ctx) if !o.widthsFinalized { o.logger.Error("Ocean.Line: Called but widths could not be finalized. Skipping line rendering.") return } } // Ensure Line uses the consistent fixedWidths for drawing ctx.Row.Widths = o.fixedWidths o.logger.Debugf("Ocean.Line DRAWING: Level=%v, Loc=%s, Pos=%s, IsSubRow=%t, WidthsLen=%d", ctx.Level, ctx.Row.Location, ctx.Row.Position, ctx.IsSubRow, ctx.Row.Widths.Len()) jr := NewJunction(JunctionContext{ Symbols: o.config.Symbols, Ctx: ctx, ColIdx: 0, Logger: o.logger, BorderTint: Tint{}, SeparatorTint: Tint{}, }) var line strings.Builder sortedColIndices := o.fixedWidths.SortedKeys() if len(sortedColIndices) == 0 { drewEmptyBorders := false if o.config.Borders.Left.Enabled() { line.WriteString(jr.RenderLeft()) drewEmptyBorders = true } if o.config.Borders.Right.Enabled() { line.WriteString(jr.RenderRight(-1)) drewEmptyBorders = true } if drewEmptyBorders { line.WriteString(tw.NewLine) o.w.Write([]byte(line.String())) o.logger.Debug("Line: Drew empty table borders based on Line call.") } else { o.logger.Debug("Line: Handled empty table case (no columns, no borders).") } o.tableOutputStarted = drewEmptyBorders // A line counts as output return } if o.config.Borders.Left.Enabled() { line.WriteString(jr.RenderLeft()) } for i, colIdx := range sortedColIndices { jr.colIdx = colIdx segmentChar := jr.GetSegment() colVisualWidth := o.fixedWidths.Get(colIdx) if colVisualWidth <= 0 { // Still need to consider separators after zero-width columns } else { if segmentChar == tw.Empty { segmentChar = o.config.Symbols.Row() } segmentDisplayWidth := twwidth.Width(segmentChar) if segmentDisplayWidth <= 0 { segmentDisplayWidth = 1 } repeatCount := 0 if colVisualWidth > 0 { repeatCount = colVisualWidth / segmentDisplayWidth if repeatCount == 0 { repeatCount = 1 } } line.WriteString(strings.Repeat(segmentChar, repeatCount)) } if i < len(sortedColIndices)-1 && o.config.Settings.Separators.BetweenColumns.Enabled() { nextColIdx := sortedColIndices[i+1] line.WriteString(jr.RenderJunction(colIdx, nextColIdx)) } } if o.config.Borders.Right.Enabled() { lastColIdx := sortedColIndices[len(sortedColIndices)-1] line.WriteString(jr.RenderRight(lastColIdx)) } line.WriteString(tw.NewLine) o.w.Write([]byte(line.String())) o.tableOutputStarted = true o.logger.Debugf("Line rendered by explicit call: %s", strings.TrimSuffix(line.String(), tw.NewLine)) } func (o *Ocean) Close() error { o.logger.Debug("Ocean.Close() called.") o.resetState() return nil } func (o *Ocean) renderContentLine(ctx tw.Formatting, lineData []string) { if !o.widthsFinalized || o.fixedWidths.Len() == 0 { o.logger.Error("renderContentLine: Cannot render, fixedWidths not set or empty.") return } var output strings.Builder if o.config.Borders.Left.Enabled() { output.WriteString(o.config.Symbols.Column()) } sortedColIndices := o.fixedWidths.SortedKeys() for i, colIdx := range sortedColIndices { cellVisualWidth := o.fixedWidths.Get(colIdx) cellContent := tw.Empty align := tw.AlignDefault padding := tw.Padding{Left: tw.Space, Right: tw.Space} switch ctx.Row.Position { case tw.Header: align = tw.AlignCenter case tw.Footer: align = tw.AlignRight default: align = tw.AlignLeft } cellCtx, hasCellCtx := ctx.Row.Current[colIdx] if hasCellCtx { cellContent = cellCtx.Data if cellCtx.Align.Validate() == nil && cellCtx.Align != tw.AlignNone { align = cellCtx.Align } if cellCtx.Padding.Paddable() { padding = cellCtx.Padding } } else if colIdx < len(lineData) { cellContent = lineData[colIdx] } actualCellWidthToRender := cellVisualWidth isHMergeContinuation := false if hasCellCtx && cellCtx.Merge.Horizontal.Present { if cellCtx.Merge.Horizontal.Start { hSpan := cellCtx.Merge.Horizontal.Span if hSpan <= 0 { hSpan = 1 } currentMergeTotalRenderWidth := 0 for k := 0; k < hSpan; k++ { idxInMergeSpan := colIdx + k // Check if idxInMergeSpan is a defined column in fixedWidths foundInFixedWidths := false if slices.Contains(sortedColIndices, idxInMergeSpan) { currentMergeTotalRenderWidth += o.fixedWidths.Get(idxInMergeSpan) foundInFixedWidths = true } if !foundInFixedWidths && idxInMergeSpan <= sortedColIndices[len(sortedColIndices)-1] { o.logger.Debugf("Col %d in HMerge span not found in fixedWidths, assuming 0-width contribution.", idxInMergeSpan) } if k < hSpan-1 && o.config.Settings.Separators.BetweenColumns.Enabled() { currentMergeTotalRenderWidth += twwidth.Width(o.config.Symbols.Column()) } } actualCellWidthToRender = currentMergeTotalRenderWidth } else { isHMergeContinuation = true } } if isHMergeContinuation { o.logger.Debugf("renderContentLine: Col %d is HMerge continuation, skipping content render.", colIdx) // The separator logic below needs to handle this correctly. // If the *previous* column was the start of a merge that spans *this* column, // then the separator after the previous column should have been suppressed. } else if actualCellWidthToRender > 0 { formattedCell := o.formatCellContent(cellContent, actualCellWidthToRender, padding, align) output.WriteString(formattedCell) } else { o.logger.Debugf("renderContentLine: col %d has 0 render width, writing no content.", colIdx) } // Add column separator if: // 1. It's not the last column in sortedColIndices // 2. Separators are enabled // 3. This cell is NOT a horizontal merge start that spans over the next column. if i < len(sortedColIndices)-1 && o.config.Settings.Separators.BetweenColumns.Enabled() { shouldAddSeparator := true if hasCellCtx && cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start { // If this merge start spans beyond the current colIdx into the next sortedColIndex if colIdx+cellCtx.Merge.Horizontal.Span > sortedColIndices[i+1] { shouldAddSeparator = false // Separator is part of the merged cell's width o.logger.Debugf("renderContentLine: Suppressed separator after HMerge col %d.", colIdx) } } if shouldAddSeparator { output.WriteString(o.config.Symbols.Column()) } } } if o.config.Borders.Right.Enabled() { output.WriteString(o.config.Symbols.Column()) } output.WriteString(tw.NewLine) o.w.Write([]byte(output.String())) o.logger.Debugf("Content line rendered: %s", strings.TrimSuffix(output.String(), tw.NewLine)) } func (o *Ocean) formatCellContent(content string, cellVisualWidth int, padding tw.Padding, align tw.Align) string { if cellVisualWidth <= 0 { return tw.Empty } contentDisplayWidth := twwidth.Width(content) padLeftChar := padding.Left if padLeftChar == tw.Empty { padLeftChar = tw.Space } padRightChar := padding.Right if padRightChar == tw.Empty { padRightChar = tw.Space } padLeftDisplayWidth := twwidth.Width(padLeftChar) padRightDisplayWidth := twwidth.Width(padRightChar) spaceForContentAndAlignment := max(cellVisualWidth-padLeftDisplayWidth-padRightDisplayWidth, 0) if contentDisplayWidth > spaceForContentAndAlignment { content = twwidth.Truncate(content, spaceForContentAndAlignment) contentDisplayWidth = twwidth.Width(content) } remainingSpace := max(spaceForContentAndAlignment-contentDisplayWidth, 0) var PL, PR string switch align { case tw.AlignRight: PL = strings.Repeat(tw.Space, remainingSpace) case tw.AlignCenter: leftSpaces := remainingSpace / 2 rightSpaces := remainingSpace - leftSpaces PL = strings.Repeat(tw.Space, leftSpaces) PR = strings.Repeat(tw.Space, rightSpaces) default: PR = strings.Repeat(tw.Space, remainingSpace) } var sb strings.Builder sb.WriteString(padLeftChar) sb.WriteString(PL) sb.WriteString(content) sb.WriteString(PR) sb.WriteString(padRightChar) currentFormattedWidth := twwidth.Width(sb.String()) if currentFormattedWidth < cellVisualWidth { if align == tw.AlignRight { prefixSpaces := strings.Repeat(tw.Space, cellVisualWidth-currentFormattedWidth) finalStr := prefixSpaces + sb.String() sb.Reset() sb.WriteString(finalStr) } else { sb.WriteString(strings.Repeat(tw.Space, cellVisualWidth-currentFormattedWidth)) } } else if currentFormattedWidth > cellVisualWidth { tempStr := sb.String() sb.Reset() sb.WriteString(twwidth.Truncate(tempStr, cellVisualWidth)) o.logger.Warnf("formatCellContent: Final string '%s' (width %d) exceeded target %d. Force truncated.", tempStr, currentFormattedWidth, cellVisualWidth) } return sb.String() } func (o *Ocean) Rendition(config tw.Rendition) { o.config = mergeRendition(o.config, config) o.logger.Debugf("Blueprint.Rendition updated. New internal config: %+v", o.config) } // Ensure Blueprint implements tw.Renditioning var _ tw.Renditioning = (*Ocean)(nil)