diff --git a/custom/autoscaling/groups/render.go b/custom/autoscaling/groups/render.go index adfabcf1..89b889dd 100644 --- a/custom/autoscaling/groups/render.go +++ b/custom/autoscaling/groups/render.go @@ -6,6 +6,7 @@ import ( "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // AutoScalingGroupRenderer renders Auto Scaling Groups @@ -192,9 +193,9 @@ func (r *AutoScalingGroupRenderer) RenderDetail(resource dao.Resource) string { } if proc.ProcessName != nil { if reason != "" { - d.FieldStyled(*proc.ProcessName, reason, render.WarningStyle()) + d.FieldStyled(*proc.ProcessName, reason, ui.WarningStyle()) } else { - d.FieldStyled(*proc.ProcessName, "Suspended", render.WarningStyle()) + d.FieldStyled(*proc.ProcessName, "Suspended", ui.WarningStyle()) } } } diff --git a/custom/cloudformation/events/render.go b/custom/cloudformation/events/render.go index fc0e612d..379b813e 100644 --- a/custom/cloudformation/events/render.go +++ b/custom/cloudformation/events/render.go @@ -6,6 +6,7 @@ import ( "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // EventRenderer renders CloudFormation stack events @@ -140,13 +141,13 @@ func (r *EventRenderer) RenderSummary(resource dao.Resource) []render.SummaryFie func cfnResourceStatusColorer(status string) render.Style { switch { case strings.HasSuffix(status, "_COMPLETE") && !strings.Contains(status, "ROLLBACK") && !strings.Contains(status, "DELETE"): - return render.SuccessStyle() + return ui.SuccessStyle() case strings.Contains(status, "IN_PROGRESS"): - return render.WarningStyle() + return ui.WarningStyle() case strings.Contains(status, "FAILED") || strings.Contains(status, "ROLLBACK"): - return render.DangerStyle() + return ui.DangerStyle() case strings.Contains(status, "DELETE_COMPLETE") || strings.Contains(status, "SKIPPED"): - return render.DimStyle() + return ui.DimStyle() default: return render.DefaultStyle() } diff --git a/custom/cloudformation/resources/render.go b/custom/cloudformation/resources/render.go index 89329cc3..3874efad 100644 --- a/custom/cloudformation/resources/render.go +++ b/custom/cloudformation/resources/render.go @@ -8,6 +8,7 @@ import ( "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/registry" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // Ensure ResourceRenderer implements render.Navigator @@ -148,13 +149,13 @@ func (r *ResourceRenderer) RenderSummary(resource dao.Resource) []render.Summary func cfnResourceStatusColorer(status string) render.Style { switch { case strings.HasSuffix(status, "_COMPLETE") && !strings.Contains(status, "ROLLBACK") && !strings.Contains(status, "DELETE"): - return render.SuccessStyle() + return ui.SuccessStyle() case strings.Contains(status, "IN_PROGRESS"): - return render.WarningStyle() + return ui.WarningStyle() case strings.Contains(status, "FAILED") || strings.Contains(status, "ROLLBACK"): - return render.DangerStyle() + return ui.DangerStyle() case strings.Contains(status, "DELETE_COMPLETE") || strings.Contains(status, "SKIPPED"): - return render.DimStyle() + return ui.DimStyle() default: return render.DefaultStyle() } @@ -164,11 +165,11 @@ func cfnResourceStatusColorer(status string) render.Style { func driftColorer(status string) render.Style { switch status { case "IN_SYNC": - return render.SuccessStyle() + return ui.SuccessStyle() case "MODIFIED", "DELETED": - return render.DangerStyle() + return ui.DangerStyle() case "NOT_CHECKED": - return render.DimStyle() + return ui.DimStyle() default: return render.DefaultStyle() } diff --git a/custom/cloudformation/stacks/render.go b/custom/cloudformation/stacks/render.go index 3984ee0e..2305346d 100644 --- a/custom/cloudformation/stacks/render.go +++ b/custom/cloudformation/stacks/render.go @@ -7,6 +7,7 @@ import ( appaws "github.com/clawscli/claws/internal/aws" "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // Ensure StackRenderer implements render.Navigator @@ -244,13 +245,13 @@ func (r *StackRenderer) RenderSummary(resource dao.Resource) []render.SummaryFie func cfnStateColorer(status string) render.Style { switch { case strings.HasSuffix(status, "_COMPLETE") && !strings.Contains(status, "ROLLBACK") && !strings.Contains(status, "DELETE"): - return render.SuccessStyle() + return ui.SuccessStyle() case strings.Contains(status, "IN_PROGRESS"): - return render.WarningStyle() + return ui.WarningStyle() case strings.Contains(status, "FAILED") || strings.Contains(status, "ROLLBACK"): - return render.DangerStyle() + return ui.DangerStyle() case strings.Contains(status, "DELETE_COMPLETE"): - return render.DimStyle() + return ui.DimStyle() default: return render.DefaultStyle() } @@ -260,11 +261,11 @@ func cfnStateColorer(status string) render.Style { func driftColorer(status string) render.Style { switch status { case "IN_SYNC": - return render.SuccessStyle() + return ui.SuccessStyle() case "DRIFTED": - return render.DangerStyle() + return ui.DangerStyle() case "NOT_CHECKED": - return render.DimStyle() + return ui.DimStyle() default: return render.DefaultStyle() } diff --git a/custom/cloudfront/distributions/render.go b/custom/cloudfront/distributions/render.go index cb941c51..d33b498e 100644 --- a/custom/cloudfront/distributions/render.go +++ b/custom/cloudfront/distributions/render.go @@ -6,6 +6,7 @@ import ( "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // DistributionRenderer renders CloudFront distributions @@ -165,7 +166,7 @@ func (r *DistributionRenderer) RenderDetail(resource dao.Resource) string { d.Field("Price Class", dist.PriceClass()) d.Field("HTTP Version", dist.HttpVersion()) if dist.IsIPV6Enabled { - d.FieldStyled("IPv6", "Enabled", render.SuccessStyle()) + d.FieldStyled("IPv6", "Enabled", ui.SuccessStyle()) } else { d.Field("IPv6", "Disabled") } @@ -173,7 +174,7 @@ func (r *DistributionRenderer) RenderDetail(resource dao.Resource) string { // Access Logging if dist.Logging != nil && dist.Logging.Enabled != nil && *dist.Logging.Enabled { d.Section("Access Logging") - d.FieldStyled("Status", "Enabled", render.SuccessStyle()) + d.FieldStyled("Status", "Enabled", ui.SuccessStyle()) if dist.Logging.Bucket != nil { d.Field("Bucket", *dist.Logging.Bucket) } diff --git a/custom/cloudwatch/log-streams/actions.go b/custom/cloudwatch/log-streams/actions.go index 39226dfe..a4502230 100644 --- a/custom/cloudwatch/log-streams/actions.go +++ b/custom/cloudwatch/log-streams/actions.go @@ -34,17 +34,13 @@ func executeLogStreamAction(ctx context.Context, act action.Action, resource dao } } -func getCloudWatchLogsClient(ctx context.Context) (*cloudwatchlogs.Client, error) { - return cwClient.GetLogsClient(ctx) -} - func executeDeleteLogStream(ctx context.Context, resource dao.Resource) action.ActionResult { ls, ok := dao.UnwrapResource(resource).(*LogStreamResource) if !ok { return action.InvalidResourceResult() } - client, err := getCloudWatchLogsClient(ctx) + client, err := cwClient.GetLogsClient(ctx) if err != nil { return action.ActionResult{Success: false, Error: err} } diff --git a/custom/codebuild/projects/render.go b/custom/codebuild/projects/render.go index 73f0b3f5..59d6bb94 100644 --- a/custom/codebuild/projects/render.go +++ b/custom/codebuild/projects/render.go @@ -6,6 +6,7 @@ import ( "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // ProjectRenderer renders CodeBuild projects @@ -164,13 +165,13 @@ func (r *ProjectRenderer) RenderDetail(resource dao.Resource) string { logs := project.Project.LogsConfig d.Section("Logging") if logs.CloudWatchLogs != nil && logs.CloudWatchLogs.Status == "ENABLED" { - d.FieldStyled("CloudWatch Logs", "Enabled", render.SuccessStyle()) + d.FieldStyled("CloudWatch Logs", "Enabled", ui.SuccessStyle()) if logs.CloudWatchLogs.GroupName != nil { d.Field(" Log Group", *logs.CloudWatchLogs.GroupName) } } if logs.S3Logs != nil && logs.S3Logs.Status == "ENABLED" { - d.FieldStyled("S3 Logs", "Enabled", render.SuccessStyle()) + d.FieldStyled("S3 Logs", "Enabled", ui.SuccessStyle()) if logs.S3Logs.Location != nil { d.Field(" Location", *logs.S3Logs.Location) } diff --git a/custom/ecs/services/render.go b/custom/ecs/services/render.go index 9056c1e1..607d2d9f 100644 --- a/custom/ecs/services/render.go +++ b/custom/ecs/services/render.go @@ -7,6 +7,7 @@ import ( appaws "github.com/clawscli/claws/internal/aws" "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // ServiceRenderer renders ECS services @@ -155,7 +156,7 @@ func (r *ServiceRenderer) RenderDetail(resource dao.Resource) string { if dc.DeploymentCircuitBreaker != nil { cb := dc.DeploymentCircuitBreaker if cb.Enable { - d.FieldStyled("Circuit Breaker", "Enabled", render.SuccessStyle()) + d.FieldStyled("Circuit Breaker", "Enabled", ui.SuccessStyle()) if cb.Rollback { d.Field("Rollback on Failure", "Enabled") } diff --git a/custom/elasticache/clusters/render.go b/custom/elasticache/clusters/render.go index c41c5698..bded6a6d 100644 --- a/custom/elasticache/clusters/render.go +++ b/custom/elasticache/clusters/render.go @@ -6,6 +6,7 @@ import ( "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // ClusterRenderer renders ElastiCache clusters @@ -151,7 +152,7 @@ func (r *ClusterRenderer) RenderDetail(resource dao.Resource) string { nodeId = *node.CacheNodeId } if status == "available" { - d.FieldStyled(nodeId, status, render.SuccessStyle()) + d.FieldStyled(nodeId, status, ui.SuccessStyle()) } else { d.Field(nodeId, status) } @@ -161,17 +162,17 @@ func (r *ClusterRenderer) RenderDetail(resource dao.Resource) string { // Security d.Section("Security") if cluster.TransitEncryptionEnabled() { - d.FieldStyled("Transit Encryption", "Enabled", render.SuccessStyle()) + d.FieldStyled("Transit Encryption", "Enabled", ui.SuccessStyle()) } else { d.Field("Transit Encryption", "Disabled") } if cluster.AtRestEncryptionEnabled() { - d.FieldStyled("At-Rest Encryption", "Enabled", render.SuccessStyle()) + d.FieldStyled("At-Rest Encryption", "Enabled", ui.SuccessStyle()) } else { d.Field("At-Rest Encryption", "Disabled") } if cluster.Item.AuthTokenEnabled != nil && *cluster.Item.AuthTokenEnabled { - d.FieldStyled("AUTH Token", "Enabled", render.SuccessStyle()) + d.FieldStyled("AUTH Token", "Enabled", ui.SuccessStyle()) } d.Field("Auto Minor Version Upgrade", formatBool(cluster.AutoMinorVersionUpgrade())) diff --git a/custom/secretsmanager/secrets/render.go b/custom/secretsmanager/secrets/render.go index eafa3a21..714b8ce7 100644 --- a/custom/secretsmanager/secrets/render.go +++ b/custom/secretsmanager/secrets/render.go @@ -6,6 +6,7 @@ import ( "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // SecretRenderer renders Secrets Manager secrets @@ -86,7 +87,7 @@ func (r *SecretRenderer) RenderDetail(resource dao.Resource) string { // Deletion Status if secret.DeletedDate != nil { d.Section("Deletion") - d.FieldStyled("Status", "SCHEDULED FOR DELETION", render.DangerStyle()) + d.FieldStyled("Status", "SCHEDULED FOR DELETION", ui.DangerStyle()) d.Field("Deletion Date", *secret.DeletedDate) } @@ -101,7 +102,7 @@ func (r *SecretRenderer) RenderDetail(resource dao.Resource) string { // Rotation d.Section("Rotation") if secret.RotationEnabled { - d.FieldStyled("Rotation", "Enabled", render.SuccessStyle()) + d.FieldStyled("Rotation", "Enabled", ui.SuccessStyle()) if secret.RotationLambdaARN != "" { d.Field("Lambda ARN", secret.RotationLambdaARN) } diff --git a/custom/sns/topics/render.go b/custom/sns/topics/render.go index b8f983ee..bd990903 100644 --- a/custom/sns/topics/render.go +++ b/custom/sns/topics/render.go @@ -6,6 +6,7 @@ import ( "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // Ensure TopicRenderer implements render.Navigator @@ -149,23 +150,23 @@ func (r *TopicRenderer) RenderDetail(resource dao.Resource) string { } if role, ok := tr.Attrs["HTTPSuccessFeedbackRoleArn"]; ok && role != "" { loggingSection() - d.FieldStyled("HTTP", "Enabled", render.SuccessStyle()) + d.FieldStyled("HTTP", "Enabled", ui.SuccessStyle()) } if role, ok := tr.Attrs["LambdaSuccessFeedbackRoleArn"]; ok && role != "" { loggingSection() - d.FieldStyled("Lambda", "Enabled", render.SuccessStyle()) + d.FieldStyled("Lambda", "Enabled", ui.SuccessStyle()) } if role, ok := tr.Attrs["SQSSuccessFeedbackRoleArn"]; ok && role != "" { loggingSection() - d.FieldStyled("SQS", "Enabled", render.SuccessStyle()) + d.FieldStyled("SQS", "Enabled", ui.SuccessStyle()) } if role, ok := tr.Attrs["FirehoseSuccessFeedbackRoleArn"]; ok && role != "" { loggingSection() - d.FieldStyled("Firehose", "Enabled", render.SuccessStyle()) + d.FieldStyled("Firehose", "Enabled", ui.SuccessStyle()) } if role, ok := tr.Attrs["ApplicationSuccessFeedbackRoleArn"]; ok && role != "" { loggingSection() - d.FieldStyled("Application", "Enabled", render.SuccessStyle()) + d.FieldStyled("Application", "Enabled", ui.SuccessStyle()) } // Encryption diff --git a/custom/stepfunctions/state-machines/render.go b/custom/stepfunctions/state-machines/render.go index 05893ab4..4ac67efe 100644 --- a/custom/stepfunctions/state-machines/render.go +++ b/custom/stepfunctions/state-machines/render.go @@ -7,6 +7,7 @@ import ( "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // Ensure StateMachineRenderer implements render.Navigator @@ -131,7 +132,7 @@ func (r *StateMachineRenderer) RenderDetail(resource dao.Resource) string { tc := sr.Detail.TracingConfiguration d.Section("Tracing (X-Ray)") if tc.Enabled { - d.FieldStyled("X-Ray Tracing", "Enabled", render.SuccessStyle()) + d.FieldStyled("X-Ray Tracing", "Enabled", ui.SuccessStyle()) } else { d.Field("X-Ray Tracing", "Disabled") } diff --git a/custom/vpc/subnets/render.go b/custom/vpc/subnets/render.go index 2ca10bac..9b1e16b7 100644 --- a/custom/vpc/subnets/render.go +++ b/custom/vpc/subnets/render.go @@ -3,11 +3,10 @@ package subnets import ( "fmt" - "charm.land/lipgloss/v2" - appaws "github.com/clawscli/claws/internal/aws" "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // Ensure SubnetRenderer implements render.Navigator @@ -126,9 +125,9 @@ func (r *SubnetRenderer) RenderDetail(resource dao.Resource) string { // Public/Private subnet indicator if sr.IsPublic() { - d.FieldStyled("Subnet Type", "Public", lipgloss.NewStyle().Foreground(lipgloss.Color("42"))) + d.FieldStyled("Subnet Type", "Public", ui.SuccessStyle()) } else { - d.FieldStyled("Subnet Type", "Private", lipgloss.NewStyle().Foreground(lipgloss.Color("33"))) + d.FieldStyled("Subnet Type", "Private", ui.SecondaryStyle()) } if sr.Item.AvailabilityZoneId != nil { diff --git a/custom/vpc/vpcs/render.go b/custom/vpc/vpcs/render.go index 6df16214..f9e3e5ba 100644 --- a/custom/vpc/vpcs/render.go +++ b/custom/vpc/vpcs/render.go @@ -6,6 +6,7 @@ import ( appaws "github.com/clawscli/claws/internal/aws" "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" + "github.com/clawscli/claws/internal/ui" ) // Ensure VPCRenderer implements render.Navigator @@ -117,12 +118,12 @@ func (r *VPCRenderer) RenderDetail(resource dao.Resource) string { // DNS Settings d.Section("DNS Settings") if vr.EnableDnsSupport { - d.FieldStyled("DNS Resolution", "Enabled", render.SuccessStyle()) + d.FieldStyled("DNS Resolution", "Enabled", ui.SuccessStyle()) } else { d.Field("DNS Resolution", "Disabled") } if vr.EnableDnsHostnames { - d.FieldStyled("DNS Hostnames", "Enabled", render.SuccessStyle()) + d.FieldStyled("DNS Hostnames", "Enabled", ui.SuccessStyle()) } else { d.Field("DNS Hostnames", "Disabled") } diff --git a/internal/action/exec_with_header.go b/internal/action/exec_with_header.go index 720bce37..a0a696b9 100644 --- a/internal/action/exec_with_header.go +++ b/internal/action/exec_with_header.go @@ -1,13 +1,13 @@ package action import ( + "cmp" "fmt" "io" "os" "os/exec" "strings" - "charm.land/lipgloss/v2" "golang.org/x/term" "github.com/clawscli/claws/internal/aws" @@ -18,9 +18,7 @@ import ( func setAWSEnv(cmd *exec.Cmd, region string) { cfg := config.Global() - if region == "" { - region = cfg.Region() - } + region = cmp.Or(region, cfg.Region()) cmd.Env = aws.BuildSubprocessEnv(cmd.Env, cfg.Selection(), region) } @@ -149,8 +147,7 @@ func (e *ExecWithHeader) Run() error { _, _ = fmt.Fprint(stdout, header) // Print separator - separatorStyle := lipgloss.NewStyle().Foreground(ui.Current().TextDim) - _, _ = fmt.Fprintln(stdout, separatorStyle.Render(strings.Repeat("─", width))) + _, _ = fmt.Fprintln(stdout, ui.DimStyle().Render(strings.Repeat("─", width))) headerLines++ // Set scroll region to exclude header (1-indexed) @@ -186,7 +183,7 @@ func (e *ExecWithHeader) Run() error { // If command failed, show error and wait for keypress if err != nil { - errorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000")).Bold(true) + errorStyle := ui.BoldDangerStyle() _, _ = fmt.Fprintln(stdout) _, _ = fmt.Fprintln(stdout, errorStyle.Render("Command failed: ")+err.Error()) _, _ = fmt.Fprintln(stdout) @@ -210,37 +207,22 @@ func (e *ExecWithHeader) buildHeader(_ int) string { } accountID := config.Global().AccountID() - // Styles - t := ui.Current() - - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(t.Primary) - - labelStyle := lipgloss.NewStyle(). - Foreground(t.TextDim) - - valueStyle := lipgloss.NewStyle(). - Foreground(t.TextBright) - - regionStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(t.Secondary) + titleStyle := ui.TitleStyle() + labelStyle := ui.DimStyle() + valueStyle := ui.TextBrightStyle() + regionStyle := ui.SectionStyle() var lines []string - // Title line title := fmt.Sprintf("%s/%s", e.Service, e.ResType) lines = append(lines, titleStyle.Render(title)) - // Resource info resourceLine := labelStyle.Render("Resource: ") + valueStyle.Render(e.Resource.GetName()) if id := e.Resource.GetID(); id != e.Resource.GetName() { resourceLine += labelStyle.Render(" (") + valueStyle.Render(id) + labelStyle.Render(")") } lines = append(lines, resourceLine) - // Context line: Profile, Region, Account contextParts := []string{ labelStyle.Render("Profile: ") + valueStyle.Render(profileDisplay), } @@ -252,9 +234,7 @@ func (e *ExecWithHeader) buildHeader(_ int) string { } lines = append(lines, strings.Join(contextParts, " ")) - // Hint line - hintStyle := lipgloss.NewStyle().Foreground(ui.Current().TextDim).Italic(true) - lines = append(lines, hintStyle.Render("Press Ctrl+D or type 'exit' to return to claws")) + lines = append(lines, ui.DimStyle().Italic(true).Render("Press Ctrl+D or type 'exit' to return to claws")) return strings.Join(lines, "\n") } diff --git a/internal/app/app.go b/internal/app/app.go index 18ec69bf..9b5a2e7b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -48,12 +48,12 @@ type appStyles struct { func newAppStyles(width int) appStyles { t := ui.Current() return appStyles{ - status: lipgloss.NewStyle().Background(t.TableHeader).Foreground(t.TableHeaderText).Padding(0, 1).Width(width), + status: ui.TableHeaderStyle().Padding(0, 1).Width(width), readOnly: lipgloss.NewStyle().Background(t.Warning).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 1), - warningTitle: lipgloss.NewStyle().Bold(true).Foreground(t.Pending).MarginBottom(1), - warningItem: lipgloss.NewStyle().Foreground(t.Warning), - warningDim: lipgloss.NewStyle().Foreground(t.TextDim).MarginTop(1), - warningBox: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Pending).Padding(1, 2), + warningTitle: ui.BoldPendingStyle().MarginBottom(1), + warningItem: ui.WarningStyle(), + warningDim: ui.DimStyle().MarginTop(1), + warningBox: ui.BoxStyle().BorderForeground(t.Pending).Padding(1, 2), } } diff --git a/internal/render/detail.go b/internal/render/detail.go index 2e44f9ed..cdae4b02 100644 --- a/internal/render/detail.go +++ b/internal/render/detail.go @@ -43,14 +43,13 @@ func DefaultDetailStyles() DetailStyles { if cachedDetailStyles != nil { return *cachedDetailStyles } - t := ui.Current() styles := DetailStyles{ - Title: lipgloss.NewStyle().Bold(true).Foreground(t.Primary), - Section: lipgloss.NewStyle().Bold(true).Foreground(t.Secondary).MarginTop(1), - Label: lipgloss.NewStyle().Foreground(t.TextDim).Width(32), - Value: lipgloss.NewStyle().Foreground(t.Text), - Dim: lipgloss.NewStyle().Foreground(t.TextDim), - Success: lipgloss.NewStyle().Foreground(t.Success), + Title: ui.TitleStyle(), + Section: ui.SectionStyle().MarginTop(1), + Label: ui.DimStyle().Width(32), + Value: ui.TextStyle(), + Dim: ui.DimStyle(), + Success: ui.SuccessStyle(), } cachedDetailStyles = &styles return styles diff --git a/internal/render/render.go b/internal/render/render.go index 8d02823a..2699536f 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -128,18 +128,17 @@ type Colorer func(value string) lipgloss.Style // StateColorer returns a colorer for common state values func StateColorer() Colorer { return func(value string) lipgloss.Style { - t := ui.Current() switch value { case "running", "available", "active", "healthy": - return lipgloss.NewStyle().Foreground(t.Success) + return ui.SuccessStyle() case "in-use", "attached": - return lipgloss.NewStyle().Foreground(t.Info) + return ui.InfoStyle() case "stopped", "stopping", "deleting": - return lipgloss.NewStyle().Foreground(t.Warning) + return ui.WarningStyle() case "terminated", "failed", "error", "unhealthy", "deleted": - return lipgloss.NewStyle().Foreground(t.Danger) + return ui.DangerStyle() case "pending", "starting", "creating": - return lipgloss.NewStyle().Foreground(t.Pending) + return ui.PendingStyle() default: return lipgloss.NewStyle() } @@ -203,26 +202,6 @@ func FormatDuration(d time.Duration) string { // Style is an alias for lipgloss.Style for convenience type Style = lipgloss.Style -// SuccessStyle returns a green style for success states -func SuccessStyle() lipgloss.Style { - return ui.SuccessStyle() -} - -// WarningStyle returns a yellow style for warning states -func WarningStyle() lipgloss.Style { - return ui.WarningStyle() -} - -// DangerStyle returns a red style for danger/error states -func DangerStyle() lipgloss.Style { - return ui.DangerStyle() -} - -// DimStyle returns a dimmed gray style -func DimStyle() lipgloss.Style { - return ui.DimStyle() -} - // DefaultStyle returns a default unstyled style func DefaultStyle() lipgloss.Style { return lipgloss.NewStyle() diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 1019ab6e..10efa83b 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/clawscli/claws/internal/dao" + "github.com/clawscli/claws/internal/ui" ) func TestFormatAge(t *testing.T) { @@ -278,10 +279,10 @@ func TestTagsColumn(t *testing.T) { func TestStyleHelpers(t *testing.T) { // These just verify the functions don't panic - _ = SuccessStyle().Render("test") - _ = WarningStyle().Render("test") - _ = DangerStyle().Render("test") - _ = DimStyle().Render("test") + _ = ui.SuccessStyle().Render("test") + _ = ui.WarningStyle().Render("test") + _ = ui.DangerStyle().Render("test") + _ = ui.DimStyle().Render("test") _ = DefaultStyle().Render("test") } @@ -393,7 +394,7 @@ func TestDetailBuilder_Section(t *testing.T) { func TestDetailBuilder_FieldStyled(t *testing.T) { d := NewDetailBuilder() - d.FieldStyled("Status", "running", SuccessStyle()) + d.FieldStyled("Status", "running", ui.SuccessStyle()) result := d.String() if !strings.Contains(result, "Status") { t.Errorf("FieldStyled() should contain label, got: %s", result) diff --git a/internal/ui/theme.go b/internal/ui/theme.go index 9af10b5c..d4ab2ee2 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -107,7 +107,109 @@ func DangerStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(current.Danger) } -// NewSpinner creates a consistently styled spinner for loading states +func TitleStyle() lipgloss.Style { + return lipgloss.NewStyle().Bold(true).Foreground(current.Primary) +} + +func SelectedStyle() lipgloss.Style { + return lipgloss.NewStyle().Background(current.Selection).Foreground(current.SelectionText) +} + +func TableHeaderStyle() lipgloss.Style { + return lipgloss.NewStyle().Background(current.TableHeader).Foreground(current.TableHeaderText) +} + +func SectionStyle() lipgloss.Style { + return lipgloss.NewStyle().Bold(true).Foreground(current.Secondary) +} + +func HighlightStyle() lipgloss.Style { + return lipgloss.NewStyle().Bold(true).Foreground(current.Accent) +} + +func BoldSuccessStyle() lipgloss.Style { + return lipgloss.NewStyle().Bold(true).Foreground(current.Success) +} + +func BoldDangerStyle() lipgloss.Style { + return lipgloss.NewStyle().Bold(true).Foreground(current.Danger) +} + +func BoldWarningStyle() lipgloss.Style { + return lipgloss.NewStyle().Bold(true).Foreground(current.Warning) +} + +func BoldPendingStyle() lipgloss.Style { + return lipgloss.NewStyle().Bold(true).Foreground(current.Pending) +} + +// AccentStyle returns a style for accent-colored text (non-bold) +func AccentStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(current.Accent) +} + +// MutedStyle returns a style for very dim/muted text +func MutedStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(current.TextMuted) +} + +// TextStyle returns a style for normal text +func TextStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(current.Text) +} + +// TextBrightStyle returns a style for emphasized text +func TextBrightStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(current.TextBright) +} + +// SecondaryStyle returns a style for secondary-colored text +func SecondaryStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(current.Secondary) +} + +// BorderStyle returns a style for border-colored text (separators) +func BorderStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(current.Border) +} + +// PrimaryStyle returns a style for primary-colored text (non-bold) +func PrimaryStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(current.Primary) +} + +// InfoStyle returns a style for info states +func InfoStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(current.Info) +} + +// PendingStyle returns a style for pending states +func PendingStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(current.Pending) +} + +func BoxStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(current.Border). + Padding(0, 1) +} + +func InputStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(current.Border). + Padding(0, 1) +} + +// InputFieldStyle returns a style for input fields (filter, command input) +func InputFieldStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Background(current.Background). + Foreground(current.Text). + Padding(0, 1) +} + func NewSpinner() spinner.Model { s := spinner.New() s.Spinner = spinner.Dot diff --git a/internal/ui/theme_test.go b/internal/ui/theme_test.go index b36210f8..c8f1fc41 100644 --- a/internal/ui/theme_test.go +++ b/internal/ui/theme_test.go @@ -117,6 +117,174 @@ func TestNewSpinner(t *testing.T) { } } +func TestTitleStyle(t *testing.T) { + style := TitleStyle() + rendered := style.Render("title") + if rendered == "" { + t.Error("TitleStyle().Render() should produce output") + } +} + +func TestSelectedStyle(t *testing.T) { + style := SelectedStyle() + rendered := style.Render("selected") + if rendered == "" { + t.Error("SelectedStyle().Render() should produce output") + } +} + +func TestTableHeaderStyle(t *testing.T) { + style := TableHeaderStyle() + rendered := style.Render("header") + if rendered == "" { + t.Error("TableHeaderStyle().Render() should produce output") + } +} + +func TestSectionStyle(t *testing.T) { + style := SectionStyle() + rendered := style.Render("section") + if rendered == "" { + t.Error("SectionStyle().Render() should produce output") + } +} + +func TestHighlightStyle(t *testing.T) { + style := HighlightStyle() + rendered := style.Render("highlight") + if rendered == "" { + t.Error("HighlightStyle().Render() should produce output") + } +} + +func TestBoldSuccessStyle(t *testing.T) { + style := BoldSuccessStyle() + rendered := style.Render("bold success") + if rendered == "" { + t.Error("BoldSuccessStyle().Render() should produce output") + } +} + +func TestBoldDangerStyle(t *testing.T) { + style := BoldDangerStyle() + rendered := style.Render("bold danger") + if rendered == "" { + t.Error("BoldDangerStyle().Render() should produce output") + } +} + +func TestBoldWarningStyle(t *testing.T) { + style := BoldWarningStyle() + rendered := style.Render("bold warning") + if rendered == "" { + t.Error("BoldWarningStyle().Render() should produce output") + } +} + +func TestBoldPendingStyle(t *testing.T) { + style := BoldPendingStyle() + rendered := style.Render("bold pending") + if rendered == "" { + t.Error("BoldPendingStyle().Render() should produce output") + } +} + +func TestAccentStyle(t *testing.T) { + style := AccentStyle() + rendered := style.Render("accent") + if rendered == "" { + t.Error("AccentStyle().Render() should produce output") + } +} + +func TestMutedStyle(t *testing.T) { + style := MutedStyle() + rendered := style.Render("muted") + if rendered == "" { + t.Error("MutedStyle().Render() should produce output") + } +} + +func TestTextStyle(t *testing.T) { + style := TextStyle() + rendered := style.Render("text") + if rendered == "" { + t.Error("TextStyle().Render() should produce output") + } +} + +func TestTextBrightStyle(t *testing.T) { + style := TextBrightStyle() + rendered := style.Render("bright") + if rendered == "" { + t.Error("TextBrightStyle().Render() should produce output") + } +} + +func TestSecondaryStyle(t *testing.T) { + style := SecondaryStyle() + rendered := style.Render("secondary") + if rendered == "" { + t.Error("SecondaryStyle().Render() should produce output") + } +} + +func TestBorderStyle(t *testing.T) { + style := BorderStyle() + rendered := style.Render("border") + if rendered == "" { + t.Error("BorderStyle().Render() should produce output") + } +} + +func TestPrimaryStyle(t *testing.T) { + style := PrimaryStyle() + rendered := style.Render("primary") + if rendered == "" { + t.Error("PrimaryStyle().Render() should produce output") + } +} + +func TestInfoStyle(t *testing.T) { + style := InfoStyle() + rendered := style.Render("info") + if rendered == "" { + t.Error("InfoStyle().Render() should produce output") + } +} + +func TestPendingStyle(t *testing.T) { + style := PendingStyle() + rendered := style.Render("pending") + if rendered == "" { + t.Error("PendingStyle().Render() should produce output") + } +} + +func TestBoxStyle(t *testing.T) { + style := BoxStyle() + rendered := style.Render("box content") + if rendered == "" { + t.Error("BoxStyle().Render() should produce output") + } +} + +func TestInputStyle(t *testing.T) { + style := InputStyle() + rendered := style.Render("input content") + if rendered == "" { + t.Error("InputStyle().Render() should produce output") + } +} + +func TestInputFieldStyle(t *testing.T) { + style := InputFieldStyle() + rendered := style.Render("filter text") + if rendered == "" { + t.Error("InputFieldStyle().Render() should produce output") + } +} + func TestThemeFields(t *testing.T) { theme := DefaultTheme() diff --git a/internal/view/action_menu.go b/internal/view/action_menu.go index c0b19ad3..2359aa4f 100644 --- a/internal/view/action_menu.go +++ b/internal/view/action_menu.go @@ -36,16 +36,16 @@ type actionMenuStyles struct { func newActionMenuStyles() actionMenuStyles { t := ui.Current() return actionMenuStyles{ - title: lipgloss.NewStyle().Bold(true).Foreground(t.Primary).MarginBottom(1), + title: ui.TitleStyle(), item: lipgloss.NewStyle().PaddingLeft(2), - selected: lipgloss.NewStyle().PaddingLeft(2).Background(t.Selection).Foreground(t.SelectionText), - shortcut: lipgloss.NewStyle().Foreground(t.Secondary), - box: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Border).Padding(0, 1).MarginTop(1), - dangerBox: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Danger).Padding(0, 1).MarginTop(1), - yes: lipgloss.NewStyle().Bold(true).Foreground(t.Success), - no: lipgloss.NewStyle().Bold(true).Foreground(t.Danger), + selected: ui.SelectedStyle().PaddingLeft(2), + shortcut: ui.SecondaryStyle(), + box: ui.BoxStyle().MarginTop(1), + dangerBox: ui.BoxStyle().BorderForeground(t.Danger).MarginTop(1), + yes: ui.BoldSuccessStyle(), + no: ui.BoldDangerStyle(), bold: lipgloss.NewStyle().Bold(true), - input: lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(t.Border).Padding(0, 1), + input: ui.InputStyle(), } } @@ -343,7 +343,7 @@ func (m *ActionMenu) renderDangerousConfirm(act action.Action) string { s := m.styles t := ui.Current() - dangerTitle := lipgloss.NewStyle().Bold(true).Foreground(t.Danger).Render("⚠ DANGER") + dangerTitle := ui.BoldDangerStyle().Render("⚠ DANGER") content := dangerTitle + "\n\n" content += fmt.Sprintf("You are about to %s:\n", s.no.Render(act.Name)) content += s.bold.Render(m.dangerous.token) + "\n\n" diff --git a/internal/view/command_input.go b/internal/view/command_input.go index 1c29a76b..8a6ea5e4 100644 --- a/internal/view/command_input.go +++ b/internal/view/command_input.go @@ -26,11 +26,10 @@ type commandInputStyles struct { } func newCommandInputStyles() commandInputStyles { - t := ui.Current() return commandInputStyles{ - input: lipgloss.NewStyle().Background(t.Background).Foreground(t.Text).Padding(0, 1), - suggestion: lipgloss.NewStyle().Foreground(t.TextDim), - highlight: lipgloss.NewStyle().Bold(true).Foreground(t.Accent), + input: ui.InputFieldStyle(), + suggestion: ui.DimStyle(), + highlight: ui.HighlightStyle(), } } @@ -55,7 +54,6 @@ type CommandInput struct { registry *registry.Registry textInput textinput.Model active bool - width int suggestions []string suggIdx int styles commandInputStyles @@ -195,7 +193,6 @@ func (c *CommandInput) View() string { // SetWidth sets the input width func (c *CommandInput) SetWidth(width int) { - c.width = width c.textInput.SetWidth(width - 4) } diff --git a/internal/view/command_input_test.go b/internal/view/command_input_test.go index a5643b17..36b329de 100644 --- a/internal/view/command_input_test.go +++ b/internal/view/command_input_test.go @@ -140,10 +140,6 @@ func TestCommandInput_SetWidth(t *testing.T) { ci := NewCommandInput(ctx, reg) ci.SetWidth(100) - - if ci.width != 100 { - t.Errorf("width = %d, want 100", ci.width) - } } func TestCommandInput_Update_Esc(t *testing.T) { diff --git a/internal/view/dashboard_view.go b/internal/view/dashboard_view.go index 8f6a999e..208ca364 100644 --- a/internal/view/dashboard_view.go +++ b/internal/view/dashboard_view.go @@ -29,13 +29,12 @@ type dashboardStyles struct { } func newDashboardStyles() dashboardStyles { - t := ui.Current() return dashboardStyles{ - warning: lipgloss.NewStyle().Foreground(t.Warning), - danger: lipgloss.NewStyle().Foreground(t.Danger), - success: lipgloss.NewStyle().Foreground(t.Success), - dim: lipgloss.NewStyle().Foreground(t.TextMuted), - highlight: lipgloss.NewStyle().Background(t.Selection).Foreground(t.SelectionText), + warning: ui.WarningStyle(), + danger: ui.DangerStyle(), + success: ui.SuccessStyle(), + dim: ui.MutedStyle(), + highlight: ui.SelectedStyle(), } } @@ -246,7 +245,7 @@ func (d *DashboardView) computeRowFromContentLine(panelIdx, lineY int) int { line := 0 if len(d.alarms) > 0 { line++ - for i := 0; i < len(d.alarms); i++ { + for i := range d.alarms { if lineY == line { return i } @@ -258,7 +257,7 @@ func (d *DashboardView) computeRowFromContentLine(panelIdx, lineY int) int { if len(d.healthItems) > 0 { line++ alarmCount := len(d.alarms) - for i := 0; i < len(d.healthItems); i++ { + for i := range d.healthItems { if lineY == line { return alarmCount + i } diff --git a/internal/view/dashboard_view_panels.go b/internal/view/dashboard_view_panels.go index 1d1842bb..118862d2 100644 --- a/internal/view/dashboard_view_panels.go +++ b/internal/view/dashboard_view_panels.go @@ -39,11 +39,8 @@ const ( ) func renderPanel(title, content string, width, height int, t *ui.Theme, hovered bool) string { - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Primary) - boxHeight := height - 1 - if boxHeight < 3 { - boxHeight = 3 - } + titleStyle := ui.TitleStyle() + boxHeight := max(height-1, 3) borderColor := t.Border if hovered { @@ -62,24 +59,15 @@ func renderPanel(title, content string, width, height int, t *ui.Theme, hovered borderStyle.Render(content)) } -func renderBar(value, max float64, width int, t *ui.Theme) string { - if max <= 0 || width <= 0 { +func renderBar(value, maxVal float64, width int, t *ui.Theme) string { + if maxVal <= 0 || width <= 0 { return "" } - ratio := value / max - if ratio > 1 { - ratio = 1 - } - filled := int(ratio * float64(width)) - if filled < 0 { - filled = 0 - } - if filled > width { - filled = width - } + ratio := min(value/maxVal, 1.0) + filled := min(max(int(ratio*float64(width)), 0), width) - barStyle := lipgloss.NewStyle().Foreground(t.Accent) - emptyStyle := lipgloss.NewStyle().Foreground(t.TextMuted) + barStyle := ui.AccentStyle() + emptyStyle := ui.MutedStyle() return barStyle.Render(strings.Repeat("█", filled)) + emptyStyle.Render(strings.Repeat("░", width-filled)) @@ -101,22 +89,15 @@ func (d *DashboardView) renderCostContent(contentWidth, contentHeight int, t *ui available := contentWidth - costValueWidth - costPadding nameWidth := available * costNameWidthRatio / 100 barWidth := available - nameWidth - if nameWidth < minCostNameWidth { - nameWidth = minCostNameWidth - } - if barWidth < minCostBarWidth { - barWidth = minCostBarWidth - } - maxServices := contentHeight - 2 - if maxServices < 3 { - maxServices = 3 - } + nameWidth = max(nameWidth, minCostNameWidth) + barWidth = max(barWidth, minCostBarWidth) + maxServices := max(contentHeight-2, 3) showCount := min(len(d.costTop), maxServices) - for i := 0; i < showCount; i++ { + for i := range showCount { c := d.costTop[i] bar := renderBar(c.cost, maxCost, barWidth, t) - name := truncateValue(c.service, nameWidth) + name := TruncateString(c.service, nameWidth) line := fmt.Sprintf("%-*s %s %8.0f", nameWidth, name, bar, c.cost) if i == focusRow { line = s.highlight.Render(line) @@ -151,8 +132,8 @@ func (d *DashboardView) renderOpsContent(contentWidth, contentHeight int, focusR } else if alarmCount > 0 { lines = append(lines, s.danger.Render(fmt.Sprintf("Alarms: %d in ALARM", alarmCount))) maxShow := min(alarmCount, contentHeight-3) - for i := 0; i < maxShow; i++ { - line := " " + s.danger.Render("• ") + truncateValue(d.alarms[i].name, contentWidth-bulletIndentWidth) + for i := range maxShow { + line := " " + s.danger.Render("• ") + TruncateString(d.alarms[i].name, contentWidth-bulletIndentWidth) if i == focusRow { line = s.highlight.Render(line) } @@ -170,9 +151,9 @@ func (d *DashboardView) renderOpsContent(contentWidth, contentHeight int, focusR lines = append(lines, s.warning.Render(fmt.Sprintf("Health: %d open", len(d.healthItems)))) remaining := contentHeight - len(lines) - 1 maxShow := min(len(d.healthItems), remaining) - for i := 0; i < maxShow; i++ { + for i := range maxShow { h := d.healthItems[i] - line := " " + s.warning.Render("• ") + truncateValue(h.service+": "+h.eventType, contentWidth-bulletIndentWidth) + line := " " + s.warning.Render("• ") + TruncateString(h.service+": "+h.eventType, contentWidth-bulletIndentWidth) if alarmCount+i == focusRow { line = s.highlight.Render(line) } @@ -209,13 +190,13 @@ func (d *DashboardView) renderSecurityContent(contentWidth, contentHeight int, f lines = append(lines, s.warning.Render(fmt.Sprintf("High: %d 🟠", high))) } maxShow := min(len(d.secItems), contentHeight-len(lines)-1) - for i := 0; i < maxShow; i++ { + for i := range maxShow { item := d.secItems[i] style := s.warning if item.severity == "CRITICAL" { style = s.danger } - line := " " + style.Render("• ") + truncateValue(item.title, contentWidth-bulletIndentWidth) + line := " " + style.Render("• ") + TruncateString(item.title, contentWidth-bulletIndentWidth) if i == focusRow { line = s.highlight.Render(line) } @@ -256,13 +237,13 @@ func (d *DashboardView) renderOptimizationContent(contentWidth, contentHeight in } if len(d.taItems) > 0 { maxShow := min(len(d.taItems), contentHeight-len(lines)-1) - for i := 0; i < maxShow; i++ { + for i := range maxShow { item := d.taItems[i] style := s.warning if item.status == "error" { style = s.danger } - line := " " + style.Render("• ") + truncateValue(item.name, contentWidth-bulletIndentWidth) + line := " " + style.Render("• ") + TruncateString(item.name, contentWidth-bulletIndentWidth) if i == focusRow { line = s.highlight.Render(line) } diff --git a/internal/view/detail_view.go b/internal/view/detail_view.go index f4c38727..279dcde3 100644 --- a/internal/view/detail_view.go +++ b/internal/view/detail_view.go @@ -5,7 +5,6 @@ import ( "strings" "charm.land/bubbles/v2/spinner" - "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -26,11 +25,10 @@ type detailViewStyles struct { } func newDetailViewStyles() detailViewStyles { - t := ui.Current() return detailViewStyles{ - title: lipgloss.NewStyle().Bold(true).Foreground(t.Primary), - label: lipgloss.NewStyle().Foreground(t.TextDim).Width(15), - value: lipgloss.NewStyle().Foreground(t.Text), + title: ui.TitleStyle(), + label: ui.DimStyle().Width(15), + value: ui.TextStyle(), } } @@ -40,15 +38,12 @@ type DetailView struct { renderer render.Renderer service string resType string - viewport viewport.Model + vp ViewportState headerPanel *HeaderPanel - ready bool - width int - height int registry *registry.Registry - dao dao.DAO // for async refresh - refreshing bool // true while fetching extended details - refreshErr error // error from last refresh attempt + dao dao.DAO + refreshing bool + refreshErr error spinner spinner.Model styles detailViewStyles } @@ -110,12 +105,10 @@ func (d *DetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { d.refreshErr = msg.err } else { d.refreshErr = nil - // Merge refreshed resource with original to preserve List-only fields d.resource = mergeResources(d.resource, msg.resource) - // Re-render content with refreshed data - if d.ready { + if d.vp.Ready { content := d.renderContent() - d.viewport.SetContent(content) + d.vp.Model.SetContent(content) } } return d, nil @@ -149,9 +142,8 @@ func (d *DetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - // Pass other messages to viewport for scrolling var cmd tea.Cmd - d.viewport, cmd = d.viewport.Update(msg) + d.vp.Model, cmd = d.vp.Model.Update(msg) return d, cmd } @@ -174,22 +166,19 @@ func (d *DetailView) handleNavigation(key string) (tea.Model, tea.Cmd) { return nil, nil } -// ViewString returns the view content as a string func (d *DetailView) ViewString() string { - if !d.ready { - return "Loading..." + if !d.vp.Ready { + return LoadingMessage } - // Get summary fields for header var summaryFields []render.SummaryField if d.renderer != nil { summaryFields = d.renderer.RenderSummary(dao.UnwrapResource(d.resource)) } - // Render header panel header := d.headerPanel.Render(d.service, d.resType, summaryFields) - return header + "\n" + d.viewport.View() + return header + "\n" + d.vp.Model.View() } // View implements tea.Model @@ -199,10 +188,6 @@ func (d *DetailView) View() tea.View { // SetSize implements View func (d *DetailView) SetSize(width, height int) tea.Cmd { - d.width = width - d.height = height - - // Set header panel width d.headerPanel.SetWidth(width) // Calculate header height dynamically @@ -213,23 +198,12 @@ func (d *DetailView) SetSize(width, height int) tea.Cmd { headerStr := d.headerPanel.Render(d.service, d.resType, summaryFields) headerHeight := d.headerPanel.Height(headerStr) - // height - header + extra space - viewportHeight := height - headerHeight + 1 - if viewportHeight < 5 { - viewportHeight = 5 - } + viewportHeight := max(height-headerHeight+1, 5) - if !d.ready { - d.viewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(viewportHeight)) - d.ready = true - } else { - d.viewport.SetWidth(width) - d.viewport.SetHeight(viewportHeight) - } + d.vp.SetSize(width, viewportHeight) - // Render content content := d.renderContent() - d.viewport.SetContent(content) + d.vp.Model.SetContent(content) return nil } @@ -286,7 +260,7 @@ func (d *DetailView) renderContent() string { // Match placeholders only at line endings to avoid replacing substrings // (e.g., "Not configured server" should not be replaced). if d.refreshing && detail != "" { - loading := ui.DimStyle().Render("Loading...") + loading := ui.DimStyle().Render(LoadingMessage) // Replace placeholders at end of line or end of content for _, placeholder := range []string{render.NotConfigured, render.Empty, render.NoValue} { diff --git a/internal/view/detail_view_test.go b/internal/view/detail_view_test.go index b5a7ed94..80222a56 100644 --- a/internal/view/detail_view_test.go +++ b/internal/view/detail_view_test.go @@ -155,28 +155,28 @@ func TestDetailViewLoadingPlaceholderReplacement(t *testing.T) { name: "refreshing replaces NotConfigured at line end", detail: "Status: " + render.NotConfigured + "\n", refreshing: true, - wantContains: []string{"Loading..."}, + wantContains: []string{LoadingMessage}, wantNotContains: []string{render.NotConfigured}, }, { name: "refreshing replaces Empty at line end", detail: "Items: " + render.Empty + "\n", refreshing: true, - wantContains: []string{"Loading..."}, + wantContains: []string{LoadingMessage}, wantNotContains: []string{render.Empty}, }, { name: "refreshing replaces NoValue at line end", detail: "Comment: " + render.NoValue + "\n", refreshing: true, - wantContains: []string{"Loading..."}, + wantContains: []string{LoadingMessage}, wantNotContains: []string{render.NoValue}, }, { name: "refreshing replaces placeholder at EOF without newline", detail: "Status: " + render.NotConfigured, refreshing: true, - wantContains: []string{"Loading..."}, + wantContains: []string{LoadingMessage}, wantNotContains: []string{render.NotConfigured}, }, { @@ -197,21 +197,21 @@ func TestDetailViewLoadingPlaceholderReplacement(t *testing.T) { name: "refreshing replaces multiple different placeholders", detail: "Status: " + render.NotConfigured + "\nItems: " + render.Empty + "\nComment: " + render.NoValue + "\n", refreshing: true, - wantContains: []string{"Loading..."}, + wantContains: []string{LoadingMessage}, wantNotContains: []string{render.NotConfigured, render.Empty, render.NoValue}, }, { name: "refreshing replaces multiple same placeholders", detail: "Status: " + render.NotConfigured + "\nEncryption: " + render.NotConfigured + "\n", refreshing: true, - wantContains: []string{"Loading..."}, + wantContains: []string{LoadingMessage}, wantNotContains: []string{render.NotConfigured}, }, { name: "refreshing replaces consecutive placeholders", detail: "Status: " + render.NotConfigured + "\n" + render.NoValue + "\n", refreshing: true, - wantContains: []string{"Loading..."}, + wantContains: []string{LoadingMessage}, wantNotContains: []string{render.NotConfigured, render.NoValue}, }, { @@ -230,8 +230,7 @@ func TestDetailViewLoadingPlaceholderReplacement(t *testing.T) { dv.refreshing = tt.refreshing dv.SetSize(100, 50) - // Get the viewport content - content := dv.viewport.View() + content := dv.vp.Model.View() for _, want := range tt.wantContains { if !strings.Contains(content, want) { diff --git a/internal/view/diff_view.go b/internal/view/diff_view.go index 7cc45e31..80c8514a 100644 --- a/internal/view/diff_view.go +++ b/internal/view/diff_view.go @@ -4,17 +4,14 @@ import ( "context" "strings" - "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/render" "github.com/clawscli/claws/internal/ui" ) -// DiffView displays side-by-side comparison of two resources type DiffView struct { ctx context.Context left dao.Resource @@ -22,27 +19,22 @@ type DiffView struct { renderer render.Renderer service string resourceType string - viewport viewport.Model - ready bool + vp ViewportState width int - height int styles diffViewStyles } type diffViewStyles struct { title lipgloss.Style header lipgloss.Style - content lipgloss.Style separator lipgloss.Style } func newDiffViewStyles() diffViewStyles { - t := ui.Current() return diffViewStyles{ - title: lipgloss.NewStyle().Bold(true).Foreground(t.Primary), - header: lipgloss.NewStyle().Bold(true).Foreground(t.Secondary), - content: lipgloss.NewStyle().Foreground(t.Text), - separator: lipgloss.NewStyle().Foreground(t.TableBorder), + title: ui.TitleStyle(), + header: ui.SectionStyle(), + separator: ui.MutedStyle(), } } @@ -75,17 +67,16 @@ func (d *DiffView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } var cmd tea.Cmd - d.viewport, cmd = d.viewport.Update(msg) + d.vp.Model, cmd = d.vp.Model.Update(msg) return d, cmd } -// ViewString returns the view content as a string func (d *DiffView) ViewString() string { - if !d.ready { - return "Loading..." + if !d.vp.Ready { + return LoadingMessage } - return d.viewport.View() + return d.vp.Model.View() } // View implements tea.Model @@ -96,25 +87,15 @@ func (d *DiffView) View() tea.View { // SetSize implements View func (d *DiffView) SetSize(width, height int) tea.Cmd { d.width = width - d.height = height // Reserve space for header headerHeight := 3 - viewportHeight := height - headerHeight - if viewportHeight < 5 { - viewportHeight = 5 - } + viewportHeight := max(height-headerHeight, 5) - if !d.ready { - d.viewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(viewportHeight)) - d.ready = true - } else { - d.viewport.SetWidth(width) - d.viewport.SetHeight(viewportHeight) - } + d.vp.SetSize(width, viewportHeight) content := d.renderSideBySide() - d.viewport.SetContent(content) + d.vp.Model.SetContent(content) return nil } @@ -149,8 +130,8 @@ func (d *DiffView) renderSideBySide() string { colWidth := (d.width - 3) / 2 // Column headers - leftHeader := truncateOrPad("◀ "+d.left.GetName(), colWidth) - rightHeader := truncateOrPad(d.right.GetName()+" ▶", colWidth) + leftHeader := TruncateOrPadString("◀ "+d.left.GetName(), colWidth) + rightHeader := TruncateOrPadString(d.right.GetName()+" ▶", colWidth) out.WriteString(s.header.Render(leftHeader)) out.WriteString(s.separator.Render(" │ ")) out.WriteString(s.header.Render(rightHeader)) @@ -161,12 +142,9 @@ func (d *DiffView) renderSideBySide() string { out.WriteString("\n") // Render side by side - maxLines := len(leftLines) - if len(rightLines) > maxLines { - maxLines = len(rightLines) - } + maxLines := max(len(leftLines), len(rightLines)) - for i := 0; i < maxLines; i++ { + for i := range maxLines { leftLine := "" rightLine := "" @@ -177,29 +155,11 @@ func (d *DiffView) renderSideBySide() string { rightLine = rightLines[i] } - out.WriteString(truncateOrPad(leftLine, colWidth)) + out.WriteString(TruncateOrPadString(leftLine, colWidth)) out.WriteString(s.separator.Render(" │ ")) - out.WriteString(truncateOrPad(rightLine, colWidth)) + out.WriteString(TruncateOrPadString(rightLine, colWidth)) out.WriteString("\n") } return out.String() } - -// truncateOrPad ensures a string is exactly the specified width -func truncateOrPad(s string, width int) string { - if width <= 0 { - return "" - } - - // Use lipgloss.Width for proper ANSI-aware width calculation - plainLen := lipgloss.Width(s) - - if plainLen > width { - // Use ansi.Truncate for proper ANSI-aware truncation - return ansi.Truncate(s, width, "…") - } - - // Pad with spaces - return s + strings.Repeat(" ", width-plainLen) -} diff --git a/internal/view/diff_view_test.go b/internal/view/diff_view_test.go index 9573d92f..6ef1ef4c 100644 --- a/internal/view/diff_view_test.go +++ b/internal/view/diff_view_test.go @@ -49,23 +49,18 @@ func TestDiffView_SetSize(t *testing.T) { dv := NewDiffView(ctx, left, right, nil, "ec2", "instances") - // Initially not ready - if dv.ready { - t.Error("Expected ready to be false initially") + if dv.vp.Ready { + t.Error("Expected vp.Ready to be false initially") } - // SetSize should initialize viewport dv.SetSize(100, 50) - if !dv.ready { - t.Error("Expected ready to be true after SetSize") + if !dv.vp.Ready { + t.Error("Expected vp.Ready to be true after SetSize") } if dv.width != 100 { t.Errorf("width = %d, want 100", dv.width) } - if dv.height != 50 { - t.Errorf("height = %d, want 50", dv.height) - } } func TestDiffView_Update_Esc(t *testing.T) { @@ -117,7 +112,7 @@ func TestDiffView_View_NotReady(t *testing.T) { // Without SetSize, should show loading view := dv.ViewString() - if view != "Loading..." { - t.Errorf("ViewString() = %q, want 'Loading...'", view) + if view != LoadingMessage { + t.Errorf("ViewString() = %q, want %q", view, LoadingMessage) } } diff --git a/internal/view/header_panel.go b/internal/view/header_panel.go index 21bc34f2..50682a0d 100644 --- a/internal/view/header_panel.go +++ b/internal/view/header_panel.go @@ -1,11 +1,11 @@ package view import ( + "cmp" "strconv" "strings" "charm.land/lipgloss/v2" - "github.com/mattn/go-runewidth" "github.com/clawscli/claws/internal/config" "github.com/clawscli/claws/internal/registry" @@ -21,16 +21,6 @@ const ( maxFieldValueWidth = 30 ) -// truncateValue truncates a string to maxWidth, adding "…" if truncated -func truncateValue(s string, maxWidth int) string { - if runewidth.StringWidth(s) <= maxWidth { - return s - } - // Truncate to fit maxWidth-1 to leave room for ellipsis - truncated := runewidth.Truncate(s, maxWidth-1, "") - return truncated + "…" -} - // HeaderPanel renders the fixed header panel at the top of resource views // headerPanelStyles holds cached lipgloss styles for performance type headerPanelStyles struct { @@ -46,11 +36,11 @@ func newHeaderPanelStyles() headerPanelStyles { t := ui.Current() return headerPanelStyles{ panel: lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(t.Border).Padding(0, 1), - label: lipgloss.NewStyle().Foreground(t.TextDim), - value: lipgloss.NewStyle().Foreground(t.Text), - accent: lipgloss.NewStyle().Foreground(t.Accent).Bold(true), - dim: lipgloss.NewStyle().Foreground(t.TextMuted), - separator: lipgloss.NewStyle().Foreground(t.Border), + label: ui.DimStyle(), + value: ui.TextStyle(), + accent: ui.HighlightStyle(), + dim: ui.MutedStyle(), + separator: ui.BorderStyle(), } } @@ -77,19 +67,11 @@ func (h *HeaderPanel) renderContextLine(service, resourceType string) string { accountDisplay = formatMultiAccounts(selections, cfg.AccountIDs()) } else { profileDisplay = cfg.Selection().DisplayName() - accountDisplay = cfg.AccountID() - if accountDisplay == "" { - accountDisplay = "-" - } + accountDisplay = cmp.Or(cfg.AccountID(), "-") } - var regionDisplay string regions := cfg.Regions() - if len(regions) == 0 { - regionDisplay = "-" - } else { - regionDisplay = strings.Join(regions, ", ") - } + regionDisplay := cmp.Or(strings.Join(regions, ", "), "-") line := s.label.Render("Profile: ") + s.value.Render(profileDisplay) + s.dim.Render(" │ ") + @@ -118,7 +100,7 @@ func formatMultiProfiles(selections []config.ProfileSelection) string { return strings.Join(names, ", ") } names := make([]string, maxShow) - for i := 0; i < maxShow; i++ { + for i := range maxShow { names[i] = selections[i].DisplayName() } return strings.Join(names, ", ") + " (+" + strconv.Itoa(len(selections)-maxShow) + ")" @@ -141,13 +123,6 @@ func formatMultiAccounts(selections []config.ProfileSelection, accountIDs map[st return strings.Join(accounts[:maxShow], ", ") + " (+" + strconv.Itoa(len(accounts)-maxShow) + ")" } -// RenderContextLine renders the AWS account/region context line. -// Can be used standalone by other views. -func RenderContextLine(service, resourceType string) string { - h := &HeaderPanel{styles: newHeaderPanelStyles()} - return h.renderContextLine(service, resourceType) -} - // SetWidth sets the panel width func (h *HeaderPanel) SetWidth(width int) { h.width = width @@ -205,7 +180,7 @@ func (h *HeaderPanel) Render(service, resourceType string, summaryFields []rende } // Truncate long values to prevent line wrapping - truncatedValue := truncateValue(field.Value, maxFieldValueWidth) + truncatedValue := TruncateString(field.Value, maxFieldValueWidth) // Format field with appropriate styling var styledValue string diff --git a/internal/view/help_view.go b/internal/view/help_view.go index 836dd6f1..57cbd3cb 100644 --- a/internal/view/help_view.go +++ b/internal/view/help_view.go @@ -1,7 +1,6 @@ package view import ( - "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -18,21 +17,17 @@ type helpViewStyles struct { } func newHelpViewStyles() helpViewStyles { - t := ui.Current() return helpViewStyles{ - title: lipgloss.NewStyle().Bold(true).Foreground(t.Primary).MarginBottom(1), - section: lipgloss.NewStyle().Bold(true).Foreground(t.Secondary).MarginTop(1), - key: lipgloss.NewStyle().Foreground(t.Success).Width(15), - desc: lipgloss.NewStyle().Foreground(t.Text), + title: ui.TitleStyle(), + section: ui.SectionStyle().MarginTop(1), + key: ui.SuccessStyle().Width(15), + desc: ui.TextStyle(), } } type HelpView struct { - width int - height int - styles helpViewStyles - viewport viewport.Model - ready bool + styles helpViewStyles + vp ViewportState } // NewHelpView creates a new HelpView @@ -47,10 +42,9 @@ func (h *HelpView) Init() tea.Cmd { return nil } -// Update implements tea.Model func (h *HelpView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd - h.viewport, cmd = h.viewport.Update(msg) + h.vp.Model, cmd = h.vp.Model.Update(msg) return h, cmd } @@ -156,12 +150,11 @@ func (h *HelpView) renderContent() string { return out } -// ViewString returns the view content as a string func (h *HelpView) ViewString() string { - if !h.ready { - return "Loading..." + if !h.vp.Ready { + return LoadingMessage } - return h.viewport.View() + return h.vp.Model.View() } // View implements tea.Model @@ -169,19 +162,9 @@ func (h *HelpView) View() tea.View { return tea.NewView(h.ViewString()) } -// SetSize implements View func (h *HelpView) SetSize(width, height int) tea.Cmd { - h.width = width - h.height = height - - if !h.ready { - h.viewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(height)) - h.ready = true - } else { - h.viewport.SetWidth(width) - h.viewport.SetHeight(height) - } - h.viewport.SetContent(h.renderContent()) + h.vp.SetSize(width, height) + h.vp.Model.SetContent(h.renderContent()) return nil } diff --git a/internal/view/log_view.go b/internal/view/log_view.go index 7d317245..e5f4bc2d 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -7,7 +7,6 @@ import ( "time" "charm.land/bubbles/v2/spinner" - "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -36,17 +35,14 @@ type LogView struct { logGroupName string logStreamName string - viewport viewport.Model - spinner spinner.Model - styles logViewStyles + vp ViewportState + spinner spinner.Model + styles logViewStyles logs []logEntry loading bool paused bool err error - ready bool - width int - height int lastEventTime int64 oldestEventTime int64 @@ -68,14 +64,13 @@ type logViewStyles struct { } func newLogViewStyles() logViewStyles { - t := ui.Current() return logViewStyles{ - header: lipgloss.NewStyle().Bold(true).Foreground(t.Primary).MarginBottom(1), - timestamp: lipgloss.NewStyle().Foreground(t.Secondary), - message: lipgloss.NewStyle().Foreground(t.Text), - paused: lipgloss.NewStyle().Bold(true).Foreground(t.Warning), - error: lipgloss.NewStyle().Foreground(t.Danger), - dim: lipgloss.NewStyle().Foreground(t.TextDim), + header: ui.TitleStyle(), + timestamp: ui.SecondaryStyle(), + message: ui.TextStyle(), + paused: ui.BoldWarningStyle(), + error: ui.DangerStyle(), + dim: ui.DimStyle(), } } @@ -266,7 +261,7 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.lastEventTime > 0 { v.oldestEventTime = msg.lastEventTime } - if v.ready { + if v.vp.Ready { v.updateViewportContent() } } @@ -283,9 +278,9 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(v.logs) > maxLogBufferSize { v.logs = v.logs[len(v.logs)-maxLogBufferSize:] } - if v.ready { + if v.vp.Ready { v.updateViewportContent() - v.viewport.GotoBottom() + v.vp.Model.GotoBottom() } } if !v.paused { @@ -308,19 +303,19 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return v, nil case "g": - if v.ready { - v.viewport.GotoTop() + if v.vp.Ready { + v.vp.Model.GotoTop() } return v, nil case "G": - if v.ready { - v.viewport.GotoBottom() + if v.vp.Ready { + v.vp.Model.GotoBottom() } return v, nil case "c": v.logs = v.logs[:0] v.oldestEventTime = 0 - if v.ready { + if v.vp.Ready { v.updateViewportContent() } return v, nil @@ -340,9 +335,9 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - if v.ready { + if v.vp.Ready { var cmd tea.Cmd - v.viewport, cmd = v.viewport.Update(msg) + v.vp.Model, cmd = v.vp.Model.Update(msg) return v, cmd } return v, nil @@ -355,12 +350,12 @@ func (v *LogView) updateViewportContent() { msg := v.styles.message.Render(entry.message) sb.WriteString(fmt.Sprintf("%s %s\n", ts, msg)) } - v.viewport.SetContent(sb.String()) + v.vp.Model.SetContent(sb.String()) } func (v *LogView) ViewString() string { - if !v.ready { - return "Loading..." + if !v.vp.Ready { + return LoadingMessage } var sb strings.Builder @@ -395,7 +390,7 @@ func (v *LogView) ViewString() string { return sb.String() } - sb.WriteString(v.viewport.View()) + sb.WriteString(v.vp.Model.View()) return sb.String() } @@ -404,18 +399,8 @@ func (v *LogView) View() tea.View { } func (v *LogView) SetSize(width, height int) tea.Cmd { - v.width = width - v.height = height viewportHeight := height - viewportHeaderOffset - - if !v.ready { - v.viewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(viewportHeight)) - v.ready = true - } else { - v.viewport.SetWidth(width) - v.viewport.SetHeight(viewportHeight) - } - + v.vp.SetSize(width, viewportHeight) v.updateViewportContent() return nil } diff --git a/internal/view/log_view_test.go b/internal/view/log_view_test.go index 635023f5..02a048d4 100644 --- a/internal/view/log_view_test.go +++ b/internal/view/log_view_test.go @@ -259,8 +259,8 @@ func TestLogViewSetSize(t *testing.T) { if cmd != nil { t.Error("Expected SetSize to return nil cmd") } - if !lv.ready { - t.Error("Expected ready to be true after SetSize") + if !lv.vp.Ready { + t.Error("Expected vp.Ready to be true after SetSize") } } diff --git a/internal/view/multi_selector.go b/internal/view/multi_selector.go index b1236ae1..b764f5e7 100644 --- a/internal/view/multi_selector.go +++ b/internal/view/multi_selector.go @@ -4,7 +4,6 @@ import ( "strings" "charm.land/bubbles/v2/textinput" - "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -25,13 +24,12 @@ type selectorStyles struct { } func newSelectorStyles() selectorStyles { - t := ui.Current() return selectorStyles{ - title: lipgloss.NewStyle().Background(t.TableHeader).Foreground(t.TableHeaderText).Padding(0, 1), + title: ui.TableHeaderStyle().Padding(0, 1), item: lipgloss.NewStyle().PaddingLeft(2), - itemSelected: lipgloss.NewStyle().PaddingLeft(2).Background(t.Selection).Foreground(t.SelectionText), - itemChecked: lipgloss.NewStyle().PaddingLeft(2).Foreground(t.Success), - filter: lipgloss.NewStyle().Foreground(t.Accent), + itemSelected: ui.SelectedStyle().PaddingLeft(2), + itemChecked: ui.SuccessStyle().PaddingLeft(2), + filter: ui.AccentStyle(), } } @@ -41,8 +39,7 @@ type MultiSelector[T SelectorItem] struct { cursor int selected map[string]bool - viewport viewport.Model - ready bool + vp ViewportState filterInput textinput.Model filterActive bool @@ -136,7 +133,7 @@ func (m *MultiSelector[T]) HandleUpdate(msg tea.Msg) (tea.Cmd, SelectorKeyResult switch msg := msg.(type) { case tea.MouseWheelMsg: var cmd tea.Cmd - m.viewport, cmd = m.viewport.Update(msg) + m.vp.Model, cmd = m.vp.Model.Update(msg) return cmd, KeyHandled case tea.MouseMotionMsg: @@ -228,7 +225,7 @@ func (m *MultiSelector[T]) HandleUpdate(msg tea.Msg) (tea.Cmd, SelectorKeyResult } var cmd tea.Cmd - m.viewport, cmd = m.viewport.Update(msg) + m.vp.Model, cmd = m.vp.Model.Update(msg) if cmd != nil { return cmd, KeyHandled } @@ -277,18 +274,18 @@ func (m *MultiSelector[T]) clampCursor() { } func (m *MultiSelector[T]) updateViewport() { - if !m.ready { + if !m.vp.Ready { return } - m.viewport.SetContent(m.renderContent()) + m.vp.Model.SetContent(m.renderContent()) if m.cursor >= 0 { - viewportHeight := m.viewport.Height() + viewportHeight := m.vp.Model.Height() if viewportHeight > 0 { - if m.cursor < m.viewport.YOffset() { - m.viewport.SetYOffset(m.cursor) - } else if m.cursor >= m.viewport.YOffset()+viewportHeight { - m.viewport.SetYOffset(m.cursor - viewportHeight + 1) + if m.cursor < m.vp.Model.YOffset() { + m.vp.Model.SetYOffset(m.cursor) + } else if m.cursor >= m.vp.Model.YOffset()+viewportHeight { + m.vp.Model.SetYOffset(m.cursor - viewportHeight + 1) } } } @@ -327,7 +324,7 @@ func (m *MultiSelector[T]) renderContent() string { } func (m *MultiSelector[T]) getItemAtPosition(y int) int { - if !m.ready { + if !m.vp.Ready { return -1 } headerHeight := 1 @@ -335,7 +332,7 @@ func (m *MultiSelector[T]) getItemAtPosition(y int) int { headerHeight++ } - contentY := y - headerHeight + m.viewport.YOffset() + contentY := y - headerHeight + m.vp.Model.YOffset() if contentY >= 0 && contentY < len(m.filtered) { return contentY } @@ -354,11 +351,11 @@ func (m *MultiSelector[T]) ViewString() string { filterView = m.styles.filter.Render("filter: "+m.filterText) + "\n" } - if !m.ready { - return title + "\n" + filterView + "Loading..." + if !m.vp.Ready { + return title + "\n" + filterView + LoadingMessage } - return title + "\n" + filterView + m.viewport.View() + return title + "\n" + filterView + m.vp.Model.View() } func (m *MultiSelector[T]) SetSize(width, height int) { @@ -367,13 +364,7 @@ func (m *MultiSelector[T]) SetSize(width, height int) { viewportHeight-- } - if !m.ready { - m.viewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(viewportHeight)) - m.ready = true - } else { - m.viewport.SetWidth(width) - m.viewport.SetHeight(viewportHeight) - } + m.vp.SetSize(width, viewportHeight) m.updateViewport() } diff --git a/internal/view/profile_selector.go b/internal/view/profile_selector.go index 7e3a22e9..ced66b17 100644 --- a/internal/view/profile_selector.go +++ b/internal/view/profile_selector.go @@ -44,12 +44,11 @@ func NewProfileSelector() *ProfileSelector { initialSelected = append(initialSelected, sel.ID()) } - t := ui.Current() p := &ProfileSelector{ selector: NewMultiSelector[profileItem]("Select Profiles", initialSelected), profileInfo: make(map[string]aws.ProfileInfo), - typeStyle: lipgloss.NewStyle().Foreground(t.TextDim), - regionStyle: lipgloss.NewStyle().Foreground(t.TextDim), + typeStyle: ui.DimStyle(), + regionStyle: ui.DimStyle(), } p.selector.SetRenderExtra(func(item profileItem) string { diff --git a/internal/view/resource_browser.go b/internal/view/resource_browser.go index 609fbad3..cc2d981a 100644 --- a/internal/view/resource_browser.go +++ b/internal/view/resource_browser.go @@ -3,6 +3,7 @@ package view import ( "context" "fmt" + "maps" "slices" "strings" "time" @@ -37,14 +38,13 @@ type resourceBrowserStyles struct { } func newResourceBrowserStyles() resourceBrowserStyles { - t := ui.Current() return resourceBrowserStyles{ - count: lipgloss.NewStyle().Foreground(t.TextDim), - filterBg: lipgloss.NewStyle().Background(t.Background).Foreground(t.Text).Padding(0, 1), - filterActive: lipgloss.NewStyle().Foreground(t.Accent).Italic(true), - tabSingle: lipgloss.NewStyle().Foreground(t.Primary), - tabActive: lipgloss.NewStyle().Background(t.Selection).Foreground(t.SelectionText).Padding(0, 1), - tabInactive: lipgloss.NewStyle().Foreground(t.TextDim).Padding(0, 1), + count: ui.DimStyle(), + filterBg: ui.InputFieldStyle(), + filterActive: ui.AccentStyle().Italic(true), + tabSingle: ui.PrimaryStyle(), + tabActive: ui.SelectedStyle().Padding(0, 1), + tabInactive: ui.DimStyle().Padding(0, 1), } } @@ -407,10 +407,7 @@ func (r *ResourceBrowser) GetTagKeys() []string { } } - keys := make([]string, 0, len(keySet)) - for key := range keySet { - keys = append(keys, key) - } + keys := slices.Collect(maps.Keys(keySet)) slices.Sort(keys) return keys } @@ -432,10 +429,7 @@ func (r *ResourceBrowser) GetTagValues(key string) []string { } } - values := make([]string, 0, len(valueSet)) - for value := range valueSet { - values = append(values, value) - } + values := slices.Collect(maps.Keys(valueSet)) slices.Sort(values) return values } diff --git a/internal/view/service_browser.go b/internal/view/service_browser.go index 6047df62..30c44e29 100644 --- a/internal/view/service_browser.go +++ b/internal/view/service_browser.go @@ -5,7 +5,6 @@ import ( "strings" "charm.land/bubbles/v2/textinput" - "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -40,8 +39,6 @@ type ServiceBrowser struct { cursor int // Current selection index in flatItems cols int // Number of columns in grid - width int - height int // Mouse hit testing - populated during render itemPositions []itemPosition @@ -50,8 +47,7 @@ type ServiceBrowser struct { headerPanel *HeaderPanel // Viewport for scrolling - viewport viewport.Model - ready bool + vp ViewportState // Filter filterInput textinput.Model @@ -76,7 +72,6 @@ type flatItem struct { } type serviceBrowserStyles struct { - title lipgloss.Style category lipgloss.Style cell lipgloss.Style cellSelected lipgloss.Style @@ -88,15 +83,8 @@ type serviceBrowserStyles struct { } func newServiceBrowserStyles() serviceBrowserStyles { - t := ui.Current() return serviceBrowserStyles{ - title: lipgloss.NewStyle(). - Background(t.TableHeader). - Foreground(t.TableHeaderText). - Padding(0, 1). - MarginBottom(1), - category: lipgloss.NewStyle(). - Foreground(t.TextDim). + category: ui.DimStyle(). Bold(true). MarginTop(1). MarginBottom(0), @@ -104,23 +92,15 @@ func newServiceBrowserStyles() serviceBrowserStyles { Width(cellWidth). Height(cellHeight). Padding(0, 1), - cellSelected: lipgloss.NewStyle(). + cellSelected: ui.SelectedStyle(). Width(cellWidth). Height(cellHeight). - Padding(0, 1). - Background(t.Selection), - serviceName: lipgloss.NewStyle(). - Bold(true). - Foreground(t.Text), - serviceNameSe: lipgloss.NewStyle(). - Bold(true). - Foreground(t.Primary), - aliases: lipgloss.NewStyle(). - Foreground(t.TextDim), - aliasesSel: lipgloss.NewStyle(). - Foreground(t.TextDim), - filterPrompt: lipgloss.NewStyle(). - Foreground(t.Primary), + Padding(0, 1), + serviceName: ui.TextStyle().Bold(true), + serviceNameSe: ui.TitleStyle(), + aliases: ui.DimStyle(), + aliasesSel: ui.DimStyle(), + filterPrompt: ui.PrimaryStyle(), } } @@ -205,9 +185,8 @@ func (s *ServiceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s.handleNavigation(msg) case tea.MouseWheelMsg: - // Pass wheel events to viewport for scrolling var cmd tea.Cmd - s.viewport, cmd = s.viewport.Update(msg) + s.vp.Model, cmd = s.vp.Model.Update(msg) return s, cmd case tea.MouseMotionMsg: @@ -362,42 +341,32 @@ func (s *ServiceBrowser) handleNavigation(msg tea.KeyPressMsg) (tea.Model, tea.C return s, nil } -// updateViewport updates the viewport content and scrolls to make cursor visible func (s *ServiceBrowser) updateViewport() { - if !s.ready { + if !s.vp.Ready { return } content := s.renderContent() - s.viewport.SetContent(content) + s.vp.Model.SetContent(content) - // Count total lines and find cursor line by scanning rendered content lines := strings.Split(content, "\n") totalLines := len(lines) - // Estimate cursor position based on proportion through flatItems if len(s.flatItems) == 0 { return } - // Calculate approximate line position cursorRatio := float64(s.cursor) / float64(len(s.flatItems)) targetLine := int(cursorRatio * float64(totalLines)) - vpHeight := s.viewport.Height() - currentTop := s.viewport.YOffset() + vpHeight := s.vp.Model.Height() + currentTop := s.vp.Model.YOffset() - // Scroll if cursor is outside visible area if targetLine < currentTop { - s.viewport.SetYOffset(max(0, targetLine-2)) + s.vp.Model.SetYOffset(max(0, targetLine-2)) } else if targetLine > currentTop+vpHeight-cellHeight { newOffset := targetLine - vpHeight + cellHeight + 2 - if newOffset > totalLines-vpHeight { - newOffset = totalLines - vpHeight - } - if newOffset < 0 { - newOffset = 0 - } - s.viewport.SetYOffset(newOffset) + newOffset = max(0, min(newOffset, totalLines-vpHeight)) + s.vp.Model.SetYOffset(newOffset) } } @@ -458,18 +427,15 @@ func (s *ServiceBrowser) selectCurrentService() (tea.Model, tea.Cmd) { return s, nil } -// getItemAtPosition returns the item index at the given (x, y) position, or -1 if none func (s *ServiceBrowser) getItemAtPosition(x, y int) int { - if !s.ready || len(s.itemPositions) == 0 { + if !s.vp.Ready || len(s.itemPositions) == 0 { return -1 } - // Calculate header panel height dynamically headerStr := s.headerPanel.RenderHome() headerHeight := s.headerPanel.Height(headerStr) - // Adjust y for header and viewport scroll offset - contentY := y - headerHeight + s.viewport.YOffset() + contentY := y - headerHeight + s.vp.Model.YOffset() if contentY < 0 { return -1 } @@ -489,22 +455,19 @@ func (s *ServiceBrowser) getItemAtPosition(x, y int) int { return -1 } -// ViewString returns the view content as a string func (s *ServiceBrowser) ViewString() string { - // Header panel (always visible at top) header := s.headerPanel.RenderHome() - if !s.ready { - return header + "\n" + "Loading..." + if !s.vp.Ready { + return header + "\n" + LoadingMessage } - // Filter input var footer string if s.filterActive { footer = "\n" + s.styles.filterPrompt.Render(s.filterInput.View()) } - return header + "\n" + s.viewport.View() + footer + return header + "\n" + s.vp.Model.View() + footer } // View implements tea.Model @@ -551,9 +514,9 @@ func (s *ServiceBrowser) renderContent() string { // Render services in grid rows := (len(catItems) + s.cols - 1) / s.cols - for row := 0; row < rows; row++ { + for row := range rows { var cells []string - for col := 0; col < s.cols; col++ { + for col := range s.cols { idx := row*s.cols + col if idx < len(catItems) { selected := globalIdx+idx == s.cursor @@ -564,7 +527,7 @@ func (s *ServiceBrowser) renderContent() string { rowHeight := strings.Count(rowContent, "\n") + 1 // +1 for the line after // Record positions for items in this row - for col := 0; col < s.cols; col++ { + for col := range s.cols { idx := row*s.cols + col if idx < len(catItems) { s.itemPositions = append(s.itemPositions, itemPosition{ @@ -622,41 +585,20 @@ func (s *ServiceBrowser) renderCell(item serviceItem, selected bool) string { // SetSize implements View func (s *ServiceBrowser) SetSize(width, height int) tea.Cmd { - s.width = width - s.height = height - // Set header panel width s.headerPanel.SetWidth(width) // Calculate columns based on width - s.cols = (width - cellPaddingX) / cellWidth - if s.cols < minColumns { - s.cols = minColumns - } - if s.cols > maxColumns { - s.cols = maxColumns - } + s.cols = max(minColumns, min((width-cellPaddingX)/cellWidth, maxColumns)) // Calculate header height dynamically headerStr := s.headerPanel.RenderHome() headerHeight := s.headerPanel.Height(headerStr) - // height - header + extra space - vpHeight := height - headerHeight + 1 - if vpHeight < 5 { - vpHeight = 5 - } - - if !s.ready { - s.viewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(vpHeight)) - s.ready = true - } else { - s.viewport.SetWidth(width) - s.viewport.SetHeight(vpHeight) - } + vpHeight := max(height-headerHeight+1, 5) - // Update viewport content - s.viewport.SetContent(s.renderContent()) + s.vp.SetSize(width, vpHeight) + s.vp.Model.SetContent(s.renderContent()) return nil } diff --git a/internal/view/strings.go b/internal/view/strings.go new file mode 100644 index 00000000..c8f06013 --- /dev/null +++ b/internal/view/strings.go @@ -0,0 +1,29 @@ +package view + +import ( + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +func TruncateString(s string, maxWidth int) string { + if maxWidth <= 0 { + return "" + } + if lipgloss.Width(s) <= maxWidth { + return s + } + return ansi.Truncate(s, maxWidth, "…") +} + +func TruncateOrPadString(s string, width int) string { + if width <= 0 { + return "" + } + w := lipgloss.Width(s) + if w > width { + return ansi.Truncate(s, width, "…") + } + return s + strings.Repeat(" ", width-w) +} diff --git a/internal/view/tag_search_view.go b/internal/view/tag_search_view.go index fba34a59..f50beafc 100644 --- a/internal/view/tag_search_view.go +++ b/internal/view/tag_search_view.go @@ -3,7 +3,8 @@ package view import ( "context" "fmt" - "sort" + "maps" + "slices" "strings" "sync" @@ -32,10 +33,27 @@ type taggedARN struct { RawARN string } +type tagSearchViewStyles struct { + header lipgloss.Style + status lipgloss.Style + filterWrap lipgloss.Style + filterActive lipgloss.Style +} + +func newTagSearchViewStyles() tagSearchViewStyles { + return tagSearchViewStyles{ + header: ui.TableHeaderStyle().Padding(0, 1), + status: ui.DimStyle().Padding(0, 1), + filterWrap: lipgloss.NewStyle().Padding(0, 1), + filterActive: ui.AccentStyle().Italic(true), + } +} + type TagSearchView struct { ctx context.Context registry *registry.Registry tagFilter string + styles tagSearchViewStyles table table.Model resources []taggedARN @@ -66,6 +84,7 @@ func NewTagSearchView(ctx context.Context, reg *registry.Registry, tagFilter str ctx: ctx, registry: reg, tagFilter: tagFilter, + styles: newTagSearchViewStyles(), loading: true, filterInput: ti, spinner: ui.NewSpinner(), @@ -555,18 +574,13 @@ func (v *TagSearchView) buildTable() { } func (v *TagSearchView) ViewString() string { - theme := ui.Current() + s := v.styles title := "Tag Search" if v.tagFilter != "" { title = fmt.Sprintf("Tag Search: %s", v.tagFilter) } - header := lipgloss.NewStyle(). - Foreground(theme.TableHeaderText). - Background(theme.TableHeader). - Padding(0, 1). - Width(v.width). - Render(title) + header := s.header.Width(v.width).Render(title) if v.loading { return header + "\n" + v.spinner.View() + " Searching..." @@ -591,21 +605,13 @@ func (v *TagSearchView) ViewString() string { statusLine += fmt.Sprintf(" [%d region errors]", len(v.partialErrors)) } - status := lipgloss.NewStyle(). - Foreground(theme.TextDim). - Padding(0, 1). - Render(statusLine) + status := s.status.Render(statusLine) filterView := "" if v.filterActive { - filterView = lipgloss.NewStyle(). - Padding(0, 1). - Render(v.filterInput.View()) + "\n" + filterView = s.filterWrap.Render(v.filterInput.View()) + "\n" } else if v.filterText != "" { - filterView = lipgloss.NewStyle(). - Foreground(theme.Accent). - Italic(true). - Render(fmt.Sprintf("filter: %s", v.filterText)) + "\n" + filterView = s.filterActive.Render(fmt.Sprintf("filter: %s", v.filterText)) + "\n" } if len(v.filtered) == 0 && len(v.resources) > 0 { @@ -683,11 +689,8 @@ func (v *TagSearchView) GetTagKeys() []string { } } - keys := make([]string, 0, len(keySet)) - for key := range keySet { - keys = append(keys, key) - } - sort.Strings(keys) + keys := slices.Collect(maps.Keys(keySet)) + slices.Sort(keys) return keys } @@ -703,11 +706,8 @@ func (v *TagSearchView) GetTagValues(key string) []string { } } - values := make([]string, 0, len(valueSet)) - for val := range valueSet { - values = append(values, val) - } - sort.Strings(values) + values := slices.Collect(maps.Keys(valueSet)) + slices.Sort(values) return values } @@ -716,11 +716,8 @@ func formatTags(tags map[string]string, maxLen int) string { return "" } - keys := make([]string, 0, len(tags)) - for k := range tags { - keys = append(keys, k) - } - sort.Strings(keys) + keys := slices.Collect(maps.Keys(tags)) + slices.Sort(keys) var parts []string for _, k := range keys { diff --git a/internal/view/view.go b/internal/view/view.go index adbd8470..96edbe00 100644 --- a/internal/view/view.go +++ b/internal/view/view.go @@ -19,6 +19,9 @@ const DefaultAutoReloadInterval = 3 * time.Second // FilterPlaceholder is the placeholder text for filter inputs const FilterPlaceholder = "filter..." +// LoadingMessage is the standard message shown while loading +const LoadingMessage = "Loading..." + // View is the interface for all views in the application type View interface { tea.Model diff --git a/internal/view/view_test.go b/internal/view/view_test.go index bd1e9dd3..29d679fa 100644 --- a/internal/view/view_test.go +++ b/internal/view/view_test.go @@ -61,7 +61,7 @@ func TestIsEscKey(t *testing.T) { } } -// truncateOrPad tests +// TruncateOrPadString tests func TestTruncateOrPad(t *testing.T) { tests := []struct { @@ -120,16 +120,16 @@ func TestTruncateOrPad(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := truncateOrPad(tt.input, tt.width) + got := TruncateOrPadString(tt.input, tt.width) // Check visual width (rune count for plain text with ellipsis) gotLen := len([]rune(got)) if tt.wantLen > 0 && gotLen != tt.wantLen { - t.Errorf("truncateOrPad(%q, %d) rune len = %d, want %d (got=%q)", tt.input, tt.width, gotLen, tt.wantLen, got) + t.Errorf("TruncateOrPadString(%q, %d) rune len = %d, want %d (got=%q)", tt.input, tt.width, gotLen, tt.wantLen, got) } if tt.wantEnd != "" && !strings.HasSuffix(got, tt.wantEnd) { - t.Errorf("truncateOrPad(%q, %d) = %q, want suffix %q", tt.input, tt.width, got, tt.wantEnd) + t.Errorf("TruncateOrPadString(%q, %d) = %q, want suffix %q", tt.input, tt.width, got, tt.wantEnd) } }) } diff --git a/internal/view/viewport_helper.go b/internal/view/viewport_helper.go new file mode 100644 index 00000000..0bfc33e1 --- /dev/null +++ b/internal/view/viewport_helper.go @@ -0,0 +1,18 @@ +package view + +import "charm.land/bubbles/v2/viewport" + +type ViewportState struct { + Model viewport.Model + Ready bool +} + +func (vs *ViewportState) SetSize(width, height int) { + if !vs.Ready { + vs.Model = viewport.New(viewport.WithWidth(width), viewport.WithHeight(height)) + vs.Ready = true + } else { + vs.Model.SetWidth(width) + vs.Model.SetHeight(height) + } +}