Skip to content

Conversation

@hjfeldy
Copy link

@hjfeldy hjfeldy commented Sep 23, 2025

Lualine Dynamic Alternative States (ie. modes)

This extension adds syntactic sugar to lualine, allowing users to cleanly configure complex dynamic setups. With this addition, lualine will determine the current "mode" of each lualine component and dynamically select a specific configuration for that component, based on the mode.

The Problem

Lualine is already plenty flexible out of the box - a dynamic statusline is achievable by using lualine function components and condition functions.

sections = {
  lualine_a = {
    -- This component will display a dynamic value,
    -- someDynamicGetter() could be evaluating a vim option,
    -- a global variable, etc - any arbitrary condition
    {
      function() 
        local dynamicValue = someDynamicGetter()
        if dynamicValue == case1Value then
          return 'case 1'
        elseif dynamicValue == case2Value then
          return 'case 2'
        else
          return 'default'
        end
      end
    }
  }
}

In the simpler case, where the component displays a single value based on some condition, a lualine condition-function can be used:

sections = {
  lualine_a = {
    {
      function() 
        return "Case 1 or 2 is true"
      end,
      cond = function()
        local dynamicValue = someDynamicGetter()
        retun dynamicValue == case1Value or dynamicValue == case2Value
      end
    }
  }
}

Here, the cond field is essentially syntactic sugar - Rather than specify the condition as a separate field, we could have just written the following:

sections = {
  lualine_a = {
    {
      function() 
        local dynamicValue = someDynamicGetter()
        local conditionMet = dynamicValue == case1Value or dynamicValue == case2Value
        return conditionMet and "Case 1 or 2 is true" or ""
      end,
    }
  }
}

But having this "cond" field feels much more natural, and it adheres to the single-responsibility principle. The function that renders a component's content should be distinct from the function that decides whether or not the content should be rendered.

This new proposed feature is essentially a natural extension of these principles. I found myself wanting to write it when thinking about different lualine "modes" that I could switch between dynamically. Specifically, I wanted to change the state of lualine whenever a Telescope window was opened. Lualine doesn't really interact with Telescope directly, and there isn't any natural way to implement a Telescope extension for lualine. This is of course because Telescope deals only in floating windows (which do not have statuslines). So I had to implement something custom for my use case, and I found myself having to write lots of code that looked like this:

inactive_sections = {
  lualine_a = {
    {
      function() 
         local dynamicValue = someDynamicGetter()
         if dynamicValue == case1Value then
            -- case 1 rendering logic
            return "case1"
         else
            -- case 2 rendering logic
            return "case2"
         end
      end,
    }
  }
}

For even 2 cases, it's pretty verbose. And if you wanted to generalize to >2 distinct modes (ie. "short", "medium" and "long" modes designed for various window configurations / monitor setups, etc), it would get pretty hairy.

You could of course clean this logic up with something like this:

local RENDER_LUALINE_A_1 = {
    [case1Value] = "case1 (1st component)",
    [case2Value] = "case2 (1st component)" 
}

local RENDER_LUALINE_A_2 = {
    [case1Value] = "case1 (2nd component)",
    [case2Value] = "case2 (2nd component)" 
}

inactive_sections = {
  lualine_a = {
    {
      function() 
         return RENDER_LUALINE_A_1[someDynamicGetter()] or ""
      end,
    },
    {
      function() 
         return RENDER_LUALINE_A_2[someDynamicGetter()] or ""
      end,
    }
  }
}

Which is a step in the right direction, but still not great. Also, if you want to change the colors in the same dynamic way, you would need to repeat all the same logic in the color field of the component options, defining variables like LUALINE_A_1_COLORS, etc... Not great.

Ultimately, if a user wants to achieve this functionality, they need to write their own custom implementation which will likely look strange in some way or another.
One thing I love about lualine is the natural readability of its configuration. So I thought it might be good to offer this dynamic functionality out of the box in a more declarative way.

The Solution - Dynamic Modes

This feature is composed of two things:

  1. An API for changing the mode of a component (or setting a global "fallback" mode)
  2. An extension of lualine's configuration logic which allows users to configure how their components "react" to these modes

API

Modes can be set for a specific component, or globally. The global mode serves as a fallback - if no mode is set for a component, then the global mode will be used.

To set a mode for a specific component:

require('lualine.dynamicMode').setMode(componentName, mode)

To set a mode globally:

require('lualine.dynamicMode').setGlobalMode(mode)

To clear a mode for a specific component:

require('lualine.dynamicMode').setMode(componentName, nil)

Note: This causes getMode(componentName) to return the global mode

To clear the global mode:

-- both equivalent
require('lualine.dynamicMode').setMode(componentName, nil)
require('lualine.dynamicMode').setMode(componentName, "normal")

The global mode can be "set to nil", but the global mode cannot actually ever be nil.
As a fallback, the global mode (and by extension each unset component mode) evaluates to normal when no mode is set

Aliasing cond with altModes

The simplest way to use this feature is essentially as an alias for the cond field. By specifying an array of altModes, we can tell lualine to display this component only when at least one of these modes is turned on.

See this configuration example:

require('lualine').setup({
  -- only the inactive_sections matter when telescope is opened,
  -- because the Telescope prompt itself does not have a statusline
  inactive_sections = {
    lualine_a = {},
    lualine_b = {
      {
        "filetype",
        icon_only = true,
        separator = "",
        padding = { left = 1, right = 0 } 
      },
      {
        "filename",
        path = 1
      },
    },
    lualine_x = {},
    lualine_c = {},
    lualine_y = {
      {
        function() return "" end, -- telescope icon
        separator = "",

        -- component will not display unless its mode (or the global mode)
        -- is equal to "telescopeFiles"
        altModes = {'telescopeFiles'}
      },
      {
        function() 
          -- retrieve a dynamic configuration value "SHOW_HIDDEN" 
          -- Determine the logo to display based on this value
          return telescopeHelpers.SHOW_HIDDEN and "󰈈 " or "󰈉 " -- open/closed eyeball icons
        end,
        separator="",
        padding = { left = 1, right = 0 },
        -- component will not display unless its mode (or the global mode)
        -- is equal to "telescopeFiles"
        altModes = {'telescopeFiles'}
      },
      {
        function() 
          return "" -- git logo
        end,
        separator="",
        color = function() 
          -- Retrieve a dynamic configuration value "RESPECT_IGNORE" 
          -- Determine the color of the git logo based on this value
          return {fg=telescopeHelpers.RESPECT_IGNORE and "green" or "red"}
        end,
        padding = { left = 0, right = 0 },
        -- component will not display unless its mode (or the global mode)
        -- is equal to "telescopeFiles"
        altModes = {'telescopeFiles'},
      }
    },
    lualine_z = {
      {
        -- Render the current working directory (substituting HOME with "~" for brevity)
        function() 
            return 'CWD: ' .. vim.uv.cwd():gsub(os.getenv('HOME'), '~')
        end,
        -- Component will not display unless its mode (or the global mode) is equal to "normal"
        -- This is the default fallback mode for all components.
        -- When no component/global mode has been explicitly set, getMode() always returns "normal"
        -- In effect, this configuration says "Do not display this component when *any* mode has been set"
        altModes = {'normal'}
      }
    }
  }
})

With this configuration, an inactive window will appear like so, in the absence of any modes.

inactive

When the telescopeFiles mode is set, the inactive windows appear like so:

active

Notice the telescope icon, the eyeball icon, and the git logo icon in the bottom right corner of each inactive window.

Negations

Optionally, altModes can be prefixed with "!" to specify that any mode except the listed altModes will cause the component to be rendered. For example, the configuration altModes = {"!myMode"} will cause the component to disappear whenever its mode is set to "myMode". Similarly, a component can be made to appear whenever any mode has been set by specifying altModes = {"!normal"}

Note that the altModes prefixed with "!" function as an AND gate, and the normal altModes function as an OR gate.

Arbitrary Per-Mode Configurations

The cond alias is nice, but we can also express more complex logic. Suppose you want to tweak the display of a component according to its mode, rather than just turning it on/off.

To do this, you can specify a map of alts (alternative configuration values) to the component's options. For example:

require('lualine').setup({
  -- only the inactive_sections matter when telescope is opened,
  -- because the Telescope prompt itself does not have a statusline
  inactive_sections = {
    lualine_a = {},
    lualine_b = {
      {
        "filetype",
        icon_only = true,
        separator = "",
        padding = { left = 1, right = 0 } 
      },
      {
        "filename",
        path = 0,
        file_status = false,
        alts = {
            mode1 = {
                symbols = {
                    unnamed = '[Empty Buffer (Mode 1)]'
                },
            },
            mode2 = {
                path = 2,
                symbols = {
                    unnamed = '[Empty Buffer (Mode 2)]'
                },
            },
            mode3 = {
                path = 2,
                symbols = {
                    unnamed = '[Empty Buffer (Mode 3)]'
                }
            },

        }
      },
    },
    lualine_c = {},
  }
})

This configuration creates a filename component which changes to one of 4 states, based on the current mode.

Notice that modes 2 and 3 override the path parameter, and none of the modes define the file_status parameter. By default, the base component's configuration values are inherited by its alternative states unless overridden. You can think of it as a parent-child relationship.

These particular configurations are obviously trivial, but they serve as a good visual example of the feature's capabilities.

Here is a more practical example:

sections = {
  lualine_a = {
    {
    "mode",
    alts = {
      short = {
        function() 
          -- Return just the first character of "Normal" / "Visual" / etc...
          return vim.fn.mode():sub(1, 1):upper()
        end
        }
      }
    }
  }
}

When the short mode is set, this component will render as a lualine function component with a custom output (the first letter of the current input mode - "V" for "Visual", "N" for "Normal", etc). If this component's mode is anything other than short, the component will display the builtin lualine "mode" component (the full word, rather than the first letter).

demo.mp4

Additional Features

For experimentation and demoing purposes a Telescope picker is provided to cycle between modes.
Selecting an entry in the picker will toggle the global mode between the selected entry and the normal mode.

The inactive_sections portion of your config will receive live updates as you interact with the picker.
Telescope must be closed to view the active_sections portion however (unless the tabline/winbar is used).
This is why I've mostly used the inactive_sections in the demo materials.

Implementation Details

The implementation is pretty lightweight. Whenever a component is instantiated, alternative configurations are constructed based on the user's configuration (the component's "base" configuration is supplemented / overridden to generate each alt configuration). These alt configurations are then used to instantiate separate components which are stored alongside the original component (each base component has an "alts" table which maps mode-names to these alternative components)

Whenever a section is rendered, we simply check whether or not an alternative component should be used in place of our base components. If so, we simply use that component in place of the base component:

  for _, component in pairs(section) do
    local alt = component:getAlt() 
    if alt then
      component = alt
    end
    -- proceed with rendering logic...

The cond aliasing logic with the altModes parameter is implemented in a completely distinct way. If altModes is specified, we simply wrap the component's existing cond function (or creating one if there no such function is declared).

Developer Notes

Some of the naming conventions could definitely be cleaned up. "customPicker" is a pretty bad name for the Telescope picker (although I don't really think the picker has much purpose beyond demoing this feature - I'd be happy to remove it altogether). The commit history is a little messy as well. @nvim-lualine if you're a fan of this feature and would like it to be merged, I'm happy to clean these things up.

If this feature does end up getting approved, I'm also happy to update the docs

@hjfeldy hjfeldy changed the title Feature/dynamic modes feat: dynamic modes Sep 23, 2025
@shadmansaleh
Copy link
Member

Thanks for opening the pr. There's a lot. I'll look at the complete thing later. But I'm not yet sure we want to add something like this. This complicates the config process quite. In general my policy has been keep things flexible so complicated setups are possible but not to cater toward any specific complex setup, unless it provides a good enough premmitive with little cost. This feels like it assumes a style of config.

Also, if I understand your usecases are handling floating windows and changing config based on set mode if I'm not mistaken.

For the first one I'd really recommend you to try out globalstatus mode. require('lualine').setup({options={globalstatus=true)}) . In that you'll have single statusline that will show status based on whichever active window you are in including floating windows. With that floating windows show status just like any regular window and you don't need to special-case..

For changing out configs on the fly based on mode, calling lualine.setup as many times as you want is fine. For example, you instead of putting a condition on everything, you can do a layered config like

base = {
 ...
 }
 
narrow = vim.tbl_extend('force', base, {
  -- custom part that needs change for narrow config
})
wide = vim.tbl_extend('force', base, {
  -- custom part that needs change for wide config
})
-- same as above. Basically separate out the configs you are trying to have in on table.

Then you can do something like

local current_config = 'wide'  -- you can do a screenwidth check and set the default based on that
function toggle_modes()
  if current_config = 'wide' then lualine.setup(narrow)
  else lualine.setup(wide)
  end
end

You get the idea. You can obviously instead of toggle just change to specific config through a telescope picker.

Also if you need sperate style on per filetype basis you can look at lualine-extensions . you can also have custom extensions.

let me know your thoughts on these.

Actually allowing more flexible ways to check when an extension get's loaded would probably be a better idea then just based on filetypes.

@hjfeldy
Copy link
Author

hjfeldy commented Sep 24, 2025

Hey, thanks for the reply.

I wasn't aware that setup() could be called repeatedly like that - other plugins can be kind of finnicky in that respect. You're right though, that would accomplish the same thing that I'm looking for. I guess you could still make an argument that this feature would offer something useful by baking the dynamic setup() pattern into the lualine config itself and making the configuration(s) "horizontal" rather than "vertical" if you know what I mean.

I was playing around with this config earlier today:

cropped.mp4

One thing I like about it is the fact that all of the configuration variations for this particular component are together in one place. But this is really a matter of stylistic preference - I can imagine someone else feeling the exact opposite way (that all of the narrow configurations should be in one place, all the medium configurations in another place, etc). This is what I mean by "vertical" vs "horizontal".

No worries if you feel that the benefits don't outweigh the complexity costs (although FWIW, there is much more complexity in the feature's usage / documentation than in the implementation itself).

As for globalstatus, I did consider that, but I'm not personally a fan. Even when a floating window is open, I still like to see the individual filenames of the out-of-focus buffers in the background. Just a preference of mine (which I admit is a little odd).

@shadmansaleh
Copy link
Member

I'll keep this open. If I see more interest from community then I'll consider it further but for now it's a no merge for me. Primarily for two reasons.

  1. The problem it's trying to solve can already be solved by existing means
  2. The horizontal approach creates further table nesting in config. Config kind of get's confusing as you increase level of nesting. I'd even like to reduce the layers of nesting already we have, but can't do since there's not enough reason to break configs.

I understand your argument for vertical vs horizontal. But due to point 2 mentioned above I'm against it. Also, there's not sufficient reason to support horizontal approach to since vertical is already supported.

still like to see the individual filenames of the out-of-focus buffers in the background. Just a preference of mine (which I admit is a little odd).

Not odd at all. That's perfectly fine. You can try out putting filename in winbar and globalstatus for the statusline if you haven't already.

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.

2 participants