// i3csstatus - Alternative generator of i3 status bar written in c# // (c) 2022 Jiri Kalvoda <jirikalvoda@kam.mff.cuni.cz> // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // any later version. // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // You should have received a copy of the GNU General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. #nullable enable using System; using System.Collections.Generic; using System.Text; using System.IO; using Pastel; using System.Text.Json; using System.Text.Json.Nodes; using System.Linq; using System.Threading; using StringInfo = System.Globalization.StringInfo; using System.CommandLine; using System.Runtime.InteropServices; using Color = System.Drawing.Color; using Process = System.Diagnostics.Process; using Config; namespace i3csstatus; class PrintAndExit: Exception { public string ToPrint; public int ExitCode; public PrintAndExit(string _ToPrint, int _ExitCode=1) { ToPrint = _ToPrint; ExitCode = _ExitCode; } public override string Message { get => $"Print \"{ToPrint}\" and exit with exit code {ExitCode}"; } } static class POSIX { [DllImport("libc", SetLastError = true)] public static extern int mkfifo(string path, int mode); [DllImport("libc", CallingConvention=CallingConvention.Cdecl, SetLastError=true)] public static extern int kill(int pid, int sig); public static int SIGUSR1 = 10; public static int EEXIST = 17; public static int ERRNO { get => Marshal.GetLastPInvokeError();} public static void Bash(string cmd) { var process = new Process(); process.StartInfo.FileName = "bash"; process.StartInfo.Arguments = ""; process.StartInfo.RedirectStandardInput = true; process.Start(); TextWriter stdin = process.StandardInput; stdin.WriteLine("exec 1> /dev/stderr"); stdin.Write(cmd); stdin.Close(); } } static class ProcesExtended { public static int KillBySignal(this Process p, int sig) { return POSIX.kill(p.Id, sig); } } static class JsonExtended { public static JsonObject RemoveNull(this JsonObject o) { List<string> toDelete = new(); foreach(var (k, v) in o) { if(v == null) toDelete.Add(k); } foreach(var k in toDelete) o.Remove(k); return o; } } static class ColorExtended { static public string ToHex(this Color c) { return $"#{c.R:X2}{c.G:X2}{c.B:X2}"; } static public Color ToColor(this string s) { return System.Drawing.ColorTranslator.FromHtml(s); } } enum Align { Left, Right, Center } enum Markup { None, Pango } record Coordinates( int X, int Y ); enum MouseButton { Left, Right, Middle } [Flags] enum Modifiers { Shift=1, Control=2, Super=4, Alt=8 } record ClickEvent( Coordinates Relative, // relative_x, relative_y Coordinates Size, // width, height MouseButton Button, Coordinates? Absolute = null, // x, y Coordinates? Output = null, // output_x, output_y Modifiers Modifiers = 0 ); record Block( string Text, string? ShortText=null, Color? Color=null, Color? BackgroundColor=null, Color? BorderColor=null, int? BorderTop=null, int? BorderRight=null, int? BorderBottom = null, int? BorderLeft = null, string? MinWidth_string = null, int? MinWidth_int = null, Align Align = Align.Left, bool Urgent = false, bool Separator = true, int? SeparatorBlockWidth=null, Markup Markup = Markup.None, Action<ClickEvent>? OnClick=null ); interface ModuleParent { public abstract void Schedule(int in_ms); public abstract T GetGlobal<T>() where T: GlobalModuleResource, new(); } abstract partial class StatusBar: ModuleParent { static public string DefaultConfigText = @" refresh = 1 [wireless] _type=i3status global_cache = 1 name = wireless _first_ config format_up = ""W: (%quality %essid) %ip"" format_down = ""W: down"" [ethernet] _type=i3status name=ethernet _first_ config format_up = ""E: %ip (%speed)"" format_down = ""E: down"" [battery] [load] _type = i3status name = load config = format = ""%1min L"" [memory] _type = i3status name = memory config format = ""%available"" threshold_degraded = ""1G"" format_degraded = ""MEMORY %available"" [time] format = yyyy-MM-dd HH:mm:ss refresh "; List<Module> modules = new(); protected void parseConfigFile(FileInfo? configFile) { if(configFile == null) { string configText; string? fileName; try { fileName = Environment.GetEnvironmentVariable("HOME")+"/.config/i3/i3csstatus.conf"; configText = File.ReadAllText(fileName); } catch(Exception e) when (e is FileNotFoundException || e is DirectoryNotFoundException) { fileName = null; configText = DefaultConfigText; } parseConfig(new ConfigParser(configText, fileName)); } else parseConfigString(File.ReadAllText(configFile.ToString()), configFile.ToString()); } protected void parseConfigString(string configString, string? fileName) { try { parseConfig(new ConfigParser(configString, fileName)); } catch(ConfigException e) when (e.Parser.FileName == fileName && fileName != null) { throw new PrintAndExit(e.ErrorStringWithFile()); } } virtual protected void parseConfig(ConfigParser p) { var moduleTypes = new Dictionary<string, Type>( from assembly in AppDomain.CurrentDomain.GetAssemblies() from type in assembly.GetTypes() where type.IsDefined(typeof(ModuleName), false) where typeof(Module).IsAssignableFrom(type) from name in type.GetCustomAttributes(typeof(ModuleName), false) select new KeyValuePair<string, Type>(((ModuleName)name).GetName(), type) ); var constructorSignature = new Type[]{}; foreach(var s in p) { ConfigValue type = s.OptionalDefaultIsSectionName("_type"); if(!moduleTypes.ContainsKey(type.AsString())) throw new ConfigMistake(type, $"Module type with name \"{type.AsString()}\" not exists."); var constructor = moduleTypes[type.AsString()].GetConstructor(constructorSignature); if(constructor == null) throw new Exception($"Missing constructor of {type} module"); Module module = (Module) constructor.Invoke(new object[]{}); modules.Add(addStandardModuleWrappers(s, module, this)); s.Use(); s.CheckUnused(); } p.MainSection.CheckUnused(); p.CheckUnused(); } public List<Block> Get() { var elements = new List<Block>(); foreach(var m in modules) elements.AddRange(m.Get()); return elements; } public abstract void Schedule(int in_ms); public abstract T GetGlobal<T>() where T: GlobalModuleResource, new(); } class InnerStatusBar: StatusBar { ModuleParent parrent; public InnerStatusBar(ModuleParent _parrent, ConfigParser p) { parrent = _parrent; parseConfig(p); } public override void Schedule(int in_ms) => parrent.Schedule(in_ms); public override T GetGlobal<T>() => parrent.GetGlobal<T>(); } abstract class RootStatusBar: StatusBar { long nextRun = 0; long refresh_ms; object nextRunMonitor = new(); public RootStatusBar(FileInfo configFile) { parseConfigFile(configFile); } override protected void parseConfig(ConfigParser p) { refresh_ms = p.MainSection["refresh"]?.AsMs() ?? 1000; base.parseConfig(p); foreach(var g in globalModuleResources) g.Value.InitEnd(); } void wait() { lock(nextRunMonitor) { while(true) { long actual = Environment.TickCount64; if(nextRun <= actual) break; long wait_ms = Math.Min(10000, nextRun - actual); Monitor.Wait(nextRunMonitor, (int)wait_ms, true); } nextRun = Environment.TickCount64 + refresh_ms; } } public void Run(TextWriter w) { initOutput(w); while(true) { wait(); Print(w); } } abstract protected void format(TextWriter w, List<Block> elements); virtual protected void initOutput(TextWriter w){} public void Print(TextWriter w) { foreach(var g in globalModuleResources) g.Value.GetBegin(); var elements = Get(); foreach(var g in globalModuleResources) g.Value.GetEnd(); format(w, elements); w.Flush(); } public override void Schedule(int in_ms) { lock(nextRunMonitor) { long actual = Environment.TickCount64; if(nextRun <= actual + in_ms) return; nextRun = actual + in_ms; Monitor.Pulse(nextRunMonitor); } } Dictionary<Type, GlobalModuleResource> globalModuleResources = new(); public override T GetGlobal<T>() { if(!globalModuleResources.ContainsKey(typeof(T))) globalModuleResources[typeof(T)] = new T(); return (T)globalModuleResources[typeof(T)]; } } class StatusBarPlainText: RootStatusBar { public static string RemoveMarkup(string s, Markup m) { if(m == Markup.Pango) { bool inTag = new(); StringBuilder res = new(); foreach(char c in s) { if(c == '<') inTag = true; if(!inTag) res.Append(c); if(c == '>') inTag = false; } return res.ToString(); } return s; } protected virtual string elementAsString(Block e) { string s = RemoveMarkup(e.Text, e.Markup); if(e.MinWidth_string != null || e.MinWidth_int != null) { int minWidth = 0; if(e.MinWidth_string != null) minWidth = new StringInfo(e.MinWidth_string).LengthInTextElements; if(e.MinWidth_int != null) minWidth = (int)e.MinWidth_int/6; int current = new StringInfo(s).LengthInTextElements; if(current < minWidth) { if(e.Align == Align.Left) s = s + new String(' ', minWidth-current); if(e.Align == Align.Right) s = new String(' ', minWidth-current) + s; if(e.Align == Align.Center) s = new String(' ', (minWidth-current+1)/2) + s + new String(' ', (minWidth-current)/2); } } return s; } public StatusBarPlainText(FileInfo configFile):base(configFile){} override protected void format(TextWriter w, List<Block> elements) { Block? last = null; foreach(var e in elements) { if(last != null) w.Write(last.Separator?"|":" "); w.Write(elementAsString(e)); last = e; } w.WriteLine(""); } } class StatusBarTerminal: StatusBarPlainText { Thread? inputThread; public StatusBarTerminal(FileInfo configFile, bool doInput):base(configFile) { if(doInput) { inputThread = new Thread(this.inputThreadFunc); inputThread.IsBackground = true; inputThread.Start(); } } void inputThreadFunc() { while(true) { System.ConsoleKeyInfo k = Console.ReadKey(); if(k.KeyChar == '\r') Schedule(0); else if(k.KeyChar == 'q') Environment.Exit(0); else Console.WriteLine(""); } } override protected string elementAsString(Block e) { string msg = base.elementAsString(e); if(e.Urgent) msg=$"\x1B[1m{msg}\x1B[0m"; if(e.Color != null) msg = msg.Pastel(e.Color.Value); if(e.BackgroundColor != null) msg = msg.PastelBg(e.BackgroundColor.Value); return msg; } override protected void format(TextWriter w, List<Block> elements) { Block? last = null; foreach(var e in elements) { if(last != null) w.Write((last.Separator?"|":" ").Pastel(Color.FromArgb(165, 229, 250))); w.Write(elementAsString(e)); last = e; } w.WriteLine(""); } } class StatusBarI3: RootStatusBar { Thread? inputThread; record HistoryElement( long time_ms, long id, IEnumerable<Block> data ); Queue<HistoryElement> history = new(); // for finding correct lambda in inputThread int outputCounter = 0; public StatusBarI3(FileInfo configFile, bool doInput):base(configFile) { Console.Error.Flush(); if(doInput) { inputThread = new Thread(this.inputThreadFunc); inputThread.IsBackground = true; inputThread.Start(); } } void inputThreadFunc() { Console.ReadLine(); // read "[" while(true) { #pragma warning disable 8602 string? line = Console.ReadLine(); if(line == null) throw new Exception("I3bar close input"); if(line[0] == ',') line = line[1..]; JsonObject json = JsonObject.Parse(line).AsObject(); MouseButton button; int jsonButton = json["button"].AsValue().GetValue<int>(); if(jsonButton == 1) button = MouseButton.Left; else if(jsonButton == 2) button = MouseButton.Middle; else if(jsonButton == 3) button = MouseButton.Right; else continue; Modifiers mod = 0; foreach(var i in json["modifiers"].AsArray()) { var s = i.AsValue().GetValue<string>(); if(s=="Shift") mod |= Modifiers.Shift; if(s=="Control") mod |= Modifiers.Control; if(s=="Mod4") mod |= Modifiers.Super; if(s=="Mod1") mod |= Modifiers.Alt; } Console.Error.WriteLine(mod); Console.Error.Flush(); var ev = new ClickEvent( Relative: new Coordinates(json["relative_x"].AsValue().GetValue<int>(), json["relative_y"].AsValue().GetValue<int>()), Absolute: new Coordinates(json["x"].AsValue().GetValue<int>(), json["y"].AsValue().GetValue<int>()), Output: new Coordinates(json["output_x"].AsValue().GetValue<int>(), json["output_x"].AsValue().GetValue<int>()), Size: new Coordinates(json["height"].AsValue().GetValue<int>(), json["width"].AsValue().GetValue<int>()), Button: button, Modifiers: mod ); string name = json["name"].AsValue().GetValue<string>(); int outputId = int.Parse(name.Split(".")[0]); int blockId = int.Parse(name.Split(".")[1]); HistoryElement currentBar; lock(history) { while(history.Count > 0 && history.Peek().id < outputId) history.Dequeue(); if(history.Count <= 0) break; currentBar = history.Peek(); } var block = currentBar.data.ElementAt(blockId); if(block.OnClick != null) block.OnClick(ev); #pragma warning restore 8602 } } override protected void initOutput(TextWriter w) { if(inputThread == null) w.WriteLine("{\"version\":1}"); else w.WriteLine("{\"version\":1, \"click_events\": true }"); w.WriteLine("[{}"); } override protected void format(TextWriter w, List<Block> elements) { if(inputThread != null) { lock(history) { while(history.Count > 0 && history.Peek().time_ms < Environment.TickCount64 - 1000) history.Dequeue(); history.Enqueue(new HistoryElement(Environment.TickCount64, outputCounter, elements)); } } JsonNode? intStringUnion(int? _int, string? _string) { JsonNode? n = null; if(_int != null) n = _int; if(_string != null) n = _string; return n; } int i = 0; var json = new JsonArray((from e in elements select new JsonObject(){ ["name"] = $"{outputCounter}.{i++}", ["full_text"] = e.Text, ["short_text"] = e.ShortText, ["color"] = e.Color?.ToHex(), ["background"] = e.BackgroundColor?.ToHex(), ["border"] = e.BorderColor?.ToHex(), ["border_top"] = e.BorderTop, ["border_right"] = e.BorderTop, ["border_bottom"] = e.BorderTop, ["border_left"] = e.BorderTop, ["min_width"] = intStringUnion(e.MinWidth_int, e.MinWidth_string), ["align"] = e.Align==Align.Right?"right": e.Align==Align.Center?"center" :null, ["urgent"] = e.Urgent?true:null, ["separator"] = e.Separator?null:false, ["separator_block_width"] = e.SeparatorBlockWidth, ["markup"] = e.Markup==Markup.Pango?"pango": null }.RemoveNull()).ToArray()); var opt = new JsonSerializerOptions(); opt.DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull; w.Write(","); w.WriteLine(json.ToJsonString(opt)); outputCounter++; } } class Program { #pragma warning disable 1998 static void LinuxCatch(Action f) { try { f(); } catch (PrintAndExit e) { Console.Error.WriteLine(e.ToPrint.Pastel(Color.FromArgb(255,0,0))); Environment.Exit(e.ExitCode); } catch (Exception e) { Console.Error.WriteLine(e.ToString().Pastel(Color.FromArgb(255,0,0))); Console.Error.WriteLine( @" this is not supposed to happen. Please report this bug to jirikalvoda+i3csstatus@kam.mff.cuni.cz. Thank you." .Pastel(Color.FromArgb(255,0,0))); Environment.Exit(134); } } static int Main(string[] args) { var configOption = new Option<FileInfo> ("--config", "Path to configuration file. If not set configuration will be taken from ~/.config/i3/i3csstatus.conf or if there is no file, default config will be taken."); configOption.AddAlias("-c"); var rootCommand = new RootCommand( @"i3csstatus - Alternative generator of i3 status bar written in c# (c) 2022 Jiri Kalvoda <jirikalvoda@kam.mff.cuni.cz> This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>."); rootCommand.AddGlobalOption(configOption); var c_plainText = new Command("plain-text", "Print status bar as plain text."); rootCommand.Add(c_plainText); c_plainText.SetHandler(async (config) => { LinuxCatch(() => (new StatusBarPlainText(config)).Run(Console.Out)); }, configOption); var c_terminal = new Command("terminal", "Print status bar with terminal escape secvence."); rootCommand.Add(c_terminal); var c_terminal_input = new Option<bool> ("--input", "Read key input."); c_terminal_input.AddAlias("-i"); c_terminal.Add(c_terminal_input); c_terminal.SetHandler(async (config, input) => { LinuxCatch(() => (new StatusBarTerminal(config, input)).Run(Console.Out)); }, configOption, c_terminal_input); var c_i3 = new Command("i3", "Comunicate with i3bar."); rootCommand.Add(c_i3); var c_i3_input = new Option<bool> ("--input", "Read mouse clicks."); c_i3_input.AddAlias("-i"); c_i3.Add(c_i3_input); c_i3.SetHandler(async (config, input) => { LinuxCatch(() => (new StatusBarI3(config, input)).Run(Console.Out)); }, configOption, c_i3_input); return rootCommand.Invoke(args); } #pragma warning restore 1998 }