frobar-ng: Sections factory!

This commit is contained in:
Geoffrey Frogeye 2025-01-06 22:48:56 +01:00
parent ecf831d4ac
commit ffd402f57c
Signed by: geoffrey
GPG key ID: C72403E7F82E6AD8

View file

@ -1,6 +1,7 @@
#!/usr/bin/env python3
import asyncio
import collections
import datetime
import enum
import ipaddress
@ -411,6 +412,8 @@ class Bar(ComposableText):
class Provider:
sectionType: type[Section] = Section
def __init__(self, color: rich.color.Color = rich.color.Color.default()) -> None:
self.modules: list[Module] = list()
self.color = color
@ -435,7 +438,7 @@ class MirrorProvider(Provider):
class SingleSectionProvider(MirrorProvider):
async def run(self) -> None:
await super().run()
self.section = Section(parent=self.module, color=self.color)
self.section = self.sectionType(parent=self.module, color=self.color)
class StaticProvider(SingleSectionProvider):
@ -481,10 +484,46 @@ class StatefulSection(Section):
self.bar.taskGroup.create_task(self.callback())
class SingleStatefulSectionProvider(MirrorProvider):
async def run(self) -> None:
await super().run()
self.section = StatefulSection(parent=self.module, color=self.color)
class StatefulSectionProvider(Provider):
sectionType = StatefulSection
class SingleStatefulSectionProvider(StatefulSectionProvider, SingleSectionProvider):
section: StatefulSection
class MultiSectionsProvider(Provider):
def __init__(self, color: rich.color.Color = rich.color.Color.default()) -> None:
super().__init__(color=color)
self.sectionKeys: dict[Module, dict[Sortable, Section]] = (
collections.defaultdict(dict)
)
self.updaters: dict[Section, typing.Callable] = dict()
async def getSectionUpdater(self, section: Section) -> typing.Callable:
raise NotImplementedError()
async def updateSections(self, sections: set[Sortable], module: Module) -> None:
moduleSections = self.sectionKeys[module]
async with asyncio.TaskGroup() as tg:
for sortKey in sections:
section = moduleSections.get(sortKey)
if not section:
section = self.sectionType(
parent=module, sortKey=sortKey, color=self.color
)
self.updaters[section] = await self.getSectionUpdater(section)
moduleSections[sortKey] = section
updater = self.updaters[section]
tg.create_task(updater())
missingKeys = set(moduleSections.keys()) - sections
for missingKey in missingKeys:
section = moduleSections.get(missingKey)
assert section
section.setText(None)
class PeriodicProvider(Provider):
@ -543,6 +582,8 @@ class I3ModeProvider(SingleSectionProvider):
i3.on(i3ipc.Event.MODE, self.on_mode)
await i3.main()
# TODO Hide WorkspaceProvider when this is active
class I3WindowTitleProvider(SingleSectionProvider):
# TODO FEAT To make this available from start, we need to find the
@ -557,44 +598,34 @@ class I3WindowTitleProvider(SingleSectionProvider):
await i3.main()
class I3WorkspacesProvider(Provider):
class I3WorkspacesProvider(MultiSectionsProvider):
COLOR_URGENT = rich.color.Color.parse("red")
COLOR_FOCUSED = rich.color.Color.parse("yellow")
# TODO Should be orange (not a terminal color)
COLOR_VISIBLE = rich.color.Color.parse("blue")
COLOR_VISIBLE = rich.color.Color.parse("cyan")
COLOR_DEFAULT = rich.color.Color.parse("bright_black")
async def updateWorkspaces(self, i3: i3ipc.Connection) -> None:
"""
Since the i3 IPC interface cannot really tell you by events
when workspaces get invisible or not urgent anymore.
Relying on those exclusively would require reimplementing some of i3 logic.
Fetching all the workspaces on event looks ugly but is the most maintainable.
Times I tried to challenge this and failed: 2.
"""
workspaces = await i3.get_workspaces()
for workspace in workspaces:
module = self.modulesFromOutput[workspace.output]
if workspace.num in self.sections:
section = self.sections[workspace.num]
if section.parent != module:
section.unsetParent()
section.setParent(module)
else:
section = Section(parent=module, sortKey=workspace.num)
self.sections[workspace.num] = section
def __init__(
self,
) -> None:
super().__init__()
self.workspaces: dict[int, i3ipc.WorkspaceReply]
def generate_switch_workspace(num: int) -> typing.Callable:
def switch_workspace() -> None:
self.bar.taskGroup.create_task(
i3.command(f"workspace number {num}")
)
self.sections: dict[int, Section] = dict()
self.modulesFromOutput: dict[str, Module] = dict()
self.bar: Bar
return switch_workspace
async def getSectionUpdater(self, section: Section) -> typing.Callable:
assert isinstance(section.sortKey, int)
num = section.sortKey
section.setAction(
Button.CLICK_LEFT, generate_switch_workspace(workspace.num)
)
def switch_to_workspace() -> None:
self.bar.taskGroup.create_task(self.i3.command(f"workspace number {num}"))
section.setAction(Button.CLICK_LEFT, switch_to_workspace)
async def update() -> None:
workspace = self.workspaces[num]
name = workspace.name
if workspace.urgent:
section.color = self.COLOR_URGENT
@ -607,26 +638,34 @@ class I3WorkspacesProvider(Provider):
if workspace.focused or workspace.visible:
name = f"{name} X" # TODO Custom names
section.setText(name)
workspacesNums = set(workspace.num for workspace in workspaces)
for num, section in self.sections.items():
if num not in workspacesNums:
# This should delete the Section but it turned out to be hard
section.setText(None)
return update
async def updateWorkspaces(self) -> None:
"""
Since the i3 IPC interface cannot really tell you by events
when workspaces get invisible or not urgent anymore.
Relying on those exclusively would require reimplementing some of i3 logic.
Fetching all the workspaces on event looks ugly but is the most maintainable.
Times I tried to challenge this and failed: 2.
"""
workspaces = await self.i3.get_workspaces()
self.workspaces = dict()
modules = collections.defaultdict(set)
for workspace in workspaces:
self.workspaces[workspace.num] = workspace
module = self.modulesFromOutput[workspace.output]
modules[module].add(workspace.num)
await asyncio.gather(
*[self.updateSections(nums, module) for module, nums in modules.items()]
)
def onWorkspaceChange(
self, i3: i3ipc.Connection, e: i3ipc.Event | None = None
) -> None:
# Cancelling the task doesn't seem to prevent performance double-events
self.bar.taskGroup.create_task(self.updateWorkspaces(i3))
def __init__(
self,
) -> None:
super().__init__()
self.sections: dict[int, Section] = dict()
self.modulesFromOutput: dict[str, Module] = dict()
self.bar: Bar
self.bar.taskGroup.create_task(self.updateWorkspaces())
async def run(self) -> None:
for module in self.modules:
@ -635,10 +674,10 @@ class I3WorkspacesProvider(Provider):
self.modulesFromOutput[output] = module
self.bar = module.bar
i3 = await i3ipc.aio.Connection(auto_reconnect=True).connect()
i3.on(i3ipc.Event.WORKSPACE, self.onWorkspaceChange)
self.onWorkspaceChange(i3)
await i3.main()
self.i3 = await i3ipc.aio.Connection(auto_reconnect=True).connect()
self.i3.on(i3ipc.Event.WORKSPACE, self.onWorkspaceChange)
self.onWorkspaceChange(self.i3)
await self.i3.main()
class AlertingProvider(Provider):
@ -785,13 +824,15 @@ class BatteryProvider(AlertingProvider, PeriodicStatefulProvider):
self.section.setText(text)
class PulseaudioProvider(SingleSectionProvider):
async def update(self) -> None:
async with pulsectl_asyncio.PulseAsync("frobar-updater") as pulse:
text = ""
# TODO Sections
for sink in await pulse.sink_list():
log.debug(f"{sink}")
class PulseaudioProvider(
MirrorProvider, StatefulSectionProvider, MultiSectionsProvider
):
async def getSectionUpdater(self, section: Section) -> typing.Callable:
assert isinstance(section, StatefulSection)
assert isinstance(section.sortKey, str)
sink = self.sinks[section.sortKey]
if (
sink.port_active.name == "analog-output-headphones"
or sink.port_active.description == "Headphones"
@ -801,75 +842,120 @@ class PulseaudioProvider(SingleSectionProvider):
sink.port_active.name == "analog-output-speaker"
or sink.port_active.description == "Speaker"
):
icon = "" if sink.mute else ""
icon = ""
elif sink.port_active.name in ("headset-output", "headphone-output"):
icon = ""
else:
icon = "?"
vol = await pulse.volume_get_all_chans(sink)
fg = (sink.mute and "#333333") or (vol > 1 and "#FF0000") or None
# TODO Show which is default
text += f" {icon} {vol:.0%}"
self.section.setText(text)
section.numberStates = 3
section.state = 1
# TODO Change volume with wheel
async def updater() -> None:
assert isinstance(section, StatefulSection)
text = icon
sink = self.sinks[section.sortKey]
async with pulsectl_asyncio.PulseAsync("frobar-get-volume") as pulse:
vol = await pulse.volume_get_all_chans(sink)
if section.state == 1:
text += f" {ramp(vol)}"
elif section.state == 2:
text += f" {vol:.0%}"
# TODO Show which is default
section.setText(text)
section.setChangedState(updater)
return updater
async def update(self) -> None:
async with pulsectl_asyncio.PulseAsync("frobar-list-sinks") as pulse:
self.sinks = dict((sink.name, sink) for sink in await pulse.sink_list())
await self.updateSections(set(self.sinks.keys()), self.module)
async def run(self) -> None:
await super().run()
await self.update()
async with pulsectl_asyncio.PulseAsync("frobar-events") as pulse:
async with pulsectl_asyncio.PulseAsync("frobar-events-listener") as pulse:
async for event in pulse.subscribe_events(pulsectl.PulseEventMaskEnum.sink):
await self.update()
class NetworkProviderSection(StatefulSection):
class NetworkProvider(
MirrorProvider, PeriodicProvider, StatefulSectionProvider, MultiSectionsProvider
):
def __init__(
self,
parent: Module,
iface: str,
provider: "NetworkProvider",
color: rich.color.Color = rich.color.Color.default(),
) -> None:
super().__init__(parent=parent, sortKey=iface, color=provider.color)
self.iface = iface
self.provider = provider
super().__init__(color=color)
self.ignore = False
self.icon = "?"
self.wifi = False
async def init(self) -> None:
loop = asyncio.get_running_loop()
self.time = loop.time()
self.io_counters = psutil.net_io_counters(pernic=True)
async def doNothing(self) -> None:
pass
@staticmethod
def getIfaceAttributes(iface: str) -> tuple[bool, str, bool]:
relevant = True
icon = "?"
wifi = False
if iface == "lo":
self.ignore = True
relevant = False
elif iface.startswith("eth") or iface.startswith("enp"):
if "u" in iface:
self.icon = ""
icon = ""
else:
self.icon = ""
icon = ""
elif iface.startswith("wlan") or iface.startswith("wl"):
self.icon = ""
self.wifi = True
icon = ""
wifi = True
elif (
iface.startswith("tun") or iface.startswith("tap") or iface.startswith("wg")
):
self.icon = ""
icon = ""
elif iface.startswith("docker"):
self.icon = ""
icon = ""
elif iface.startswith("veth"):
self.icon = ""
icon = ""
elif iface.startswith("vboxnet"):
self.icon = ""
self.numberStates = 5 if self.wifi else 4
self.state = 1 if self.wifi else 0
icon = ""
self.setChangedState(self.update)
return relevant, icon, wifi
async def update(self) -> None:
if self.ignore or not self.provider.if_stats[self.iface].isup:
self.setText(None)
async def getSectionUpdater(self, section: Section) -> typing.Callable:
assert isinstance(section, StatefulSection)
assert isinstance(section.sortKey, str)
iface = section.sortKey
relevant, icon, wifi = self.getIfaceAttributes(iface)
if not relevant:
return self.doNothing
section.numberStates = 5 if wifi else 4
section.state = 1 if wifi else 0
async def update() -> None:
assert isinstance(section, StatefulSection)
if not self.if_stats[iface].isup:
section.setText(None)
return
text = self.icon
state = self.state + (0 if self.wifi else 1) # SSID
if self.wifi and state >= 1:
cmd = ["iwgetid", self.iface, "--raw"]
text = icon
state = section.state + (0 if wifi else 1)
if wifi and state >= 1: # SSID
cmd = ["iwgetid", iface, "--raw"]
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE
)
@ -877,7 +963,7 @@ class NetworkProviderSection(StatefulSection):
text += f" {stdout.decode().strip()}"
if state >= 2: # Address
for address in self.provider.if_addrs[self.iface]:
for address in self.if_addrs[iface]:
if address.family == socket.AF_INET:
net = ipaddress.IPv4Network(
(address.address, address.netmask), strict=False
@ -886,11 +972,11 @@ class NetworkProviderSection(StatefulSection):
break
if state >= 3: # Speed
prevRecv = self.provider.prev_io_counters[self.iface].bytes_recv
recv = self.provider.io_counters[self.iface].bytes_recv
prevSent = self.provider.prev_io_counters[self.iface].bytes_sent
sent = self.provider.io_counters[self.iface].bytes_sent
dt = self.provider.time - self.provider.prev_time
prevRecv = self.prev_io_counters[iface].bytes_recv
recv = self.io_counters[iface].bytes_recv
prevSent = self.prev_io_counters[iface].bytes_sent
sent = self.io_counters[iface].bytes_sent
dt = self.time - self.prev_time
recvDiff = (recv - prevRecv) / dt
sentDiff = (sent - prevSent) / dt
@ -899,25 +985,15 @@ class NetworkProviderSection(StatefulSection):
if state >= 4: # Counter
text += f"{humanSize(recv)}{humanSize(sent)}"
self.setText(text)
section.setText(text)
section.setChangedState(update)
class NetworkProvider(MirrorProvider, PeriodicProvider):
def __init__(
self,
color: rich.color.Color = rich.color.Color.default(),
) -> None:
super().__init__(color=color)
self.sections: dict[str, NetworkProviderSection] = dict()
async def init(self) -> None:
loop = asyncio.get_running_loop()
self.time = loop.time()
self.io_counters = psutil.net_io_counters(pernic=True)
return update
async def loop(self) -> None:
loop = asyncio.get_running_loop()
async with asyncio.TaskGroup() as tg:
self.prev_io_counters = self.io_counters
self.prev_time = self.time
# On-demand would only benefit if_addrs:
@ -929,22 +1005,7 @@ class NetworkProvider(MirrorProvider, PeriodicProvider):
self.if_addrs = psutil.net_if_addrs()
self.io_counters = psutil.net_io_counters(pernic=True)
for iface in self.if_stats:
section = self.sections.get(iface)
if not section:
section = NetworkProviderSection(
parent=self.module, iface=iface, provider=self
)
self.sections[iface] = section
tg.create_task(section.update())
for iface, section in self.sections.items():
if iface not in self.if_stats:
section.setText(None)
async def onStateChange(self, section: StatefulSection) -> None:
assert isinstance(section, NetworkProviderSection)
await section.update()
await self.updateSections(set(self.if_stats.keys()), self.module)
class TimeProvider(PeriodicStatefulProvider):