chrisant996 / clink

Bash's powerful command line editing in cmd.exe
https://chrisant996.github.io/clink/
GNU General Public License v3.0
3.6k stars 142 forks source link

question: how to put text on the right side of a prompt #145

Closed bw1faeh0 closed 3 years ago

bw1faeh0 commented 3 years ago

Is there a way to create a prompt where some symbols are positioned to the far right of the prompt (but in the same line), like in the screenshot? image

Something like moving cursor to the right, output some symbols, and after that moving the cursor back to the start column?

chrisant996 commented 3 years ago

The screen shot has two lines.

If the question is about the second line:

If the question is about the first line:

bw1faeh0 commented 3 years ago

The screenshot is from a z-shell (zsh).\ The question is about the second line.\ In the case the input string becomes too long, the far right of the prompt disapears:

image

So, at the end, the input length is not limited, the prompt will be overwritten, which is OK in my opinion.

chrisant996 commented 3 years ago

I see, then both of my answers are relevant. 👍

For example:

In other words, this is almost entirely about using ANSI escape codes (the only reason console.cellcount() is needed is to deal with complex Unicode scripts, where some symbols use more than one cell).

chrisant996 commented 3 years ago

Or put another way, if you can find the program or script that was used to generate the zsh prompt string, it will likely work with Clink if the script language is translated to Lua instead of shell script. Because it's not about the script language; it's just a matter of using ANSI escape codes.

chrisant996 commented 3 years ago

But again, Readline won't know that the text is there, and won't do anything to preserve or clear the text, so the text may be overwritten or shifted, etc. So it can be done, if there is acceptance of Readline's limitations.

bw1faeh0 commented 3 years ago

Sorry, I just have to ask a follow up question...

For quick testing I've done something like this:

...
-- build prompt...

local saveCursorPos = "\033[s" 
local restoreCursorPos = "\033[u" 

prompt = prompt .. saveCursorPos .. "                    Bla" .. restoreCursorPos

return prompt

Result looks like this:

image

So, the escape codes were not recognized.

I tried to surround them by \001 and \002 like this local saveCursorPos = "\001\033[s\002" and this local restoreCursorPos = "\001\033[u\002" as described in ANSI escape codes in the prompt string, but didn't worked either.

I'm using escape codes for coloring without problems inside the string, as you can guess from my screenshot.

chrisant996 commented 3 years ago
local saveCursorPos = "\033[s" 
local restoreCursorPos = "\033[u" 
prompt = prompt .. saveCursorPos .. "                    Bla" .. restoreCursorPos

... the escape codes were not recognized.

What terminal host is being used? The terminal host might not support those escape codes. Windows Terminal, ConEmu, ConsoleZ, and native VT support in Windows all support them. But if some other terminals don't, then the new clink-select-complete command will likely have a garbled display, and I will need to rewrite how it moves the cursor around.

If your preferred terminal doesn't support CSI s/u then rearrange the prompt to not use them. First do the right side of the last line of the prompt, then use "\r" to move the cursor to column 1, then do the left side of the last line of the prompt.

This is general ANSI escape code stuff, and isn't per se about Clink. I mention that only because it means generic ANSI escape code examples and prompt examples can be used with Clink, because there's not really any Clink-specific stuff going on here. For example, if there's a prompt configuration from oh-my-posh or another prompt-generating program, you can examine the ANSI escape codes they generate, and use those. Or possibly just invoke the program to produce the prompt (see the oh-my-posh example in the Clink docs).

I tried to surround them by \001 and \002 like this local saveCursorPos = "\001\033[s\002" and this local restoreCursorPos = "\001\033[u\002" as described in ANSI escape codes in the prompt string, but didn't worked either.

That sounds right: The \001 and \002 characters are just so that Readline doesn't need to parse ANSI escape codes itself, so that it can exclude them from counting the visible length of the prompt text. Clink automatically adds them for all ANSI escape codes that Clink handles (and even for some that Clink doesn't handle).

By the way: Do you actually mind discussing such topics in git issues? If yes... I also like to write mails. Just drop me a line @ git@mail.flaemig42.de

Thank you for offering -- I think my email address is public in my github profile (let me know if it isn't). I'm generally happy to discuss in issues: they are searchable, which means in the future others can benefit from the conversations.

bw1faeh0 commented 3 years ago

What terminal host is being used? The terminal host might not support those escape codes.

I'm using Windows Terminal Preview v1.10.1933.0. I will check if there is a issue reported regarding these two escape codes within the Windows Terminal community.

bw1faeh0 commented 3 years ago

Problem found: The Escape-Sequence in

local saveCursorPos = "\033[s" 
local restoreCursorPos = "\033[u" 

is wrong, has to be \x1b:

local saveCursorPos = "\x1b[s"
local restoreCursorPos = "\x1b[u"

Result: image

My first source was https://tldp.org/HOWTO/Bash-Prompt-HOWTO/x361.html. Maybe bash uses different escape sequences...

chrisant996 commented 3 years ago

Problem found: The Escape-Sequence in

local saveCursorPos = "\033[s" 
local restoreCursorPos = "\033[u" 

is wrong, has to be \x1b:

local saveCursorPos = "\x1b[s"
local restoreCursorPos = "\x1b[u"

I'm glad you got it working. But the details don't sound right.

The "\033" is syntax for an octal (base 8) character. Octal 33 is decimal 27, which is the Escape character.

The "\x1b" is syntax for a hexadecimal (base 16) character. Hex 1b is decimal 27, which is the Escape character.

They're the same... 🙃

I wonder if there might have been another detail that changed somewhere in some of the code that wasn't listed.

bw1faeh0 commented 3 years ago

I wonder if there might have been another detail that changed somewhere in some of the code that wasn't listed.

Here you are. I think there is no big deal about the cursor positioning stuff...

local green  = "\x1b[92m"
local brightYellow = "\x1b[93m"
local yellow = "\x1b[33m"
local cyan   = "\x1b[36m"
local red = "\x1b[91m"
local normal = "\x1b[m"
local color = green

local saveCursorPos = "\x1b[s"
local restoreCursorPos = "\x1b[u"
local setCursorPos1 = "\x1b["
local setCursorPos2 = "C"

local branchString = ""
-- status git repo
local sgr = green

local function get_git_dir(dir)
    -- Check if the current directory is in a git repo.
    local child
    repeat
        if os.isdir(path.join(dir, ".git")) then
            return dir
        end
        -- Walk up one level to the parent directory.
        dir,child = path.toparent(dir)
        -- If child is empty, we've reached the top.
    until (not child or child == "")
    return nil
end

local function get_git_branch()
    -- Get the current git branch name.
    local file = io.popen("git branch --show-current 2>nul")
    local branch = file:read("*a"):match("(.+)\n")
    file:close()
    return branch
end

local function get_git_status()
    -- The io.popenyield API is like io.popen, but it yields until the output is
    -- ready to be read.
    local file = io.popenyield("git --no-optional-locks status --porcelain 2>nul")
    local status = false
    local lineLen = 0
    for line in file:lines() do
        -- If there's any output, the status is not clean.  Since this example
        -- doesn't analyze the details, it can stop once it knows there's any
        -- output at all.
        status = true
        break
    end
    file:close()
    return status
end

-- A prompt filter that discards any prompt so far and sets the
-- prompt to the current working directory.  An ANSI escape code
-- colors it brightYellow.
local my_prompt = clink.promptfilter(30)
function my_prompt:filter(prompt)

    dir = os.getcwd()
    console.settitle(dir)
    local lastReturnValue = os.getenv('ERRORLEVEL')

    if lastReturnValue ~= "0" then
        color = red
    else
        color = green
    end

    dirString = color.."<"..brightYellow..dir..color..">"
    dateString = color.."<"..brightYellow..os.date("%x")..color..">"
    lastReturnValueString = color.."-<"..brightYellow.."↳ "..lastReturnValue..color..">-"
    userName = color.."<"..brightYellow..os.getenv('USERNAME')..color.." @ "..brightYellow..os.getenv('COMPUTERNAME')..color..">-<"
    branchStringOffset = 0

    -- Do nothing if not a git repo.
    local gitDir = get_git_dir(os.getcwd())
    if not gitDir then
        -- do nothing
        branchString = ""
        sgr = green
    else
        -- Reset the cached status if in a different repo.
        if prev_dir ~= gitDir then
            prev_status = nil
            prev_dir = gitDir
        end

        branch=get_git_branch()
        if not branch or branch == "" then
            branchString = ""
            sgr = green
        else
            -- Start a coroutine to get git status, and returns nil immediately.  The
            -- coroutine runs in the background, and triggers prompt filtering again
            -- when it completes.  After it completes the return value here will be the
            -- result from get_git_status().
            local status = clink.promptcoroutine(get_git_status)
            -- If no status yet, use the status from the previous prompt.
            if status == nil then
                status = prev_status
            else
                prev_status = status
            end

            -- Choose color for the git branch name:  green if status is clean, brightYellow
            -- if status is not clean, or default color if status isn't known yet.

            if status ~= nil then
                sgr = status and yellow or color
                if status == true then
                    branchString = red.."["..brightYellow..branch..red.."]"..color
                else
                    branchString = "["..brightYellow..branch..color.."]"
                end
            end
        end
    end

    --
    -- first line
    --
    promptFirstLine = color..">-"..dateString.."-"..dirString.."-"..branchString

    -- calculate visible characters of the first line of the prompt
    promptFirstLineLength = console.cellcount(promptFirstLine) + console.cellcount(userName)

    -- create fillstring depending on current stringlength
    fillString = ""
    for i=promptFirstLineLength, console.getwidth()-1, 1 do
        fillString = fillString.."-"
    end

    -- complete first line: date, dir, branch (optional), username
    promptFirstLine = "\n"..promptFirstLine..fillString..userName.."\n"

    --
    -- second line
    --
    promptSecondLine = ">-<"..brightYellow..os.date(" %X ")..color..">-"..sgr.."|> "
    -- calculate visible characters of the second line of the prompt
    promptSecondLineLength = console.cellcount(promptSecondLine) + console.cellcount(lastReturnValueString)

    -- calculate number of cursorposition movements to the right
    local n = console.getwidth() - promptSecondLineLength - 1

    -- complete second line (putting last returnValue to the far right)
    promptSecondLine = promptSecondLine..saveCursorPos..setCursorPos1..n..setCursorPos2..lastReturnValueString.."<"..restoreCursorPos

    prompt = promptFirstLine..promptSecondLine

    return prompt
end

-- Alias funcion
local function alias(short, long)
    os.execute('doskey ' .. short .. '=' .. long)
end

-- create aliases
alias('l',     'dir /B /P')
alias('ll',    'dir /D /P')
alias('lll',   'dir')
alias('gitl',  'git log --graph --color --decorate=auto --oneline')
alias('gitll', 'git log --graph --color --decorate=auto --name-status --abbrev-commit')
alias('top',   'ntop -s CPU%')
alias('stop',  'sudo ntop -s CPU%')
alias('cat',   'bat $1')
alias('type',  'bat $1')
chrisant996 commented 3 years ago

Oh wow Lua is weird compared to other languages:

Under section 3.1 Lexical Conventions the Lua manual states:

A byte in a literal string can also be specified by its numerical value. This can be done with the escape sequence \xXX, where XX is a sequence of exactly two hexadecimal digits, or with the escape sequence \ddd, where ddd is a sequence of up to three decimal digits. (Note that if a decimal escape is to be followed by a digit, it must be expressed using exactly three digits.) Strings in Lua can contain any 8-bit value, including embedded zeros, which can be specified as '\0'.

In other words:

So in Lua the Escape character must be written as \027 or \x1b.

The \xXX syntax is pretty universal across programming languages, so it's good for portability. The main reason I avoid the \ddd syntax is that octal is rarely used and so numbers in octal are unfamiliar to read, and C/C++ don't have a literal string escape for using decimal to represent a character, thus hexadecimal is generally the way to go for clarity.

chrisant996 commented 3 years ago

Also, sorry but it goes haywire when the input line reaches the right justified portion. Because Readline counted the right-justified portion as though it had been left-justified, so Readline is confused where the right margin is.

I don't think this will be able to work very soon, but I will explore how it could be supported in the future.

chrisant996 commented 3 years ago

Oh, good: zsh has a special separate configurable prompt for the right-justified prompt segment. I was hoping for that; it shouldn't be much work to hook it up as a separate prompt string. Maybe sometime in the next week or two.

bw1faeh0 commented 3 years ago

I don't think this will be able to work very soon, but I will explore how it could be supported in the future.

I'm pretty fine with my current solution. It is only the return code that is displayed to the right of the line. It does not matter if this string is overwritten by user input on the command line or if it disappears after starting typing.

it shouldn't be much work to hook it up as a separate prompt string. Maybe sometime in the next week or two.

I can't estimate the efforts needed for that, but in my point of view it is more eyecandy and not as important...

chrisant996 commented 3 years ago

I think you haven't tried typing enough at the input line to reach the right-justified text.

Try doing it. You'll see how badly it gets garbled.

bw1faeh0 commented 3 years ago

Try doing it. You'll see how badly it gets garbled.

I got your point image

My current work-around:\ Using a 27" display with windows terminal in fullscreen 😆 So there is enough input line available 😃 image

chrisant996 commented 3 years ago

Well that turned out to be easy, after all. A few small surgical changes in Readline, and a little plumbing code in Clink. The Readline changes should even be shareable with Chet Ramey for potential inclusion in the public Readline library, and maybe even in bash, if he wants to add support there.

Changes to Readline code: 90067711b3617b3e4b3504108c94958511ad5a81, 33520f929baad2a3b9e951a00dc92e2fe21824d5 Changes to Clink code: 5d24b0f5baf3d593641a0663ec5604dcdadca244, f6e9e4bff5e46222b01c192f22e599550ac8c6fa, 3ed09e642269dbb4244bfda9d64960e81cbf420c

chrisant996 commented 3 years ago

I updated the script to use :rightfilter() for right side prompt. Here is a clink.1.2.24.0ed435.zip with a pre-release build if you want to try it out. I'll publish a release build sometime next week.

local green  = "\x1b[92m"
local brightYellow = "\x1b[93m"
local yellow = "\x1b[33m"
local cyan   = "\x1b[36m"
local red = "\x1b[91m"
local normal = "\x1b[m"
local color = green

local branchString = ""
-- status git repo
local sgr = green

local function get_git_dir(dir)
    -- Check if the current directory is in a git repo.
    local child
    repeat
        if os.isdir(path.join(dir, ".git")) then
            return dir
        end
        -- Walk up one level to the parent directory.
        dir,child = path.toparent(dir)
        -- If child is empty, we've reached the top.
    until (not child or child == "")
    return nil
end

local function get_git_branch()
    -- Get the current git branch name.
    local file = io.popen("git branch --show-current 2>nul")
    local branch = file:read("*a"):match("(.+)\n")
    file:close()
    return branch
end

local function get_git_status()
    -- The io.popenyield API is like io.popen, but it yields until the output is
    -- ready to be read.
    local file = io.popenyield("git --no-optional-locks status --porcelain 2>nul")
    local status = false
    local lineLen = 0
    for line in file:lines() do
        -- If there's any output, the status is not clean.  Since this example
        -- doesn't analyze the details, it can stop once it knows there's any
        -- output at all.
        status = true
        break
    end
    file:close()
    return status
end

-- A prompt filter that discards any prompt so far and sets the
-- prompt to the current working directory.  An ANSI escape code
-- colors it brightYellow.
local my_prompt = clink.promptfilter(130)
function my_prompt:filter(prompt)

    dir = os.getcwd()
    console.settitle(dir)
    local lastReturnValue = os.getenv('ERRORLEVEL')

    if lastReturnValue ~= "0" then
        color = red
    else
        color = green
    end

    dirString = color.."<"..brightYellow..dir..color..">"
    dateString = color.."<"..brightYellow..os.date("%x")..color..">"
    userName = color.."<"..brightYellow..os.getenv('USERNAME')..color.." @ "..brightYellow..os.getenv('COMPUTERNAME')..color..">-<"
    branchStringOffset = 0

    -- Do nothing if not a git repo.
    local gitDir = get_git_dir(os.getcwd())
    if not gitDir then
        -- do nothing
        branchString = ""
        sgr = green
    else
        -- Reset the cached status if in a different repo.
        if prev_dir ~= gitDir then
            prev_status = nil
            prev_dir = gitDir
        end

        branch=get_git_branch()
        if not branch or branch == "" then
            branchString = ""
            sgr = green
        else
            -- Start a coroutine to get git status, and returns nil immediately.  The
            -- coroutine runs in the background, and triggers prompt filtering again
            -- when it completes.  After it completes the return value here will be the
            -- result from get_git_status().
            local status = clink.promptcoroutine(get_git_status)
            -- If no status yet, use the status from the previous prompt.
            if status == nil then
                status = prev_status
            else
                prev_status = status
            end

            -- Choose color for the git branch name:  green if status is clean, brightYellow
            -- if status is not clean, or default color if status isn't known yet.

            if status ~= nil then
                sgr = status and yellow or color
                if status == true then
                    branchString = red.."["..brightYellow..branch..red.."]"..color
                else
                    branchString = "["..brightYellow..branch..color.."]"
                end
            end
        end
    end

    --
    -- first line
    --
    promptFirstLine = color..">-"..dateString.."-"..dirString.."-"..branchString

    -- calculate visible characters of the first line of the prompt
    promptFirstLineLength = console.cellcount(promptFirstLine) + console.cellcount(userName)

    -- create fillstring depending on current stringlength
    fillString = ""
    for i=promptFirstLineLength, console.getwidth()-1, 1 do
        fillString = fillString.."-"
    end

    -- complete first line: date, dir, branch (optional), username
    promptFirstLine = "\n"..promptFirstLine..fillString..userName.."\n"

    --
    -- second line
    --
    promptSecondLine = color..">-<"..brightYellow..os.date(" %X ")..color..">-"..sgr.."|> "

    prompt = promptFirstLine..promptSecondLine

    return prompt
end

function my_prompt:rightfilter(prompt)

    local lastReturnValue = os.getenv('ERRORLEVEL')

    if lastReturnValue ~= "0" then
        color = red
    else
        color = green
    end

    return color.."-<"..brightYellow.."↳ "..lastReturnValue..color..">-<"
end

-- Workaround for Readline's assumption that there is no right-justified text.
local function show_hangover()
    if promptHangover then
        clink.print(promptHangover)
    end
end
clink.onbeginedit(show_hangover)

-- Alias funcion
local function alias(short, long)
    os.execute('doskey ' .. short .. '=' .. long)
end

-- create aliases
alias('l',     'dir /B /P')
alias('ll',    'dir /D /P')
alias('lll',   'dir')
alias('gitl',  'git log --graph --color --decorate=auto --oneline')
alias('gitll', 'git log --graph --color --decorate=auto --name-status --abbrev-commit')
alias('top',   'ntop -s CPU%')
alias('stop',  'sudo ntop -s CPU%')
alias('cat',   'bat $1')
alias('type',  'bat $1')
chrisant996 commented 3 years ago

v1.2.24 includes the %CLINK_RPROMPT% and :rightfilter() changes.

I can't update the Clink site yet, because a recent change inside GitHub Pages has broken how sites are updated. So until GitHub Pages gets fixed, the only way to access new versions of Clink is from the releases page. Hopefully the GitHub Pages outage won't be more than a few days.

bw1faeh0 commented 3 years ago

I'm always using the relases page.\ Until github gets the pages stuff fixed, you can fix a small issue with your docu. You are linking to https://github.com/collink/clink-git-extensions, which causes a 404 error ;)

chrisant996 commented 3 years ago

I'm always using the relases page. Until github gets the pages stuff fixed, you can fix a small issue with your docu. You are linking to https://github.com/collink/clink-git-extensions, which causes a 404 error ;)

Are you are looking at the v1.2.23 docs?

The v1.2.24 docs should not reference the collink repo anymore -- apparently that user deleted their repo (maybe even due to having been linked from the Clink docs, I don't know).