diff --git a/README.md b/README.md index 3e30908..e6a4b42 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ https://github.com/user-attachments/assets/7e2db305-58bc-4b3d-9c65-7dc0461aead7 - Bar: Waybar | **Quickshell** - Shell: **Fish** - Prompt: **Oh My Posh** -- Terminal: **Kitty** & Ghostty +- Terminal: **Kitty** & (**WezTerm** | Ghostty) - Power Menu: **Wlogout** - Colorscheme: **Catppuccin Mocha** - App Launcher: **Rofi** | Fuzzel diff --git a/config/scripts/.local/scripts/smb-umount b/config/scripts/.local/scripts/smb-umount index 8ff6ed7..40cb63d 100755 --- a/config/scripts/.local/scripts/smb-umount +++ b/config/scripts/.local/scripts/smb-umount @@ -23,6 +23,5 @@ for mp in $mount_points; do echo "Unmounting $mp" sudo umount "$mp" || { echo "Failed to unmount $mp" - exit 1 } done diff --git a/config/yazi/.config/yazi/package.toml b/config/yazi/.config/yazi/package.toml index adfcc07..f131578 100644 --- a/config/yazi/.config/yazi/package.toml +++ b/config/yazi/.config/yazi/package.toml @@ -1,11 +1,11 @@ [[plugin.deps]] use = "yazi-rs/plugins:git" -rev = "56971d0" -hash = "36a484acf6a0a0219c543ccb4cee218f" +rev = "88990a6" +hash = "270915fa8282a19908449530ff66f7e2" [[plugin.deps]] use = "yazi-rs/plugins:smart-enter" -rev = "56971d0" +rev = "88990a6" hash = "56fdabc96fc1f4d53c96eb884b02a5be" [[plugin.deps]] @@ -15,13 +15,13 @@ hash = "699fe07e0d2d1b4af8dafb84168eeb04" [[plugin.deps]] use = "KKV9/compress" -rev = "e6007f7" -hash = "e0b1051849756dd72fca874c320259a" +rev = "cb6e8ec" +hash = "424ada4807b20ccd4fc85d1d7c26d1e4" [[plugin.deps]] use = "llanosrocas/yaziline" -rev = "6266926" -hash = "9917ab5cb9bdbab7ca7f2501f84f0f11" +rev = "d9cc2cb" +hash = "b6073aadf2f9a1d5389a6d389f33f69c" [[plugin.deps]] use = "Rolv-Apneseth/starship" diff --git a/config/yazi/.config/yazi/plugins/compress.yazi/main.lua b/config/yazi/.config/yazi/plugins/compress.yazi/main.lua index 2b8a750..8733762 100644 --- a/config/yazi/.config/yazi/plugins/compress.yazi/main.lua +++ b/config/yazi/.config/yazi/plugins/compress.yazi/main.lua @@ -1,497 +1,568 @@ --- Check for windows -local is_windows = ya.target_family() == "windows" --- Define flags and strings -local is_password, is_encrypted, is_level, cmd_password, cmd_level, default_extension = false, false, false, "", "", "zip" - --- Function to check valid filename -local function is_valid_filename(name) - -- Trim whitespace from both ends - name = name:match("^%s*(.-)%s*$") - if name == "" then - return false - end - if is_windows then - -- Windows forbidden chars and reserved names - if name:find('[<>:"/\\|%?%*]') then - return false - end - else - -- Unix forbidden chars - if name:find("/") or name:find("%z") then - return false - end - end - return true -end - --- Function to send notifications -local function notify_error(message, urgency) - ya.notify( - { - title = "Archive", - content = message, - level = urgency, - timeout = 5 - } - ) -end - --- Function to check if command is available -local function is_command_available(cmd) - local stat_cmd - if is_windows then - stat_cmd = string.format("where %s > nul 2>&1", cmd) - else - stat_cmd = string.format("command -v %s >/dev/null 2>&1", cmd) - end - local cmd_exists = os.execute(stat_cmd) - if cmd_exists then - return true - else - return false - end -end - --- Function to change command arrays --> string -- Use first command available or first command -local function find_command_name(cmd_list) - for _, cmd in ipairs(cmd_list) do - if is_command_available(cmd) then - return cmd - end - end - return cmd_list[1] -- Return first command as fallback -end - --- Function to append filename to it's parent directory url -local function combine_url(path, file) - path, file = Url(path), Url(file) - return tostring(path:join(file)) -end - --- Function to make a table of selected or hovered files: path = filenames -local selected_or_hovered = - ya.sync( - function() - local tab, paths, names, path_fnames = cx.active, {}, {}, {} - for _, u in pairs(tab.selected) do - paths[#paths + 1] = tostring(u.parent) - names[#names + 1] = tostring(u.name) - end - if #paths == 0 and tab.current.hovered then - paths[1] = tostring(tab.current.hovered.url.parent) - names[1] = tostring(tab.current.hovered.name) - end - for idx, name in ipairs(names) do - if not path_fnames[paths[idx]] then - path_fnames[paths[idx]] = {} - end - table.insert(path_fnames[paths[idx]], name) - end - return path_fnames, names, tostring(tab.current.cwd) - end -) - --- Table of archive commands -local archive_commands = { - ["%.zip$"] = { - {command = "zip", args = {"-r"}, level_arg = "-", level_min = 0, level_max = 9, passwordable = true}, - { - command = {"7z", "7zz", "7za"}, - args = {"a", "-tzip"}, - level_arg = "-mx=", - level_min = 0, - level_max = 9, - passwordable = true - }, - { - command = {"tar", "bsdtar"}, - args = {"-caf"}, - level_arg = {"--option", "compression-level="}, - level_min = 1, - level_max = 9 - } - }, - ["%.7z$"] = { - { - command = {"7z", "7zz", "7za"}, - args = {"a"}, - level_arg = "-mx=", - level_min = 0, - level_max = 9, - header_arg = "-mhe=on", - passwordable = true - } - }, - ["%.rar$"] = { - { - command = "rar", - args = {"a"}, - level_arg = "-m", - level_min = 0, - level_max = 5, - header_arg = "-hp", - passwordable = true - } - }, - ["%.tar.gz$"] = { - {command = {"tar", "bsdtar"}, args = {"rpf"}, level_arg = "-", level_min = 1, level_max = 9, compress = "gzip"}, - { - command = {"tar", "bsdtar"}, - args = {"rpf"}, - level_arg = "-mx=", - level_min = 1, - level_max = 9, - compress = "7z", - compress_args = {"a", "-tgzip"} - }, - { - command = {"tar", "bsdtar"}, - args = {"-czf"}, - level_arg = {"--option", "gzip:compression-level="}, - level_min = 1, - level_max = 9 - } - }, - ["%.tar.xz$"] = { - {command = {"tar", "bsdtar"}, args = {"rpf"}, level_arg = "-", level_min = 1, level_max = 9, compress = "xz"}, - { - command = {"tar", "bsdtar"}, - args = {"rpf"}, - level_arg = "-mx=", - level_min = 1, - level_max = 9, - compress = "7z", - compress_args = {"a", "-txz"} - }, - { - command = {"tar", "bsdtar"}, - args = {"-cJf"}, - level_arg = {"--option", "xz:compression-level="}, - level_min = 1, - level_max = 9 - } - }, - ["%.tar.bz2$"] = { - {command = {"tar", "bsdtar"}, args = {"rpf"}, level_arg = "-", level_min = 1, level_max = 9, compress = "bzip2"}, - { - command = {"tar", "bsdtar"}, - args = {"rpf"}, - level_arg = "-mx=", - level_min = 1, - level_max = 9, - compress = "7z", - compress_args = {"a", "-tbzip2"} - }, - { - command = {"tar", "bsdtar"}, - args = {"-cjf"}, - level_arg = {"--option", "bzip2:compression-level="}, - level_min = 1, - level_max = 9 - } - }, - ["%.tar.zst$"] = { - { - command = {"tar", "bsdtar"}, - args = {"rpf"}, - level_arg = "-", - level_min = 1, - level_max = 22, - compress = "zstd", - compress_args = {"--ultra"} - } - }, - ["%.tar.lz4$"] = { - { - command = {"tar", "bsdtar"}, - args = {"rpf"}, - level_arg = "-", - level_min = 1, - level_max = 12, - compress = "lz4" - } - }, - ["%.tar.lha$"] = { - { - command = {"tar", "bsdtar"}, - args = {"rpf"}, - level_arg = "-o", - level_min = 5, - level_max = 7, - compress = "lha", - compress_args = {"-a"} - } - }, - ["%.tar$"] = { - {command = {"tar", "bsdtar"}, args = {"rpf"}} - } -} - -return { - entry = function(_, job) - -- Parse flags and default extension - if job.args ~= nil then - for _, arg in ipairs(job.args) do - if arg:match("^%-(%w+)$") then - -- Handle combined flags (e.g., -phl) - for flag in arg:sub(2):gmatch(".") do - if flag == "p" then - is_password = true - elseif flag == "h" then - is_encrypted = true - elseif flag == "l" then - is_level = true - end - end - elseif arg:match("^[%w%.]+$") then - -- Handle default extension (e.g., 7z, zip) - if archive_commands["%." .. arg .. "$"] then - default_extension = arg - else - notify_error(string.format("Unsupported extension: %s", arg), "warn") - end - else - notify_error(string.format("Unknown argument: %s", arg), "warn") - end - end - end - - -- Exit visual mode - ya.emit("escape", {visual = true}) - -- Define file table and output_dir (pwd) - local path_fnames, fnames, output_dir = selected_or_hovered() - -- Get archive filename - local output_name, event = - ya.input( - { - title = "Create archive:", - pos = {"top-center", y = 3, w = 40} - } - ) - if event ~= 1 then - return - end - - -- Determine the default name for the archive - local default_name = #fnames == 1 and fnames[1] or Url(output_dir).name - output_name = output_name == "" and string.format("%s.%s", default_name, default_extension) or output_name - - -- Add default extension if none is specified - if not output_name:match("%.%w+$") then - output_name = string.format("%s.%s", output_name, default_extension) - end - - -- Validate the final archive filename - if not is_valid_filename(output_name) then - notify_error("Invalid archive filename", "error") - return - end - - -- Match user input to archive command - local archive_cmd, - archive_args, - archive_compress, - archive_level_arg, - archive_level_min, - archive_level_max, - archive_header_arg, - archive_passwordable, - archive_compress_args - local matched_pattern = false - for pattern, cmd_list in pairs(archive_commands) do - if output_name:match(pattern) then - matched_pattern = true -- Mark that file extension is correct - for _, cmd in ipairs(cmd_list) do - -- Check if archive_cmd is available - local find_command = type(cmd.command) == "table" and find_command_name(cmd.command) or cmd.command - if is_command_available(find_command) then - -- Check if compress_cmd (if listed) is available - if cmd.compress == nil or is_command_available(cmd.compress) then - archive_cmd = find_command - archive_args = cmd.args - archive_compress = cmd.compress or "" - archive_level_arg = is_level and cmd.level_arg or "" - archive_level_min = cmd.level_min - archive_level_max = cmd.level_max - archive_header_arg = is_encrypted and cmd.header_arg or "" - archive_passwordable = cmd.passwordable or false - archive_compress_args = cmd.compress_args or {} - break - end - end - end - if archive_cmd then - break - end - end - end - - -- Check if no archive command is available for the extension - if not matched_pattern then - notify_error("Unsupported file extension", "error") - return - end - - -- Check if no suitable archive program was found - if not archive_cmd then - notify_error("Could not find a suitable archive program for the selected file extension", "error") - return - end - - -- Check if archive command has multiple names - if type(archive_cmd) == "table" then - archive_cmd = find_command_name(archive_cmd) - end - - -- Exit if archive command is not available - if not is_command_available(archive_cmd) then - notify_error(string.format("%s not available", archive_cmd), "error") - return - end - - -- Exit if compress command is not available - if archive_compress ~= "" and not is_command_available(archive_compress) then - notify_error(string.format("%s compression not available", archive_compress), "error") - return - end - - -- Add password arg if selected - if archive_passwordable and is_password then - local output_password, event = - ya.input( - { - title = "Enter password:", - obscure = true, - pos = {"top-center", y = 3, w = 40} - } - ) - if event ~= 1 then - return - end - if output_password ~= "" then - cmd_password = "-P" .. output_password - if archive_cmd == "rar" and is_encrypted then - cmd_password = archive_header_arg .. output_password -- Add archive arg for rar - end - table.insert(archive_args, cmd_password) - end - end - - -- Add header arg if selected for 7z - if is_encrypted and archive_header_arg ~= "" and archive_cmd ~= "rar" then - table.insert(archive_args, archive_header_arg) - end - - -- Add level arg if selected - if archive_level_arg ~= "" and is_level then - local output_level, event = - ya.input( - { - title = string.format("Enter compression level (%s - %s)", archive_level_min, archive_level_max), - pos = {"top-center", y = 3, w = 40} - } - ) - if event ~= 1 then - return - end - -- Validate user input for compression level - if - output_level ~= "" and tonumber(output_level) ~= nil and tonumber(output_level) >= archive_level_min and - tonumber(output_level) <= archive_level_max - then - cmd_level = - type(archive_level_arg) == "table" and archive_level_arg[#archive_level_arg] .. output_level or - archive_level_arg .. output_level - local target_args = archive_compress == "" and archive_args or archive_compress_args - if type(archive_level_arg) == "table" then - -- Insert each element of archive_level_arg (except last) into target_args at the correct position - for i = 1, #archive_level_arg - 1 do - table.insert(target_args, i, archive_level_arg[i]) - end - table.insert(target_args, #archive_level_arg, cmd_level) -- Add level at the end - else - -- Insert the compression level argument at the start if not a table - table.insert(target_args, 1, cmd_level) - end - else - notify_error("Invalid level specified. Using defaults.", "warn") - end - end - - -- Store the original output name for later use - local original_name = output_name - - -- If compression is needed, adjust the output name to exclude extensions like ".tar" - if archive_compress ~= "" then - output_name = output_name:match("(.*%.tar)") or output_name - end - - -- Create a temporary directory for intermediate files - local temp_dir_name = ".tmp_compress" - local temp_dir = combine_url(output_dir, temp_dir_name) - local temp_dir, _ = tostring(fs.unique_name(Url(temp_dir))) - - -- Attempt to create the temporary directory - local temp_dir_status, temp_dir_err = fs.create("dir_all", Url(temp_dir)) - if not temp_dir_status then - -- Notify the user if the temporary directory creation fails - notify_error(string.format("Failed to create temp directory, error code: %s", temp_dir_err), "error") - return - end - - -- Define the temporary output file path within the temporary directory - local temp_output_url = combine_url(temp_dir, output_name) - - -- Add files to the output archive - for filepath, filenames in pairs(path_fnames) do - -- Execute the archive command for each path and its respective files - local archive_status, archive_err = - Command(archive_cmd):arg(archive_args):arg(temp_output_url):arg(filenames):cwd(filepath):spawn():wait() - if not archive_status or not archive_status.success then - -- Notify the user if the archiving process fails and clean up the temporary directory - notify_error(string.format("Failed to create archive %s with '%s', error: %s", output_name, archive_cmd, archive_err), "error") - local cleanup_status, cleanup_err = fs.remove("dir_all", Url(temp_dir)) - if not cleanup_status then - notify_error(string.format("Failed to clean up temporary directory %s, error: %s", temp_dir, cleanup_err), "error") - end - return - end - end - - -- If compression is required, execute the compression command - if archive_compress ~= "" then - local compress_status, compress_err = - Command(archive_compress):arg(archive_compress_args):arg(temp_output_url):spawn():wait() - if not compress_status or not compress_status.success then - -- Notify the user if the compression process fails and clean up the temporary directory - notify_error(string.format("Failed to compress archive %s with '%s', error: %s", output_name, archive_compress, compress_err), "error") - local cleanup_status, cleanup_err = fs.remove("dir_all", Url(temp_dir)) - if not cleanup_status then - notify_error(string.format("Failed to clean up temporary directory %s, error: %s", temp_dir, cleanup_err), "error") - end - return - end - end - - -- Move the final file from the temporary directory to the output directory - local final_output_url, temp_url_processed = combine_url(output_dir, original_name), combine_url(temp_dir, original_name) - final_output_url, _ = tostring(fs.unique_name(Url(final_output_url))) - local move_status, move_err = fs.rename(Url(temp_url_processed), Url(final_output_url)) - if not move_status then - -- Notify the user if the move operation fails and clean up the temporary directory - notify_error(string.format("Failed to move %s to %s, error: %s", temp_url_processed, final_output_url, move_err), "error") - local cleanup_status, cleanup_err = fs.remove("dir_all", Url(temp_dir)) - if not cleanup_status then - notify_error(string.format("Failed to clean up temporary directory %s, error: %s", temp_dir, cleanup_err), "error") - end - return - end - - -- Cleanup the temporary directory after successful operation - local cleanup_status, cleanup_err = fs.remove("dir_all", Url(temp_dir)) - if not cleanup_status then - notify_error(string.format("Failed to clean up temporary directory %s, error: %s", temp_dir, cleanup_err), "error") - end - end -} - +--- @since 25.12.29 + +-- Check for windows +local is_windows = ya.target_family() == "windows" + +-- Define default flags and strings +local is_password, is_encrypted, is_level = false, false, false +local default_extension = "zip" + +-- Function to check valid filename +local function is_valid_filename(name) + -- Trim whitespace from both ends + name = name:match("^%s*(.-)%s*$") + if name == "" then + return false + end + if is_windows then + -- Windows forbidden chars and reserved names + if name:find('[<>:"/\\|%?%*]') then + return false + end + else + -- Unix forbidden chars + if name:find("/") or name:find("%z") then + return false + end + end + return true +end + +-- Function to send notifications +local function notify(message, level) + ya.notify({ + title = "Archive", + content = message, + level = level, + timeout = 5, + }) +end + +-- Function to check if command is available +local function is_command_available(cmd) + local stat_cmd + if is_windows then + stat_cmd = string.format("where %s > nul 2>&1", cmd) + else + stat_cmd = string.format("command -v %s >/dev/null 2>&1", cmd) + end + return os.execute(stat_cmd) +end + +-- Function to find first available command from list +local function find_command_name(cmd_list) + for _, cmd in ipairs(cmd_list) do + if is_command_available(cmd) then + return cmd + end + end + return cmd_list[1] -- Return first command as fallback +end + +-- Function to append filename to its parent directory url +local function combine_url(path, file) + local path_url = Url(path) + local file_url = Url(file) + return tostring(path_url:join(file_url)) +end + +-- Function to make a table of selected or hovered files: path = filenames +local selected_or_hovered = ya.sync(function() + local tab = cx.active + local paths = {} + local names = {} + local path_fnames = {} + + for _, u in pairs(tab.selected) do + paths[#paths + 1] = tostring(u.parent) + names[#names + 1] = tostring(u.name) + end + + if #paths == 0 and tab.current.hovered then + paths[1] = tostring(tab.current.hovered.url.parent) + names[1] = tostring(tab.current.hovered.name) + end + + for idx, name in ipairs(names) do + local path = paths[idx] + if not path_fnames[path] then + path_fnames[path] = {} + end + table.insert(path_fnames[path], name) + end + + return path_fnames, names, tostring(tab.current.cwd) +end) + +-- Function to cleanup temporary directory +local function cleanup_temp_dir(temp_dir) + local status, err = fs.remove("dir_all", Url(temp_dir)) + if not status then + notify( + string.format("Failed to clean up temporary directory %s, error: %s", ya.quote(temp_dir), tostring(err)), + "error" + ) + return false + end + return true +end + +-- Function for compression level +local function add_compression_level(target_args, level_arg, level_value) + if type(level_arg) == "table" then + -- Insert each element except last + for i = 1, #level_arg - 1 do + table.insert(target_args, i, level_arg[i]) + end + -- Add the level value with the last element + table.insert(target_args, #level_arg, level_arg[#level_arg] .. level_value) + else + -- Single string argument + table.insert(target_args, 1, level_arg .. level_value) + end +end + +-- Function for password handling +local function get_password_args(archive_cmd, encrypted, header_arg) + local output_password, event = ya.input({ + title = "Enter password:", + obscure = true, + pos = { "top-center", y = 3, w = 40 }, + }) + if event ~= 1 or output_password == "" then + return nil + end + -- Handling for RAR with encryption + if archive_cmd == "rar" and encrypted then + return { header_arg .. output_password } + end + return { "-P" .. output_password } +end + +-- Table of archive commands +local archive_commands = { + ["%.zip$"] = { + { command = "zip", args = { "-r" }, level_arg = "-", level_min = 0, level_max = 9, passwordable = true }, + { + command = { "7z", "7zz", "7za" }, + args = { "a", "-tzip" }, + level_arg = "-mx=", + level_min = 0, + level_max = 9, + passwordable = true, + }, + { + command = { "tar", "bsdtar" }, + args = { "-caf" }, + level_arg = { "--option", "compression-level=" }, + level_min = 1, + level_max = 9, + }, + }, + ["%.7z$"] = { + { + command = { "7z", "7zz", "7za" }, + args = { "a" }, + level_arg = "-mx=", + level_min = 0, + level_max = 9, + header_arg = "-mhe=on", + passwordable = true, + }, + }, + ["%.rar$"] = { + { + command = "rar", + args = { "a" }, + level_arg = "-m", + level_min = 0, + level_max = 5, + header_arg = "-hp", + passwordable = true, + }, + }, + ["%.tar%.gz$"] = { + { + command = { "tar", "bsdtar" }, + args = { "rpf" }, + level_arg = "-", + level_min = 1, + level_max = 9, + compress = "gzip", + }, + { + command = { "tar", "bsdtar" }, + args = { "rpf" }, + level_arg = "-mx=", + level_min = 1, + level_max = 9, + compress = "7z", + compress_args = { "a", "-tgzip" }, + }, + { + command = { "tar", "bsdtar" }, + args = { "-czf" }, + level_arg = { "--option", "gzip:compression-level=" }, + level_min = 1, + level_max = 9, + }, + }, + ["%.tar%.xz$"] = { + { + command = { "tar", "bsdtar" }, + args = { "rpf" }, + level_arg = "-", + level_min = 1, + level_max = 9, + compress = "xz", + }, + { + command = { "tar", "bsdtar" }, + args = { "rpf" }, + level_arg = "-mx=", + level_min = 1, + level_max = 9, + compress = "7z", + compress_args = { "a", "-txz" }, + }, + { + command = { "tar", "bsdtar" }, + args = { "-cJf" }, + level_arg = { "--option", "xz:compression-level=" }, + level_min = 1, + level_max = 9, + }, + }, + ["%.tar%.bz2$"] = { + { + command = { "tar", "bsdtar" }, + args = { "rpf" }, + level_arg = "-", + level_min = 1, + level_max = 9, + compress = "bzip2", + }, + { + command = { "tar", "bsdtar" }, + args = { "rpf" }, + level_arg = "-mx=", + level_min = 1, + level_max = 9, + compress = "7z", + compress_args = { "a", "-tbzip2" }, + }, + { + command = { "tar", "bsdtar" }, + args = { "-cjf" }, + level_arg = { "--option", "bzip2:compression-level=" }, + level_min = 1, + level_max = 9, + }, + }, + ["%.tar%.zst$"] = { + { + command = { "tar", "bsdtar" }, + args = { "rpf" }, + level_arg = "-", + level_min = 1, + level_max = 22, + compress = "zstd", + compress_args = { "--ultra" }, + }, + }, + ["%.tar%.lz4$"] = { + { + command = { "tar", "bsdtar" }, + args = { "rpf" }, + level_arg = "-", + level_min = 1, + level_max = 12, + compress = "lz4", + }, + }, + ["%.tar%.lha$"] = { + { + command = { "tar", "bsdtar" }, + args = { "rpf" }, + level_arg = "-o", + level_min = 5, + level_max = 7, + compress = "lha", + compress_args = { "-a" }, + }, + }, + ["%.tar$"] = { + { command = { "tar", "bsdtar" }, args = { "rpf" } }, + }, +} + +-- Function for command matching +local function find_archive_command(output_name) + for pattern, cmd_list in pairs(archive_commands) do + if output_name:match(pattern) then + for _, cmd in ipairs(cmd_list) do + -- Check if archive command is available + local cmd_name = type(cmd.command) == "table" and find_command_name(cmd.command) or cmd.command + if is_command_available(cmd_name) then + -- Check if compress command (if listed) is available + if not cmd.compress or is_command_available(cmd.compress) then + return { + cmd = cmd_name, + args = cmd.args, + compress = cmd.compress or "", + level_arg = cmd.level_arg or "", + level_min = cmd.level_min, + level_max = cmd.level_max, + header_arg = cmd.header_arg or "", + passwordable = cmd.passwordable or false, + compress_args = cmd.compress_args or {}, + } + end + end + end + -- Pattern matched but no suitable command found + return nil + end + end + -- No pattern matched - unsupported extension + return false +end + +return { + entry = function(_, job) + -- Parse flags and default extension + if job.args then + for _, arg in ipairs(job.args) do + if arg:match("^%-(%w+)$") then + -- Handle combined flags (e.g., -phl) + for flag in arg:sub(2):gmatch(".") do + if flag == "p" then + is_password = true + elseif flag == "h" then + is_encrypted = true + elseif flag == "l" then + is_level = true + end + end + elseif arg:match("^[%w%.]+$") then + -- Handle default extension (e.g., 7z, zip) + if archive_commands["%." .. arg .. "$"] then + default_extension = arg + else + notify(string.format("Unsupported extension: %s", arg), "warn") + end + else + notify(string.format("Unknown argument: %s", arg), "warn") + end + end + end + + -- Exit visual mode + ya.emit("escape", { visual = true }) + + -- Define file table and output_dir (pwd) + local path_fnames, fnames, output_dir = selected_or_hovered() + + -- Get archive filename + local output_name, event = ya.input({ + title = "Create archive:", + pos = { "top-center", y = 3, w = 40 }, + }) + if event ~= 1 then + return + end + + -- Determine the default name for the archive + local default_name = #fnames == 1 and fnames[1] or Url(output_dir).name + output_name = output_name == "" and string.format("%s.%s", default_name, default_extension) or output_name + + -- Add default extension if none is specified + if not output_name:match("%.%w+$") then + output_name = string.format("%s.%s", output_name, default_extension) + end + + -- Validate the final archive filename + if not is_valid_filename(output_name) then + notify("Invalid archive filename", "error") + return + end + + -- Command matching + local archive_config = find_archive_command(output_name) + if archive_config == false then + notify("Unsupported file extension", "error") + return + elseif not archive_config then + notify("Could not find a suitable archive program for the selected file extension", "error") + return + end + + -- Extract configuration + local archive_cmd = archive_config.cmd + local archive_args = archive_config.args + local archive_compress = archive_config.compress + local archive_level_arg = is_level and archive_config.level_arg or "" + local archive_level_min = archive_config.level_min + local archive_level_max = archive_config.level_max + local archive_header_arg = is_encrypted and archive_config.header_arg or "" + local archive_passwordable = archive_config.passwordable + local archive_compress_args = archive_config.compress_args + + -- Password handling + if archive_passwordable and is_password then + local password_args = get_password_args(archive_cmd, is_encrypted, archive_header_arg) + if password_args then + for _, arg in ipairs(password_args) do + table.insert(archive_args, arg) + end + end + end + + -- Add header arg if selected for 7z + if is_encrypted and archive_header_arg ~= "" and archive_cmd ~= "rar" then + table.insert(archive_args, archive_header_arg) + end + + -- Use extracted compression level + if archive_level_arg ~= "" and is_level then + local output_level, level_event = ya.input({ + title = string.format("Enter compression level (%s - %s)", archive_level_min, archive_level_max), + pos = { "top-center", y = 3, w = 40 }, + }) + if level_event ~= 1 then + return + end + -- Validate user input for compression level + local level_num = tonumber(output_level) + if level_num and level_num >= archive_level_min and level_num <= archive_level_max then + local target_args = archive_compress == "" and archive_args or archive_compress_args + add_compression_level(target_args, archive_level_arg, output_level) + else + notify("Invalid level specified. Using defaults.", "warn") + end + end + + -- Store the original output name for later use + local original_name = output_name + + -- If compression is needed, adjust the output name to exclude extensions like ".tar" + if archive_compress ~= "" then + output_name = output_name:match("(.*%.tar)") or output_name + end + + -- Create a temporary directory for intermediate files + local temp_dir_name = ".tmp_compress" + local temp_dir = combine_url(output_dir, temp_dir_name) + temp_dir = tostring(fs.unique_name(Url(temp_dir))) + + -- Attempt to create the temporary directory + local temp_dir_status, temp_dir_err = fs.create("dir_all", Url(temp_dir)) + if not temp_dir_status then + -- Notify the user if the temporary directory creation fails + notify(string.format("Failed to create temp directory, error code: %s", tostring(temp_dir_err)), "error") + return + end + + -- Define the temporary output file path within the temporary directory + local temp_output_url = combine_url(temp_dir, output_name) + + -- Add files to the output archive + for filepath, filenames in pairs(path_fnames) do + -- Execute the archive command for each path and its respective files + local archive_status, archive_err = + Command(archive_cmd):arg(archive_args):arg(temp_output_url):arg(filenames):cwd(filepath):spawn():wait() + if not archive_status or not archive_status.success then + -- Notify the user if the archiving process fails and clean up the temporary directory + notify( + string.format( + "Failed to create archive %s with '%s', error: %s", + ya.quote(output_name), + archive_cmd, + tostring(archive_err) + ), + "error" + ) + cleanup_temp_dir(temp_dir) + return + end + end + + -- If compression is required, execute the compression command + if archive_compress ~= "" then + local compress_status, compress_err + + -- Check if using 7z for compression (requires output file argument) + if archive_compress:match("^7z") then + local compressed_output = combine_url(temp_dir, original_name) + compress_status, compress_err = Command(archive_compress) + :arg(archive_compress_args) + :arg(compressed_output) + :arg(temp_output_url) + :spawn() + :wait() + else + -- Native compression tools (gzip, xz, bzip2, etc.) compress in-place + compress_status, compress_err = + Command(archive_compress):arg(archive_compress_args):arg(temp_output_url):spawn():wait() + end + + if not compress_status or not compress_status.success then + -- Notify the user if the compression process fails and clean up the temporary directory + notify( + string.format( + "Failed to compress archive %s with '%s', error: %s", + ya.quote(output_name), + archive_compress, + tostring(compress_err) + ), + "error" + ) + cleanup_temp_dir(temp_dir) + return + end + end + + -- Move the final file from the temporary directory to the output directory + local final_output_url = combine_url(output_dir, original_name) + local temp_url_processed = combine_url(temp_dir, original_name) + final_output_url = tostring(fs.unique_name(Url(final_output_url))) + local from, to = Url(temp_url_processed), Url(final_output_url) + local move_status, move_err = fs.rename(from, to) + if not move_status then + if move_err and move_err.kind == "CrossesDevices" then + local copy_status, copy_err = fs.copy(from, to) + if not copy_status then + notify( + string.format( + "Failed to copy across devices %s to %s, error: %s", + ya.quote(from.name), + ya.quote(to.name), + copy_err and tostring(copy_err.kind) or "unknown" + ), + "error" + ) + cleanup_temp_dir(temp_dir) + return + end + else + notify( + string.format( + "Failed to move %s to %s, error: %s", + ya.quote(from.name), + ya.quote(to.name), + move_err and tostring(move_err.kind) or "unknown" + ), + "error" + ) + cleanup_temp_dir(temp_dir) + return + end + end + + -- Cleanup the temporary directory after successful operation + cleanup_temp_dir(temp_dir) + + -- Notify user of success + notify(string.format("Successfully created archive: %s", ya.quote(to.name)), "info") + end, +} diff --git a/config/yazi/.config/yazi/plugins/git.yazi/README.md b/config/yazi/.config/yazi/plugins/git.yazi/README.md index 23b3bbb..c014b38 100644 --- a/config/yazi/.config/yazi/plugins/git.yazi/README.md +++ b/config/yazi/.config/yazi/plugins/git.yazi/README.md @@ -15,7 +15,10 @@ ya pkg add yazi-rs/plugins:git Add the following to your `~/.config/yazi/init.lua`: ```lua -require("git"):setup() +require("git"):setup { + -- Order of status signs showing in the linemode + order = 1500, +} ``` And register it as fetchers in your `~/.config/yazi/yazi.toml`: @@ -39,12 +42,14 @@ run = "git" You can customize the [Style](https://yazi-rs.github.io/docs/plugins/layout#style) of the status sign with: -- `th.git.modified` -- `th.git.added` -- `th.git.untracked` -- `th.git.ignored` -- `th.git.deleted` -- `th.git.updated` +- `th.git.unknown` - status cannot/not yet determined +- `th.git.modified` - modified file +- `th.git.added` - added file +- `th.git.untracked` - untracked file +- `th.git.ignored` - ignored file +- `th.git.deleted` - deleted file +- `th.git.updated` - updated file +- `th.git.clean` - clean file For example: @@ -57,20 +62,24 @@ th.git.deleted = ui.Style():fg("red"):bold() You can also customize the text of the status sign with: -- `th.git.modified_sign` -- `th.git.added_sign` -- `th.git.untracked_sign` -- `th.git.ignored_sign` -- `th.git.deleted_sign` -- `th.git.updated_sign` +- `th.git.unknown_sign` - status cannot/not yet determined +- `th.git.modified_sign` - modified file +- `th.git.added_sign` - added file +- `th.git.untracked_sign` - untracked file +- `th.git.ignored_sign` - ignored file +- `th.git.deleted_sign` - deleted file +- `th.git.updated_sign` - updated file +- `th.git.clean_sign` - clean file For example: ```lua -- ~/.config/yazi/init.lua th.git = th.git or {} +th.git.unknown_sign = " " th.git.modified_sign = "M" th.git.deleted_sign = "D" +th.git.clean_sign = "✔" ``` ## License diff --git a/config/yazi/.config/yazi/plugins/git.yazi/main.lua b/config/yazi/.config/yazi/plugins/git.yazi/main.lua index cdc1532..993be7e 100644 --- a/config/yazi/.config/yazi/plugins/git.yazi/main.lua +++ b/config/yazi/.config/yazi/plugins/git.yazi/main.lua @@ -7,14 +7,15 @@ local WINDOWS = ya.target_family() == "windows" -- see `bubble_up` ---@enum CODES local CODES = { - excluded = 100, -- ignored directory + unknown = 100, -- status cannot/not yet determined + excluded = 99, -- ignored directory ignored = 6, -- ignored file untracked = 5, modified = 4, added = 3, deleted = 2, updated = 1, - unknown = 0, + clean = 0, } local PATTERNS = { @@ -79,7 +80,7 @@ local function bubble_up(changed) local url = Url(path).parent while url and url ~= empty do local s = tostring(url) - new[s] = (new[s] or CODES.unknown) > code and new[s] or code + new[s] = (new[s] or CODES.clean) > code and new[s] or code url = url.parent end end @@ -116,7 +117,7 @@ local add = ya.sync(function(st, cwd, repo, changed) st.dirs[cwd] = repo st.repos[repo] = st.repos[repo] or {} for path, code in pairs(changed) do - if code == CODES.unknown then + if code == CODES.clean then st.repos[repo][path] = nil elseif code == CODES.excluded then -- Mark the directory with a special value `excluded` so that it can be distinguished during UI rendering @@ -162,20 +163,24 @@ local function setup(st, opts) local t = th.git or {} local styles = { - [CODES.ignored] = t.ignored and ui.Style(t.ignored) or ui.Style():fg("darkgray"), - [CODES.untracked] = t.untracked and ui.Style(t.untracked) or ui.Style():fg("magenta"), - [CODES.modified] = t.modified and ui.Style(t.modified) or ui.Style():fg("yellow"), - [CODES.added] = t.added and ui.Style(t.added) or ui.Style():fg("green"), - [CODES.deleted] = t.deleted and ui.Style(t.deleted) or ui.Style():fg("red"), - [CODES.updated] = t.updated and ui.Style(t.updated) or ui.Style():fg("yellow"), + [CODES.unknown] = t.unknown or ui.Style(), + [CODES.ignored] = t.ignored or ui.Style():fg("darkgray"), + [CODES.untracked] = t.untracked or ui.Style():fg("magenta"), + [CODES.modified] = t.modified or ui.Style():fg("yellow"), + [CODES.added] = t.added or ui.Style():fg("green"), + [CODES.deleted] = t.deleted or ui.Style():fg("red"), + [CODES.updated] = t.updated or ui.Style():fg("yellow"), + [CODES.clean] = t.clean or ui.Style(), } local signs = { + [CODES.unknown] = t.unknown_sign or "", [CODES.ignored] = t.ignored_sign or " ", [CODES.untracked] = t.untracked_sign or "? ", [CODES.modified] = t.modified_sign or " ", [CODES.added] = t.added_sign or " ", [CODES.deleted] = t.deleted_sign or " ", [CODES.updated] = t.updated_sign or " ", + [CODES.clean] = t.clean_sign or "", } Linemode:children_add(function(self) @@ -185,12 +190,12 @@ local function setup(st, opts) local url = self._file.url local repo = st.dirs[tostring(url.base or url.parent)] - local code + local code = CODES.unknown if repo then - code = repo == CODES.excluded and CODES.ignored or st.repos[repo][tostring(url):sub(#repo + 2)] + code = repo == CODES.excluded and CODES.ignored or st.repos[repo][tostring(url):sub(#repo + 2)] or CODES.clean end - if not code or signs[code] == "" then + if signs[code] == "" then return "" elseif self._file.is_hovered then return ui.Line { " ", signs[code] } @@ -240,11 +245,11 @@ local function fetch(_, job) end ya.dict_merge(changed, propagate_down(excluded, cwd, Url(repo))) - -- Reset the status of any files that don't appear in the output of `git status` to `unknown`, + -- Reset the status of any files that don't appear in the output of `git status` to `clean`, -- so that cleaning up outdated statuses from `st.repos` for _, path in ipairs(paths) do local s = path:sub(#repo + 2) - changed[s] = changed[s] or CODES.unknown + changed[s] = changed[s] or CODES.clean end add(tostring(cwd), repo, changed) diff --git a/config/yazi/.config/yazi/plugins/yaziline.yazi/README.md b/config/yazi/.config/yazi/plugins/yaziline.yazi/README.md index 5291f00..2cb7b8e 100644 --- a/config/yazi/.config/yazi/plugins/yaziline.yazi/README.md +++ b/config/yazi/.config/yazi/plugins/yaziline.yazi/README.md @@ -9,7 +9,7 @@ All supported features are listed [here](#features). More presets are available ## Requirements -- yazi version >= [917e1f5](https://github.com/sxyazi/yazi/commit/917e1f54a10445f2e25147c4b81a3c77d8233632) +- yazi version >= [26.1.22](https://github.com/sxyazi/yazi/releases/tag/v26.1.22). - Font with symbol support. For example [Nerd Fonts](https://www.nerdfonts.com/). ## Compatibility @@ -24,7 +24,7 @@ This setup allows shipping stable versions on time, while giving early access to | yaziline | yazi | | :------------------------------------------------------------------------: | ----------------------------------------------------------------------------------------- | -| [v2.5.2](https://github.com/llanosrocas/yaziline.yazi/releases/tag/v2.5.2) | [v25.12.29](https://github.com/sxyazi/yazi/releases/tag/v25.12.29) | +| [v2.5.2](https://github.com/llanosrocas/yaziline.yazi/releases/tag/v2.5.2) | [v26.1.22](https://github.com/sxyazi/yazi/releases/tag/v26.1.22) | | [v2.5.2](https://github.com/llanosrocas/yaziline.yazi/releases/tag/v2.5.2) | [2f66561](https://github.com/sxyazi/yazi/commit/2f66561a8251f8788b2b0fd366af90555ecafc86) | | [v2.5.2](https://github.com/llanosrocas/yaziline.yazi/releases/tag/v2.5.2) | [6cfa92f](https://github.com/sxyazi/yazi/commit/6cfa92f11205d212155579b5b76d4cbabe723829) | | [v2.5.2](https://github.com/llanosrocas/yaziline.yazi/releases/tag/v2.5.2) | [917e1f5](https://github.com/sxyazi/yazi/commit/917e1f54a10445f2e25147c4b81a3c77d8233632) | diff --git a/memo/mail-service.md b/memo/mail-service.md index bbda481..932fea8 100644 --- a/memo/mail-service.md +++ b/memo/mail-service.md @@ -2,7 +2,7 @@ > [!WARNING] > -> 仅记录我的折腾过程, 并非指南, 并非推荐, 并非技术文档. +> 仅记录我的折腾过程, 可能有错误和遗漏, 请勿当作严谨的教程或文档. ## 目录 diff --git a/memo/shell.md b/memo/shell.md deleted file mode 100644 index 2bb6805..0000000 --- a/memo/shell.md +++ /dev/null @@ -1,32 +0,0 @@ -## 登陆 shell - -登陆 shell 是指用户通过终端登录系统时启动的 shell,通常是用户登录时执行的第一个 shell,可以通过`grep "^$(whoami):" /etc/passwd`查看。 - -登陆 shell 为 bash 时,在登陆时会检索 - -- `/etc/profile` - -并加载,并会加载以下第一个存在的用户配置文件: - -- `~/.bash_profile` -- `~/.bash_login` -- `~/.profile` - -所有全局环境变量以及其他非交互配置(如 ssh-agent)都可以写进这些文件。 - -## 非登陆 shell - -非登陆 shell 是指用户在已经登录的情况下启动的 shell,通常是通过终端仿真器或其他方式打开的 shell。 - -非登陆 shell 为 bash 时,会先加载`/etc/bash.bashrc`,然后加载用户的`~/.bashrc`文件。 - -对于非登陆 shell 为 fish 的情况,则会先加载`/etc/fish/conf.d`以及`/etc/fish/config.fish`, -然后加载用户的`~/.config/fish/conf.d`以及`~/.config/fish/config.fish`。 - -非登陆 shell 会继承登陆 shell 的环境变量,但不会加载登陆 shell 的配置文件。 - -## 当前做法 - -桌面端将登陆 shell 设置为 bash,对于终端模拟器显式指定 shell 为 fish,并禁用 conf.d 目录下的配置文件。 - -服务器端同样将登陆 shell 设置为 bash,并在.bashrc 中启动 fish,同样不使用 conf.d 目录下的配置文件。 diff --git a/memo/terminals.md b/memo/terminals.md new file mode 100644 index 0000000..15e02ae --- /dev/null +++ b/memo/terminals.md @@ -0,0 +1,435 @@ +一些关于终端模拟器(Terminal Emulator)的话题, 持续补充中... + +> 我日常 99% 的时间都在 Wayland (剩下 0.9% 在 Windows, 0.1% 对着 TTY 发呆), 所以本篇内容**完全不会**考虑 X11 环境. + +## Index + +- [Index](#index) +- [基本原理](#基本原理) + - [TTY / PTY](#tty--pty) + - [Shell](#shell) + - [终端模拟器](#终端模拟器) + - [控制序列](#控制序列) +- [图像协议](#图像协议) + - [各终端支持情况](#各终端支持情况) + - [使用方法](#使用方法) + - [检测方法](#检测方法) + - [基本模式](#基本模式) + - [KGP](#kgp) + - [Sixel](#sixel) + - [ITerm](#iterm) + - [3 in 1](#3-in-1) + - [快速检测](#快速检测) + - [显示效果](#显示效果) + - [性能测试](#性能测试) +- [默认 Shell](#默认-shell) + - [一些概念](#一些概念) + - [最佳实践](#最佳实践) +- [GPU 加速](#gpu-加速) +- [一些特殊者](#一些特殊者) + - [Ghostty](#ghostty) + - [Kmscon](#kmscon) +- [References](#references) + +## 基本原理 + +### TTY / PTY + +> TTY - Teletypewriter, PTY - Pseudo Terminal + +TTY 本质为内核中的双向通信管道与数据处理层. 现代 Linux 系统中, 物理 TTY 几乎完全被 PTY 取代. PTY 是一对虚拟的字符设备, 分为 **Master** 和 **Slave**. + +- **Slave** 模拟了传统的硬件串口行为. Shell 和其他命令行程序主要与这一端交互. + +- **Master** 则由终端模拟器使用, 负责将用户的输入传递给 Slave 端, 并将 Slave 端的输出渲染到屏幕上. + +- **Line Discipline** 是介于 Master 和 Slave 之间的一个中间层, 负责处理"行编辑"逻辑. + - **Canonical Mode**: 这是默认模式, Line Discipline 会缓存用户输入的字符, 直到检测到换行符(Enter)或 EOF(通常为 Ctrl+D)时才将整行输入发送给 Slave 端. 在此模式下, Line Discipline 还会处理一些特殊字符, 如退格符(Backspace)用于删除前一个字符, Ctrl+U 用于删除整行等. + + - **Raw Mode**: 在此模式下, Line Discipline 不会对输入进行任何处理, 用户输入的每个字符都会立即传递给 Slave 端. 这对于需要实时响应用户输入的应用程序(如文本编辑器和终端复用器)非常重要. + + - **Signal Handling**: Line Discipline 还负责处理一些控制字符, 如 Ctrl+C 用于发送中断信号(SIGINT)给前台进程, Ctrl+Z 用于发送挂起信号(SIGTSTP)等. + +### Shell + +Shell 是运行在 TTY Slave 端的命令行解释器, 负责: + +- 解析用户输入的命令; + +- 通过 `fork()` 和 `exec()` 等系统调用来启动子进程或执行内置命令; + +- 将子进程的输出通过 TTY Slave 端发送回终端模拟器的 Master 端进行显示; + +- 管理前台和后台进程组, 处理信号传递等. + +值得注意的是, Shell 中输入命令后的"回显"并不是 Shell 自己完成的, 而必须通过 TTY 的 Line Discipline. 当用户输入字符时, Line Discipline 会将其显示在屏幕上, 从未完成"回显". + +### 终端模拟器 + +终端模拟器是负责转换 I/O 数据流与渲染显示的 GUI 应用程序. 它通过 PTY Master 端与 Shell 及其他命令行程序通信. + +- **输入**: 捕获键盘事件, 转换为字节流写入 PTY Master 文件描述符; + +- **输出**: 从 PTY Master 读取字节流, 解析控制序列; + +- **渲染**: 根据解析结果更新屏幕显示, 包括文本内容, 光标位置, 颜色等. + +### 控制序列 + +控制序列是一种特殊的字节序列, 基于不同协议, 用于在终端模拟器中实现各种功能, 如: + +- `CSI` (Control Sequence Introducer): 以 `\033[` 开头 + - 光标移动: `\033[;H` 或 `\033[;f` + - 清屏: `\033[2J` + - 颜色设置: `\033[38;2;;;m` (前景色), `\033[48;2;;;m` (背景色) + +- `OSC` (Operating System Command): 以 `\033]` 开头 + - 设置窗口标题: `\033]0;title\a` + - 设置剪贴板内容: `\033]52;c;data\a` + +下一节将会提到的各类图像协议也是通过控制序列实现的. + +## 图像协议 + +即在终端模拟器里显示图片的~~旁门左道~~各类协议, 其中使用较为广泛的有三个: + +- **KGP**(非官方简称): [Kitty Terminal Graphics Protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/) +- **Sixel**: [Sixel](https://en.wikipedia.org/wiki/Sixel) +- **ITerm**: [ITerm2 Inline Images Protocol](https://iterm2.com/documentation-images.html) + +值得一提的是其中 KGP 甚至能被用来在终端模拟器里播放视频, 只需要给 mpv 加上 `--vo=kitty` 参数即可. + +### 各终端支持情况 + +不完全统计, 只列举我知道和确实使用过的. + +| Terminal | KGP | Sixel | ITerm | +| ------------- | --- | ----- | ----- | +| Alacritty | ❌ | ❌ | ❌ | +| Foot | ❌ | ✅ | ❌ | +| Ghostty | ✅ | ❌ | ❌ | +| GNOME Console | ❌ | ❌ | ❌ | +| Kitty | ✅ | ❌ | ❌ | +| Konsole | ✅ | ✅ | ✅ | +| Rio | ❌ | ✅ | ❌ | +| Tabby | ❌ | ✅ | ❌ | +| Warp | ✅ | ❌ | ❌ | +| WezTerm | ✅ | ✅ | ✅ | + +### 使用方法 + +- 部分终端模拟器提供了显示图片的小程序/内置功能, 可以通过参数调用, 例如: + + ```bash + kitty +kitten icat /path/to/image + ``` + + 将会使用 KGP 显示图片, + + ```bash + wezterm imgcat /path/to/image + ``` + + 将会使用 ITerm 图形协议形式图片. 以上两个指令在其他支持相同协议的终端模拟器同样可用. + +- 另一个很好用的通用程序是 [chafa](https://github.com/hpjansson/chafa). 除了自动检测并使用以上三种协议显示图片外, 它还支持以 `symbols` 格式使用 [ANSI 颜色转义序列](https://en.wikipedia.org/wiki/ANSI_escape_code)显示图片的大致样貌, 这在不支持以上任何一种协议的终端模拟器(如 Alacritty)上很好用. + +### 检测方法 + +最简单直接的检测方法当然是真的找一张图片用各种协议都试一遭. 但有些时候可能会需要快速 / 轻量 / 自动的检测手段, 例如在一个需要显示图片的 TUI / CLI 程序里. 此时各种控制序列就可以派上用场了. + +> [!NOTE] +> +> 下文中所有控制序列及其响应均采用反斜杠转义表示方法, 例如 `\033` 表示 ESC, `\\` 表示单个反斜杠. + +#### 基本模式 + +正如[基本原理](#基本原理)一节所说, 在 Linux 的 TTY 架构设计中, 终端模拟器和内核之间只有一条双向通信管道. 当内核向终端发送查询序列后, 终端模拟器的响应会通过这唯一一条管道发送给内核, 而用户输入的字符也是通过这条管道发送给内核的, 因此内核会将终端模拟器的响应像用户的输入一样放在输入队列中. 具体表现为终端模拟器的响应出现在输入缓冲区内. + +为了读取这些响应, 脚本需要通过 `stty raw -echo` 开启 Raw 模式, 关闭回显, 然后通过 `read` 等命令逐字符读取. 这适用于本节将会涉及的所有控制序列. + +#### KGP + +KGP 提供了[标准化的检测方法](https://sw.kovidgoyal.net/kitty/graphics-protocol/#querying-support-and-available-transmission-mediums). 一种简单通用的实践为发送 `\033_Gi=,s=1,v=1,a=q,t=d,f=24;AAAA\033\\\033[c` 序列, 它由两部分组成: + +- `\033_Gi=,s=1,v=1,a=q,t=d,f=24;AAAA\033\\` + + 这是 KGP 规定的用于查询的控制序列, `i=` 用于指定查询操作的编号, 范围 `1` 到 `4294967295`, 可以设为随机数. `a=q` 表明该操作为查询. 如果终端支持 KGP, 则会响应: + + `\033_Gi=;OK\033\\` + + 反之如果无响应或响应错误, 则可认为不支持. + +- `\033[c` + + 这是大多数终端都会响应的 `DA1` 序列, 用于查询终端特性, 标准响应正则为 `\033\[\?[0-9;]*c`, 但是此处只用于标识 Kitty 图像协议查询序列响应的结束, 其本身具体响应了什么并不重要, 这得益于 KGP "在收到查询序列后必须立即相应, 不能先处理其他输入"的规定. 例如, 如果一次查询返回了 `DA1` 的响应而没有有效的 KGP 的相应, 则可视为该终端模拟器不支持 KGP. + +#### Sixel + +Sixel 支持情况可以用 `DA1` 查询, 即 `\033[c`. 如响应中分号分隔的数字中包含 `4`, 则可视为支持. + +值得注意的一点是, 很多终端复用器(Terminal Multiplexer)如 Tmux / Zellij 也会在 `DA1` 的响应中包含 `4`, 但实际支持情况取决于终端复用器的配置与宿主终端. + +#### ITerm + +ITerm2 图片协议本质为 `OSC 1337` 控制序列的 `FILE` 特性. ITerm2 文档虽然提供了[检测方法](https://iterm2.com/feature-reporting/), 但一番测试后发现在我认知范围内的支持 ITerm2 图片协议的终端上均无法得到有效相应. + +但还有其他方法可以实现查询目的. 虽然无法直接查询 `FILE` 特性的支持情况, 但可以通过执行 `OSC 1337` 控制序列中其他副作用和开销较小的查询来间接获知是否支持该控制序列, 进而获知是否支持通过该协议显示图片. 一种常见的方法是查询 `ReportCellSize`, 具体控制序列为 `\033]1337;ReportCellSize\a`. 如果返回了以 `\033]1337;ReportCellSize=` 为前缀的响应, 则可视为通过. 虽然看起来并不怎么健壮, 但根据我自己的测试结果误判可能性很小, 已经足够使用了. + +同样的, 上述查询也推荐使用 `DA1` 做哨兵. 由此, 完整的控制序列为: `\033]1337;ReportCellSize\a\033[c`. + +#### 3 in 1 + +不难发现, 用于查询 KGP 和 `OSC 1337` 的控制序列都可以用 `DA1` 做哨兵, 而 `DA1` 本身可以用来查询 Sixel 协议的支持情况. 因此, 三次查询可以被整合到单个控制序列中, 由此得以实现三合一检测脚本: + +```bash +#!/usr/bin/env bash + +set -euo pipefail + +# Ensure in a interactive terminal +[ ! -t 0 ] && exit 0 + +# Construct query +KGP_QUERY_ID=$RANDOM +KGP_QUERY_CODE=$(printf "\033_Gi=%d,s=1,v=1,a=q,t=d,f=24;AAAA\033\\" "$KGP_QUERY_ID") +ITERM2_QUERY_CODE=$(printf "\033]1337;ReportCellSize\a") +KGP_EXPECTED_RESPONSE=$(printf "\033_Gi=%d;OK\033\\" "$KGP_QUERY_ID") +ITERM2_EXPECTED_RESPONSE=$(printf "\033]1337;") # followed by "ReportCellSize=...", but only the prefix is enough +FENCE_CODE=$(printf "\033[c") + +# Set terminal to raw mode with timeout +stty_orig=$(stty -g) +trap 'stty "$stty_orig"' EXIT +stty -echo -icanon min 1 time 0 + +printf "%s%s%s" "$ITERM2_QUERY_CODE" "$KGP_QUERY_CODE" "$FENCE_CODE" > /dev/tty + +support_kgp=0 +support_iterm2=0 +support_sixel=0 + +response="" +while true; do + IFS= read -r -N 1 -t 0.3 char || { + [ -z "$char" ] && break + } + + response+="$char" + + if [[ "$response" == *"$KGP_EXPECTED_RESPONSE"* ]]; then + support_kgp=1 + fi + + if [[ "$response" == *"$ITERM2_EXPECTED_RESPONSE"* ]]; then + support_iterm2=1 + fi + + if [[ "$response" == *$'\033['*'c' ]]; then + break + fi + + if [ ${#response} -gt 1024 ]; then + break + fi +done + +if [[ "$response" =~ $'\x1b'\[\?([0-9;]*)c ]]; then + params="${BASH_REMATCH[1]}" + + IFS=';' read -ra codes <<< "$params" + + for code in "${codes[@]}"; do + if [[ "$code" == "4" ]]; then + support_sixel=1 + break + fi + done +fi + +if [ "$support_kgp" -eq 1 ]; then + echo "kitty" +fi + +if [ "$support_iterm2" -eq 1 ]; then + echo "iterm" +fi + +if [ "$support_sixel" -eq 1 ]; then + echo "sixels" +fi +``` + +对于支持的协议, 这个脚本会输出 `kitty` / `iterm` / `sixels` (命名方式来自 chafa 的 format 参数), 每个一行. + +#### 快速检测 + +如果不想写脚本, 也可以直接在 shell 里执行以下命令: + +```bash +bash <(curl -fsSL https://tgp.uyani.de/query) +``` + +这将会根据上述原理检测当前终端模拟器支持的图像协议, 并输出结果. 当然, 这需要联网并且信任该脚本的来源. 请务必先拉取并查看脚本内容以确认无害后再执行. + +将网址中的 `query` 替换为 `kitty` / `iterm` / `sixels` 可以通过显示测试图片的方式验证对应协议的支持情况, 例如: + +```bash +bash <(curl -fsSL https://tgp.uyani.de/kitty) +``` + +将会尝试用 KGP 显示一张测试图片, 如果显示成功则说明支持 KGP, 反之则不支持. + +> [!TIP] +> +> 对于 fish shell, 类似 `cmdA <(cmdB)` 的语法可被替换为 `cmdA (cmdB | psub)`, 因此上述命令在 fish 里可以写为: +> +> ```fish +> bash (curl -fsSL https://tgp.uyani.de/query | psub) +> ``` + +### 显示效果 + +先说结论, 在大多数终端模拟器上, KGP ≈ ITerm >> Sixel. + +Sixel 是三者之中最老的, 颜色格式类似 GIF89a, 只支持索引颜色和单色键透明度, 画面有明显的颗粒感和色带. 如此妥协换来的是三者之中最强的兼容性, 甚至连 Windows Terminal 都支持 Sixel, 可见一斑. + +ITerm 的实现方式很简单粗暴, 它会将图片数据原封不动地交给终端模拟器渲染, 因此实际显示效果极大程度上取决于终端模拟器的实现方式. 不过, 由于终端模拟器能拿到原始的图像二进制数据, 显示效果一般不会比其他二者差. + +KGP 既支持直接传输 PNG 二进制数据, 也支持传输 24bit 与 32bit 像素数据, 支持指定 Z-Index 叠加显示, 原生支持动图, 可玩性是三者之中最高的, 显示效果通常也不会差. + +### 性能测试 + +简单的速度测试. + +- 固定宽度连续输出53张中到大尺寸(1920x1080到9457x5324不等)JPEG和PNG图片, 取5次耗时平均, 单位为秒. + + | Terminal | KGP | Sixels | ITerm | + | -------- | ----- | ------ | ----- | + | Kitty | 4.486 | - | - | + | Ghostty | 7.184 | - | - | + | Konsole | 7.388 | 4.842 | 7.266 | + | WezTerm | 4.820 | 6.218 | 5.042 | + | Foot | - | 4.124 | - | + +- 连续输出64张小尺寸(50x50以内)PNG图片, 取5次耗时平均, 单位为毫秒. + + | Terminal | KGP | Sixels | ITerm | + | -------- | ----- | ------ | ----- | + | Kitty | 975.0 | - | - | + | Ghostty | 724.9 | - | - | + | Konsole | 744.6 | 781.6 | 768.1 | + | WezTerm | 970.4 | 980.2 | 962.0 | + | Foot | - | 719.6 | - | + +## 默认 Shell + +> 虽然这和终端模拟器关系不大, 但姑且放这里一起说说. + +### 一些概念 + +- **登录 shell** 是指用户通过终端登录系统时启动的 shell, 通常是用户登录时执行的第一个 shell. + + 登录 shell 为 bash 时, 在登录时会检索 + - `/etc/profile` + + 并加载, 并会加载以下第一个存在的用户配置文件: + - `~/.bash_profile` + - `~/.bash_login` + - `~/.profile` + + 所有全局环境变量以及其他非交互配置都可以写进这些文件. + +- **非登录 shell** 是指用户在已经登录的情况下启动的 shell, 通常是通过终端模拟器或其他方式打开的 shell. + + 非登录 shell 为 bash 时, 会先加载 `/etc/bash.bashrc`, 然后加载用户的 `~/.bashrc` 文件. + + 对于非登录 shell 为 fish 的情况, 则会先加载 `/etc/fish/conf.d` 以及 `/etc/fish/config.fish`, + 然后加载用户的 `~/.config/fish/conf.d` 以及 `~/.config/fish/config.fish`. + + 非登录 shell 会继承登录 shell 的环境变量, 但不会加载登录 shell 的配置文件. + +- **默认 shell** 是指用户通过终端登录系统时默认启动的登录 shell, 通常也将会是大多数终端模拟器默认启动的 shell. + + 默认 shell 对每个用户单独设置, 存储在 `/etc/passwd` 文件中, 可以在 `useradd` 时通过 `-s` 参数指定, 后续也可以通过 `chsh` 更改. + +- **POSIX 兼容** 的 shell 指兼容 [POSIX 规定的 Shell 语法](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html)的 shell, 常用 shell 如 bash / dash / zsh 均在此列, 另一些注重用户交互体验或其他方面的 Shell 如 fish 可能不会(完全)做到 POSIX 兼容. + +### 最佳实践 + +由于类似 fish 的 shell 未做到 POSIX 兼容, 因此不适合作为登录 shell 使用. 如果想享受 fish 提供的便利功能, 最推荐的方法是仅在交互场景启用 fish. [Archwiki](https://wiki.archlinux.org/title/Fish) 上列举了两种方法: + +- 在终端模拟器的配置里指定启动的程序 + + 绝大多数终端模拟器都提供了类似的选项, 如 Kitty 可以通过设置 `shell fish` 自动启动 fish, 对于 WezTerm 则为 `config.default_prog = { "/usr/bin/fish" }`. + + 这是兼容性最好的方法, 因为它完全不会影响原有登录终端的任何配置, 同时也完全不影响日常使用. 唯一的麻烦点在于对于每个终端模拟器(也包括 TTY 与 Kmscon 之类, 如果用到的话)需要单独配置. + +- 在 .bashrc 中自动启用 fish + + 这适用于不想对每个终端模拟器单独配置的情况或远程 SSH 登录的情况. 具体做法为添加以下内容到 `$HOME/.bashrc` 的**末尾**: + + ```bash + if grep -qv 'fish' /proc/$PPID/comm && [[ ${SHLVL} == [1,2] ]]; then + shopt -q login_shell && LOGIN_OPTION='--login' || LOGIN_OPTION='' + exec fish $LOGIN_OPTION + fi + ``` + + 但值得注意的是, 此方法不保证完全不会出问题, 例如我曾经遇到过在服务器如此配置后 vscode 远程连接无法正常建立的情况. + + 一种更为妥协的办法是通过 + + ```bash + type f &>/dev/null || alias f="exec fish" + ``` + + 设置 `f` 别名(如果没有被占用的话), 在进入 bash 后手动敲击 `f` + Enter 切换到 fish. 虽然麻烦些但明显更为可靠. + +## GPU 加速 + +虽然听起来高大上, 也是很多终端模拟器写在 Description 里的核心特性之一, 但是在实际使用场景中就我个人经验而言影响并没有想象中那么巨大. 终端模拟器所主要面对的仍然是纯文本场景, 最多换一换颜色, 滚一滚屏幕, 这对于现代 CPU 来说并没有很吃力. + +## 一些特殊者 + +### Ghostty + +如果说终端模拟器也要有自己的原神, 那么 Ghostty 无疑是最合适的候选之一. 关于这个终端模拟器可以聊的东西有很多, 这里先简单列一些 Pro 和 Con. + +- Pros + - Terminal Inspector + + 独一份的调试窗口. 虽然对我来说大多数时候都没什么实际作用, 但总会有用到的时候, 具有一定不可替代性~~, 并且真的很酷~~. + + - 自定义 Shader + + 简单如光标跳转动画, 复杂如全局光效, 从 CRT 到 Glitchy, 可玩性极高. + +- Cons + - 不稳定 + + 尽管从首个正式 Release 开始计算已经过了一周岁生日, 但 Ghostty 目前仍处于早期版本, 使用过程中还是会遇到各种奇奇怪怪的问题, 并不适合作为主力终端模拟器使用. + + - 启动速度 + + 相比其他相同定位的终端模拟器, Ghostty 的冷启动速度可以说奇慢无比. 尽管[文档](https://ghostty.org/docs/linux/systemd)有提到在启动 `app-com.mitchellh.ghostty.service` 服务的前提下使用 `ghostty +new-window` 加快启动速度, 但这同时放弃了很多灵活性. 例如, `ghostty +new-window` 无法与 `-e` 参数一起使用, 非冷启动的实例也难以同步环境变量, 以 systemd 服务启动的 ghostty 甚至无法自动同步在 WM 如 niri 处配置的环境变量. 虽然这些缺失的灵活性可以通过其他一些方法弥补, 但这确实是使用其他终端模拟器时不曾面对的问题. + +### Kmscon + +这是运行在 Linux TTY 上的终端模拟器, 可以在一定程度上作为传统 TTY 的替代品使用, 提供了诸如复杂字体渲染 / CJK 文字 / 多显示器支持等高级功能. 关于此的话题可以在 [kmscon.md](kmscon.md) 中找到. + +## References + +- [Kitty Terminal Graphics Protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/) + +- [Sixel - Wikipedia](https://en.wikipedia.org/wiki/Sixel) + +- [XTerm Control Sequences](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Sixel-Graphics) + +- [Feature Reporting Spec - ITerm2](https://iterm2.com/feature-reporting/) + +- [Shell Command Language](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html) + +- [Fish - ArchWiki](https://wiki.archlinux.org/title/Fish) + +- [Systemd and D-Bus - Linux - Ghostty](https://ghostty.org/docs/linux/systemd)