Skip to content

Conversation

lrstanley
Copy link
Contributor

@lrstanley lrstanley commented Sep 5, 2025

Describe your changes

  • We now use a half block as the fill character by default, and when it detects that the half block is used, it will double the blend/gradient resolution, using foreground and background colors in tandem.
    • Falls back to regular blending when it detects that the full character isn't a half block, as we can't reasonably determine that it's a half-block style, and/or that the character would fill the left portion of the cell. This should allow people to set the original block character if they desire for the original logic.
  • New method WithColors(color.Colors...) which supports 2+ color stops.
    • 0 colors: clears all previously set colors, setting them back to defaults.
    • 1 color: uses a solid fill with the given color.
    • 2+ colors: uses a blend of the provided colors.
  • New method WithDefaultBlend() which sets a default blend of colors (replaces WithDefaultGradient).
  • New method WithScaled(bool) toggles scaling blended colors based off filled vs full progress bar.
  • Above simplifies the API, so the following have been removed:
    • WithDefaultGradient()
    • WithGradient()
    • WithDefaultScaledGradient()
    • WithScaledGradient()
    • WithSolidFill()
  • New method WithColorFunc(ColorFunc) which allows completely dynamic styling for the progress bar (see below).
  • Exported filled and empty characters as constants
  • Moved all of the default colors to unexported variables so they can more easily be reused.
  • Switched to snapshot tests given the new more complex blending logic, as this is easier to debug and visualize.

Dependency updates

  • Updated lipgloss/v2 to latest v2-exp head charmbracelet/lipgloss@71dd8ee to get access to lipgloss.Blend1D().
  • Updated bubbletea/v2 to v2.0.0-beta.4 -- this is what caused many of the table snapshots to need to be updated, as v2.0.0-beta.1 -> v2.0.0-beta.4 moved away from the nbsp-style spaces.

Related issue/discussion

Examples

Animated with scaled blending

progress.New(
	progress.WithColors(
		charmtone.Paprika,
		charmtone.Sriracha,
		charmtone.Cherry,
		charmtone.Flamingo,
	),
	progress.WithScaled(true),
)

Animated with blending

progress.New(
	progress.WithColors(
		charmtone.Paprika,
		charmtone.Sriracha,
		charmtone.Cherry,
		charmtone.Flamingo,
	),
)

New half-block implementation which adds better resolution

New (the default):

Old:

For those who still want the old solution, they can override the fill character to the block character, which restores the old logic:

progress.New(
	progress.WithColors(
		charmtone.Tang,
		charmtone.Tuna,
		charmtone.Lilac,
		charmtone.Thunder,
	),
	progress.WithFillCharacters('█', progress.DefaultEmptyCharacter),
	progress.WithScaled(true),
)

Custom ColorFunc: using total percentage

progress.New(
	progress.WithColorFunc(func(total, current float64) color.Color {
		if total <= 0.2 {
			return lipgloss.Color("#FF0000") // Red
		}
		if total <= 0.4 {
			return lipgloss.Color("#FF8000") // Orange
		}
		if total <= 0.6 {
			return lipgloss.Color("#00FF00") // Green
		}
		if total <= 0.8 {
			return lipgloss.Color("#0080FF") // Light blue
		}
		return lipgloss.Color("#0000FF") // Dark blue
	}),
)

Custom ColorFunc: using rendered percentage

progress.New(
	progress.WithColorFunc(func(total, current float64) color.Color {
		if current <= 0.2 {
			return lipgloss.Color("#FF0000") // Red
		}
		if current <= 0.4 {
			return lipgloss.Color("#FF8000") // Orange
		}
		if current <= 0.6 {
			return lipgloss.Color("#00FF00") // Green
		}
		if current <= 0.8 {
			return lipgloss.Color("#0080FF") // Light blue
		}
		return lipgloss.Color("#0000FF") // Dark blue
	}),
)

Custom ColorFunc: advanced

using the code from @ChausseBenjamin (thanks!).

Checklist before requesting a review

@lrstanley lrstanley force-pushed the feature/v2-progress-blend branch from 4a0af78 to f5d9f69 Compare September 5, 2025 22:58
@lrstanley lrstanley changed the title feat(progress): support multiple stops and improved blend algorithm v2: feat(progress): support multiple stops and improved blend algorithm Sep 5, 2025
@ChausseBenjamin
Copy link

ChausseBenjamin commented Sep 6, 2025

Migrating from v1 to v2 seems like s good opportunity for this optimization:

  • You can effectively double the progress bar resolution by setting two colors (fg+bg) on the left-half block character (this doesn't even need to be user facing). It makes gradients much smoother with tightly packed colors and small progress bars.

I do agree your solution is the most user-friendly but not providing a gradientFunc or FillFunc interface seems like a missed opportunity for unique use cases:

  • a progress bar represented by hue effectively has 360 stops (one color per degree)
  • a user could implement a striped effect by doing a modulo n to alternate between two colors
  • maybe a user could color each step of a process (ex: download, unzip, install) a different color to give the user more info about the progression (just a random idea that I had)

Otherwise the work you did in this PR looks amazing!

@lrstanley
Copy link
Contributor Author

lrstanley commented Sep 6, 2025

Migrating from v1 to v2 seems like s good opportunity for this optimization:

* You can effectively double the progress bar resolution by setting two colors (fg+bg) on the left-half block character `▌` (this doesn't even need to be user facing). It makes gradients much smoother with tightly packed colors and small progress bars.

I can implement this as part of this PR, should be quite simple, though would you prefer to bundle that as part of your work (and maybe shift to v2 for your PR, given v2 will be out semi-soon)? Also worth noting that we would likely have to break the API for WithFillCharacters given it doesn't take into account this type of scenario.

I do agree your solution is the most user-friendly but not providing a gradientFunc or FillFunc interface seems like a missed opportunity for unique use cases:

* a progress bar represented by hue effectively has 360 stops (one color per degree)

* a user could implement a striped effect by doing a modulo `n` to alternate between two colors

* maybe a user could color each step of a process (ex: download, unzip, install) a different color to give the user more info about the progression (just a random idea that I had)

I do think there is still room to support a custom blend function for unique usecases (at least, I don't think this PR will conflict with that). Do you happen to have some example code of what you're thinking for such a function?

@lrstanley
Copy link
Contributor Author

lrstanley commented Sep 6, 2025

1 other thought: should we consolidate WithSolidFill(), WithDefaultBlend(), WithBlend(), WithDefaultScaledBlend() & WithScaledBlend(), into these?:

  • WithColors(color.Color...) - adjusts what it does purely on number of arguments. 0 uses default, 1 uses solid fill, 2 uses blending.
  • WithScaled() - no longer has arguments, simply toggles scaling, so we don't duplicate the argument logic.

done.

@aymanbagabas
Copy link
Member

1 other thought: should we consolidate WithSolidFill(), WithDefaultBlend(), WithBlend(), WithDefaultScaledBlend() & WithScaledBlend(), into these?:

  • WithColors(color.Color...) - adjusts what it does purely on number of arguments. 0 uses default, 1 uses solid fill, 2 uses blending.
  • WithScaled() - no longer has arguments, simply toggles scaling, so we don't duplicate the argument logic.

I'm all for this +1. What do you all think @meowgorithm @raphamorim @andreynering @caarlos0 ?

@raphamorim
Copy link
Member

I'm all for this +1. What do you all think @meowgorithm @raphamorim @andreynering @caarlos0 ?

+1 , I may think WithColors with 0 uses default a bit confusing tbh (I would rather do nothing or have a different behaviour), but no strong opinions.

@ChausseBenjamin
Copy link

ChausseBenjamin commented Sep 8, 2025

@lrstanley

Do you happen to have some example code of what you're thinking for such a function?

My thought was a user could pass in a function with this signature as an Option

// ProgressFillFunc defines a function that returns which color 
// to use for a given *current* part of the progress bar.
// totalCompletion: Overall completion of the progress bar (0.0 to 1.0)
// current: Position within the progress bar where the color is needed (0.0 to 1.0)
type ProgressFillFunc func(totalCompletion, current float64) color.Color

Then take the following example: Say a user wants to split their progress bar into sections representing 3 package downloads (5Mb, 2Mb, and 3Mb), they could do implement something along those lines:

// Example sequential download
func DowloadsProgressFill(totalCompletion, current float64) color.Color {
  if total < 0.5 {
    return lg.Color("#FF0000")
  } 
  if total < 0.7 {
    return lg.Color("#00FF00")
  }
  return lg.Color("#0000FF")
}

Another example would be a stipe effect that animates as if it were a drawbar like so:

// CreateStripedLuminanceGradient creates a moving striped progress bar using HSL luminance variations.
// The stripes move as the progress advances, creating an animated effect.
// hue: base hue (0-360), saturation: base saturation (0-1), stripeWidth: width of each stripe (0-1)
func CreateStripedLuminanceGradient(hue, saturation, stripeWidth float64) ProgressFillFunc {
	// Clamp parameters to valid ranges
	h := math.Mod(hue, 360)
	if h < 0 {
		h += 360
	}
	sat := math.Max(0, math.Min(1, saturation))
	width := math.Max(0.01, math.Min(1, stripeWidth)) // Minimum stripe width to avoid division by zero

	return func(totalCompletion, current float64) color.Color {
		// Create moving stripe pattern based on current position and total completion
		// The totalCompletion acts as the "x offset" making stripes appear to move
		stripePosition := (current - totalCompletion) / width

		// Use modulo to create repeating pattern (0 to 1)
		stripe := math.Mod(stripePosition, 1.0)

		// Create luminance variation: oscillate between 0.2 and 0.8
		// Using sine wave for smooth transitions
		luminance := 0.5 + 0.3*math.Sin(stripe*2*math.Pi)

		// Clamp luminance to valid range
		luminance = math.Max(0.1, math.Min(0.9, luminance))

		// Create HSL color and return
		return colorful.Hsl(h, sat, luminance) // <- would need to be refactored to avoid using legacy colorful but you get the picture ;)
	}
}

Simple functions as such could give the user visuals like the following:

Screen.Recording.2025-09-08.at.18.28.23.mov

Even the Blend function could implement this function operation in the background if we want to keep the interface uniform for everything.

@meowgorithm
Copy link
Member

1 other thought: should we consolidate WithSolidFill(), WithDefaultBlend(), WithBlend(), WithDefaultScaledBlend() & WithScaledBlend(), into these?:

  • WithColors(color.Color...) - adjusts what it does purely on number of arguments. 0 uses default, 1 uses solid fill, 2 uses blending.
  • WithScaled() - no longer has arguments, simply toggles scaling, so we don't duplicate the argument logic.

+1, this is a great call.

@lrstanley lrstanley force-pushed the feature/v2-progress-blend branch 4 times, most recently from 7c318af to f5dd3fb Compare September 11, 2025 00:49
@lrstanley
Copy link
Contributor Author

Ok, I think it's ready for review again, @meowgorithm @ChausseBenjamin @aymanbagabas @raphamorim. The new changes include:

  • Now using half-block implementation for double color resolution (but still supports old full block). Thanks for the idea @ChausseBenjamin.
  • Simpler API using WithColors(colors...) -- I did slightly change what the 0-length does based off my previous post, so now it clears the values. Let me know if that's ok or something else is more preferred.
  • WithScaled(bool) for toggling scaling.
  • WithColorFunc(fn), which supports a ColorFunc function with a signature of func(total, current float64) color.Color, to support various dynamic usecases (pretty much anything anyone can think of?). Thanks for the idea @ChausseBenjamin.
    • This also supports automatic improved resolution when half-block is used.
  • Removed all of the deprecated stuff.

I did look at including the multi-step-block from 680 (or even just supporting rendering the last cell with a half block for partial percentages), but the experience by default isn't great, as there is unstyled space between the start of the block on the last cell, and the start of rendering the empty block, and seems only to work well when someone overrides the background + doesn't set (or uses the default) empty character.

Updated the main description with all of the new gifs, more examples, etc.

@lrstanley lrstanley force-pushed the feature/v2-progress-blend branch from f5dd3fb to b76ab88 Compare September 11, 2025 01:12
@ChausseBenjamin
Copy link

@lrstanley This is insanely good!!! 🤯

Copy link
Member

@raphamorim raphamorim left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code wise looks good, will leave to @meowgorithm check as well

@ChausseBenjamin
Copy link

ChausseBenjamin commented Sep 12, 2025

I did look at including the multi-step-block from 680 (or even just supporting rendering the last cell with a half block for partial percentages), but the experience by default isn't great, as there is unstyled space between the start of the block on the last cell, and the start of rendering the empty block, and seems only to work well when someone overrides the background + doesn't set (or uses the default) empty character.

It just occured to me that a similar mechanism to your half-block check could be applied here. Something like this could enable the behaviour:

if Model.Empty == ' ' && slices.Contains([]rune{'▌', '█'}, Model.Full){
  // Use smooth animation blocks for the intercecting character between full and empty
}

I don't mind writing my own PR for it once this one gets merged if you want to avoid scope-creep ;)

@lrstanley
Copy link
Contributor Author

It just occured to me that a similar mechanism to your half-block check could be applied here. Something like this could enable the behaviour:

if Model.Empty == ' ' && slices.Contains([]rune{'▌', '█'}, Model.Full){
  // Use smooth animation blocks for the intercecting character between full and empty
}

I don't mind writing my own PR for it once this one gets merged if you want to avoid scope-creep ;)

I think the main problem with the more advanced block percentage logic is this:

but the experience by default isn't great, as there is unstyled space between the start of the block on the last cell, and the start of rendering the empty block, and seems only to work well when someone overrides the background color + doesn't set (or doesn't use the default) empty character.

Given that, I think it will need a lot more care and thorough brainstorming in terms of API and out-of-box behavior that probably should be a separate PR.

@lrstanley lrstanley force-pushed the feature/v2-progress-blend branch from b76ab88 to 3b2b23b Compare September 12, 2025 20:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants