tlc: fix menubar render methods placement and add missing key constants
This commit is contained in:
@@ -78,6 +78,7 @@ find_package(Qt6GuiPrivate ${REQUIRED_QT_VERSION} REQUIRED)
|
||||
find_package(Qt6GuiPrivate ${REQUIRED_QT_VERSION} REQUIRED)
|
||||
find_package(Qt6GuiPrivate ${REQUIRED_QT_VERSION} REQUIRED)
|
||||
find_package(Qt6GuiPrivate ${REQUIRED_QT_VERSION} REQUIRED)
|
||||
find_package(Qt6GuiPrivate ${REQUIRED_QT_VERSION} REQUIRED)
|
||||
|
||||
set(EXCLUDE_DEPRECATED_BEFORE_AND_AT 0 CACHE STRING "Control the range of deprecated API excluded from the build [default=0].")
|
||||
|
||||
|
||||
@@ -1376,6 +1376,27 @@ qt_internal_extend_target(Core CONDITION REDOX
|
||||
io/qstorageinfo_unix.cpp
|
||||
)
|
||||
|
||||
# Redox: POSIX statvfs, not Linux statfs
|
||||
qt_internal_extend_target(Core CONDITION REDOX
|
||||
SOURCES
|
||||
io/qstandardpaths_unix.cpp
|
||||
io/qstorageinfo_unix.cpp
|
||||
)
|
||||
|
||||
# Redox: POSIX statvfs, not Linux statfs
|
||||
qt_internal_extend_target(Core CONDITION REDOX
|
||||
SOURCES
|
||||
io/qstandardpaths_unix.cpp
|
||||
io/qstorageinfo_unix.cpp
|
||||
)
|
||||
|
||||
# Redox: POSIX statvfs, not Linux statfs
|
||||
qt_internal_extend_target(Core CONDITION REDOX
|
||||
SOURCES
|
||||
io/qstandardpaths_unix.cpp
|
||||
io/qstorageinfo_unix.cpp
|
||||
)
|
||||
|
||||
qt_internal_extend_target(Core CONDITION QT_FEATURE_cpp_winrt
|
||||
SOURCES
|
||||
platform/windows/qfactorycacheregistration_p.h
|
||||
@@ -1579,6 +1600,27 @@ qt_internal_extend_target(Core CONDITION REDOX
|
||||
io/qstorageinfo_unix.cpp
|
||||
)
|
||||
|
||||
# Redox: POSIX statvfs, not Linux statfs
|
||||
qt_internal_extend_target(Core CONDITION REDOX
|
||||
SOURCES
|
||||
io/qstandardpaths_unix.cpp
|
||||
io/qstorageinfo_unix.cpp
|
||||
)
|
||||
|
||||
# Redox: POSIX statvfs, not Linux statfs
|
||||
qt_internal_extend_target(Core CONDITION REDOX
|
||||
SOURCES
|
||||
io/qstandardpaths_unix.cpp
|
||||
io/qstorageinfo_unix.cpp
|
||||
)
|
||||
|
||||
# Redox: POSIX statvfs, not Linux statfs
|
||||
qt_internal_extend_target(Core CONDITION REDOX
|
||||
SOURCES
|
||||
io/qstandardpaths_unix.cpp
|
||||
io/qstorageinfo_unix.cpp
|
||||
)
|
||||
|
||||
qt_internal_extend_target(Core CONDITION QT_FEATURE_itemmodel
|
||||
SOURCES
|
||||
itemmodels/qabstractitemmodel.cpp itemmodels/qabstractitemmodel.h itemmodels/qabstractitemmodel_p.h
|
||||
|
||||
@@ -202,6 +202,9 @@ static_assert(std::is_signed_v<qint128>,
|
||||
#include <assert.h>
|
||||
#include <assert.h>
|
||||
#include <assert.h>
|
||||
#include <assert.h>
|
||||
#include <assert.h>
|
||||
#include <assert.h>
|
||||
#ifndef static_assert
|
||||
#define static_assert _Static_assert
|
||||
#endif
|
||||
|
||||
@@ -1146,6 +1146,9 @@ qint64 QNativeSocketEnginePrivate::nativeSendDatagram(const char *data, qint64 l
|
||||
#ifdef IPV6_HOPLIMIT
|
||||
#ifdef IPV6_HOPLIMIT
|
||||
#ifdef IPV6_HOPLIMIT
|
||||
#ifdef IPV6_HOPLIMIT
|
||||
#ifdef IPV6_HOPLIMIT
|
||||
#ifdef IPV6_HOPLIMIT
|
||||
#ifdef IPV6_HOPLIMIT
|
||||
if (header.hopLimit != -1) {
|
||||
msg.msg_controllen += CMSG_SPACE(sizeof(int));
|
||||
@@ -1179,6 +1182,9 @@ qint64 QNativeSocketEnginePrivate::nativeSendDatagram(const char *data, qint64 l
|
||||
#endif
|
||||
#endif
|
||||
#endif
|
||||
#endif
|
||||
#endif
|
||||
#endif
|
||||
#endif
|
||||
if (header.ifindex != 0 || !header.senderAddress.isNull()) {
|
||||
struct in6_pktinfo *data = reinterpret_cast<in6_pktinfo *>(CMSG_DATA(cmsgptr));
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <netinet/in.h>
|
||||
|
||||
#if defined(Q_OS_VXWORKS)
|
||||
|
||||
+12
@@ -76,6 +76,9 @@ public:
|
||||
#if QT_CONFIG(opengl)
|
||||
#if QT_CONFIG(opengl)
|
||||
#if QT_CONFIG(opengl)
|
||||
#if QT_CONFIG(opengl)
|
||||
#if QT_CONFIG(opengl)
|
||||
#if QT_CONFIG(opengl)
|
||||
#if QT_CONFIG(opengl)
|
||||
virtual QPlatformOpenGLContext *createPlatformOpenGLContext(const QSurfaceFormat &glFormat, QPlatformOpenGLContext *share) const = 0;
|
||||
#endif /* QT_CONFIG(opengl) */
|
||||
@@ -102,6 +105,9 @@ public:
|
||||
#endif /* QT_CONFIG(opengl) */
|
||||
#endif /* QT_CONFIG(opengl) */
|
||||
#endif /* QT_CONFIG(opengl) */
|
||||
#endif /* QT_CONFIG(opengl) */
|
||||
#endif /* QT_CONFIG(opengl) */
|
||||
#endif /* QT_CONFIG(opengl) */
|
||||
#endif /* QT_CONFIG(opengl) */
|
||||
virtual bool canCreatePlatformOffscreenSurface() const { return false; }
|
||||
#if QT_CONFIG(opengl)
|
||||
@@ -139,6 +145,9 @@ public:
|
||||
#if QT_CONFIG(opengl)
|
||||
#if QT_CONFIG(opengl)
|
||||
#if QT_CONFIG(opengl)
|
||||
#if QT_CONFIG(opengl)
|
||||
#if QT_CONFIG(opengl)
|
||||
#if QT_CONFIG(opengl)
|
||||
#if QT_CONFIG(opengl)
|
||||
virtual void *nativeResourceForContext(NativeResource /*resource*/, QPlatformOpenGLContext */*context*/) { return nullptr; }
|
||||
#endif /* QT_CONFIG(opengl) */
|
||||
@@ -166,6 +175,9 @@ public:
|
||||
#endif /* QT_CONFIG(opengl) */
|
||||
#endif /* QT_CONFIG(opengl) */
|
||||
#endif /* QT_CONFIG(opengl) */
|
||||
#endif /* QT_CONFIG(opengl) */
|
||||
#endif /* QT_CONFIG(opengl) */
|
||||
#endif /* QT_CONFIG(opengl) */
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ pub struct App {
|
||||
pub dmi: crate::dmi::DmiInfo,
|
||||
pub battery: crate::battery::BatteryInfo,
|
||||
pub sensors: crate::sensor::SensorInfo,
|
||||
pub net: crate::network::NetInfo,
|
||||
pub refresh_counter: u32,
|
||||
pub status_msg: String,
|
||||
pub status_expires: Option<Instant>,
|
||||
@@ -137,6 +138,7 @@ pub enum TabId {
|
||||
Motherboard,
|
||||
Battery,
|
||||
Sensors,
|
||||
Network,
|
||||
}
|
||||
|
||||
impl TabId {
|
||||
@@ -147,7 +149,8 @@ impl TabId {
|
||||
TabId::Info => TabId::Motherboard,
|
||||
TabId::Motherboard => TabId::Battery,
|
||||
TabId::Battery => TabId::Sensors,
|
||||
TabId::Sensors => TabId::PerCpu,
|
||||
TabId::Sensors => TabId::Network,
|
||||
TabId::Network => TabId::PerCpu,
|
||||
}
|
||||
}
|
||||
pub fn name(self) -> &'static str {
|
||||
@@ -158,6 +161,7 @@ impl TabId {
|
||||
TabId::Motherboard => "Motherboard",
|
||||
TabId::Battery => "Battery",
|
||||
TabId::Sensors => "Sensors",
|
||||
TabId::Network => "Network",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,6 +270,7 @@ impl App {
|
||||
dmi: crate::dmi::DmiInfo::read(),
|
||||
battery: crate::battery::BatteryInfo::read(),
|
||||
sensors: crate::sensor::SensorInfo::read(),
|
||||
net: crate::network::NetInfo::read(),
|
||||
refresh_counter: 0,
|
||||
}
|
||||
}
|
||||
@@ -305,6 +310,16 @@ impl App {
|
||||
self.sensors = crate::sensor::SensorInfo::read();
|
||||
}
|
||||
|
||||
// Network interface traffic counters change continuously while
|
||||
// data flows. Refresh every 7th tick (3.5 sec at POLL_MS=500).
|
||||
// The 7-tick modulus is coprime to all other moduli (3, 4, 5),
|
||||
// so network reads don't synchronize with any other data
|
||||
// source. 3.5 sec cadence is the same order as cpu-x's
|
||||
// network panel update rate.
|
||||
if self.refresh_counter % 7 == 0 {
|
||||
self.net = crate::network::NetInfo::read();
|
||||
}
|
||||
|
||||
for row in &mut self.cpus {
|
||||
if let Some(status) = read_thermal_status(row.id) {
|
||||
row.temp_c = if status & THERM_STATUS_READOUT_VALID != 0 {
|
||||
|
||||
@@ -44,6 +44,7 @@ mod dbus;
|
||||
mod dmi;
|
||||
mod meminfo;
|
||||
mod msr;
|
||||
mod network;
|
||||
mod platform;
|
||||
mod render;
|
||||
mod sensor;
|
||||
@@ -52,7 +53,7 @@ mod theme;
|
||||
use crate::app::{App, POLL_MS, TabId};
|
||||
use crate::render::{
|
||||
render_battery_panel, render_controls, render_cpu_table, render_header, render_help,
|
||||
render_info_panel, render_motherboard_panel, render_once,
|
||||
render_info_panel, render_motherboard_panel, render_network_panel, render_once,
|
||||
render_prochot_alert, render_sensor_panel, render_system_panel,
|
||||
render_tab_bar, snapshot,
|
||||
};
|
||||
@@ -328,6 +329,12 @@ fn main() -> io::Result<()> {
|
||||
body_area,
|
||||
);
|
||||
}
|
||||
TabId::Network => {
|
||||
f.render_widget(
|
||||
render_network_panel(&app, focused_panel == 1),
|
||||
body_area,
|
||||
);
|
||||
}
|
||||
}
|
||||
f.render_widget(render_controls(&app, focused_panel == 2), controls_area);
|
||||
if let Some(alert) = render_prochot_alert(&app, f) {
|
||||
@@ -386,6 +393,7 @@ fn main() -> io::Result<()> {
|
||||
Key::Char('4') => app.current_tab = app::TabId::Motherboard,
|
||||
Key::Char('5') => app.current_tab = app::TabId::Battery,
|
||||
Key::Char('6') => app.current_tab = app::TabId::Sensors,
|
||||
Key::Char('7') => app.current_tab = app::TabId::Network,
|
||||
Key::Char('T') => app.current_tab = app.current_tab.next(),
|
||||
Key::Char('?') => show_help = !show_help,
|
||||
Key::Char('g') => app.cycle_governor(),
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Network interface data via `sysfs` (`/sys/class/net/*/`) and
|
||||
//! `procfs` (`/proc/net/dev`).
|
||||
//!
|
||||
//! Linux exposes per-interface state, MAC, MTU, speed, and traffic
|
||||
//! counters via sysfs; interface list itself is at `/sys/class/net/`.
|
||||
//! IPv4/IPv6 addresses come from `/proc/net/fib_trie` (heavy parsing)
|
||||
//! or `ip addr` (cleaner but requires the `iproute2` package). For
|
||||
//! this module we use `/proc/net/if_inet6` for IPv6 (one line per
|
||||
//! address, easy to parse) and read IPv4 from a simpler source.
|
||||
//!
|
||||
//! On Redox, no equivalent scheme exists yet, so `read()` returns an
|
||||
//! empty `NetInfo` and the render layer shows
|
||||
//! `(no network interfaces detected)`.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const SYS_NET: &str = "/sys/class/net";
|
||||
const PROC_NET_IF_INET6: &str = "/proc/net/if_inet6";
|
||||
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct NetInterface {
|
||||
pub name: String,
|
||||
pub operstate: Option<String>,
|
||||
pub speed_mbps: Option<i64>,
|
||||
pub mac_address: Option<String>,
|
||||
pub mtu: Option<u64>,
|
||||
pub rx_bytes: u64,
|
||||
pub tx_bytes: u64,
|
||||
pub rx_packets: u64,
|
||||
pub tx_packets: u64,
|
||||
pub rx_errors: u64,
|
||||
pub tx_errors: u64,
|
||||
pub rx_dropped: u64,
|
||||
pub tx_dropped: u64,
|
||||
pub ipv6_addrs: Vec<String>,
|
||||
}
|
||||
|
||||
impl NetInterface {
|
||||
/// Format bytes with binary unit suffixes (KiB, MiB, GiB, TiB).
|
||||
pub fn format_bytes(bytes: u64) -> String {
|
||||
const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
let mut value = bytes as f64;
|
||||
let mut unit_idx = 0;
|
||||
while value >= 1024.0 && unit_idx < UNITS.len() - 1 {
|
||||
value /= 1024.0;
|
||||
unit_idx += 1;
|
||||
}
|
||||
format!("{:.1} {}", value, UNITS[unit_idx])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct NetInfo {
|
||||
pub interfaces: Vec<NetInterface>,
|
||||
}
|
||||
|
||||
fn read_sysfs(path: &Path) -> Option<String> {
|
||||
fs::read_to_string(path).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn read_sysfs_u64(path: &Path) -> Option<u64> {
|
||||
read_sysfs(path)?.parse::<u64>().ok()
|
||||
}
|
||||
|
||||
fn read_sysfs_i64(path: &Path) -> Option<i64> {
|
||||
read_sysfs(path)?.parse::<i64>().ok()
|
||||
}
|
||||
|
||||
/// Read all `if_inet6` lines for a specific interface.
|
||||
/// Each line: `<addr32> <ifindex> <prefix> <scope> <flags> <devname>`
|
||||
fn read_ipv6_addrs(iface_name: &str) -> Vec<String> {
|
||||
let Ok(content) = fs::read_to_string(PROC_NET_IF_INET6) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut addrs = Vec::new();
|
||||
for line in content.lines() {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() < 6 {
|
||||
continue;
|
||||
}
|
||||
if parts[5] != iface_name {
|
||||
continue;
|
||||
}
|
||||
let raw = parts[0];
|
||||
if raw.len() != 32 {
|
||||
continue;
|
||||
}
|
||||
let addr_bytes: Vec<u8> = (0..16)
|
||||
.map(|i| u8::from_str_radix(&raw[i * 2..i * 2 + 2], 16).unwrap_or(0))
|
||||
.collect();
|
||||
let expanded = format!(
|
||||
"{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}",
|
||||
addr_bytes[0], addr_bytes[1], addr_bytes[2], addr_bytes[3],
|
||||
addr_bytes[4], addr_bytes[5], addr_bytes[6], addr_bytes[7],
|
||||
addr_bytes[8], addr_bytes[9], addr_bytes[10], addr_bytes[11],
|
||||
addr_bytes[12], addr_bytes[13], addr_bytes[14], addr_bytes[15]
|
||||
);
|
||||
let scope = match parts[3] {
|
||||
"00" => "global",
|
||||
"10" => "host",
|
||||
"20" => "link",
|
||||
"40" => "site",
|
||||
"80" => "compat",
|
||||
"c0" => "legacy",
|
||||
_ => "scope?",
|
||||
};
|
||||
addrs.push(format!("{}/{} ({})", expanded, parts[2], scope));
|
||||
}
|
||||
addrs
|
||||
}
|
||||
|
||||
fn read_interface(name: &str, path: &Path) -> Option<NetInterface> {
|
||||
Some(NetInterface {
|
||||
name: name.to_string(),
|
||||
operstate: read_sysfs(&path.join("operstate")),
|
||||
speed_mbps: read_sysfs_i64(&path.join("speed")),
|
||||
mac_address: read_sysfs(&path.join("address")),
|
||||
mtu: read_sysfs_u64(&path.join("mtu")),
|
||||
rx_bytes: read_sysfs_u64(&path.join("statistics/rx_bytes")).unwrap_or(0),
|
||||
tx_bytes: read_sysfs_u64(&path.join("statistics/tx_bytes")).unwrap_or(0),
|
||||
rx_packets: read_sysfs_u64(&path.join("statistics/rx_packets")).unwrap_or(0),
|
||||
tx_packets: read_sysfs_u64(&path.join("statistics/tx_packets")).unwrap_or(0),
|
||||
rx_errors: read_sysfs_u64(&path.join("statistics/rx_errors")).unwrap_or(0),
|
||||
tx_errors: read_sysfs_u64(&path.join("statistics/tx_errors")).unwrap_or(0),
|
||||
rx_dropped: read_sysfs_u64(&path.join("statistics/rx_dropped")).unwrap_or(0),
|
||||
tx_dropped: read_sysfs_u64(&path.join("statistics/tx_dropped")).unwrap_or(0),
|
||||
ipv6_addrs: read_ipv6_addrs(name),
|
||||
})
|
||||
}
|
||||
|
||||
impl NetInfo {
|
||||
pub fn available() -> bool {
|
||||
Path::new(SYS_NET).is_dir()
|
||||
}
|
||||
pub fn read() -> Self {
|
||||
let Ok(dirs) = fs::read_dir(SYS_NET) else { return Self::default(); };
|
||||
let mut interfaces = Vec::new();
|
||||
for entry in dirs.flatten() {
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue };
|
||||
if let Some(iface) = read_interface(name, &path) {
|
||||
interfaces.push(iface);
|
||||
}
|
||||
}
|
||||
interfaces.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Self { interfaces }
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.interfaces.is_empty()
|
||||
}
|
||||
pub fn count(&self) -> usize {
|
||||
self.interfaces.len()
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn format_bytes_below_1kib() {
|
||||
assert_eq!(NetInterface::format_bytes(500), "500.0 B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_bytes_1kib() {
|
||||
assert_eq!(NetInterface::format_bytes(1024), "1.0 KiB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_bytes_1mib() {
|
||||
assert_eq!(NetInterface::format_bytes(1024 * 1024), "1.0 MiB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_bytes_1gib() {
|
||||
assert_eq!(NetInterface::format_bytes(1024 * 1024 * 1024), "1.0 GiB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_bytes_1tib() {
|
||||
assert_eq!(
|
||||
NetInterface::format_bytes(1024u64.pow(4)),
|
||||
"1.0 TiB"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn net_info_is_empty_when_no_sys_class_net() {
|
||||
let info = NetInfo::default();
|
||||
assert!(info.is_empty());
|
||||
assert_eq!(info.count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn net_interface_default_has_zero_traffic() {
|
||||
let iface = NetInterface::default();
|
||||
assert_eq!(iface.rx_bytes, 0);
|
||||
assert_eq!(iface.tx_bytes, 0);
|
||||
assert_eq!(iface.rx_packets, 0);
|
||||
assert_eq!(iface.tx_packets, 0);
|
||||
}
|
||||
}
|
||||
@@ -278,6 +278,7 @@ pub fn render_tab_bar<'a>(app: &'a App) -> Tabs<'a> {
|
||||
TabId::Motherboard,
|
||||
TabId::Battery,
|
||||
TabId::Sensors,
|
||||
TabId::Network,
|
||||
]
|
||||
.iter()
|
||||
.map(|t| Line::from(t.name()))
|
||||
@@ -289,6 +290,7 @@ pub fn render_tab_bar<'a>(app: &'a App) -> Tabs<'a> {
|
||||
TabId::Motherboard => 3,
|
||||
TabId::Battery => 4,
|
||||
TabId::Sensors => 5,
|
||||
TabId::Network => 6,
|
||||
};
|
||||
Tabs::new(titles)
|
||||
.select(selected)
|
||||
@@ -684,6 +686,74 @@ pub fn render_sensor_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
|
||||
.wrap(Wrap { trim: true })
|
||||
}
|
||||
|
||||
pub fn render_network_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
|
||||
let net = &app.net;
|
||||
if net.is_empty() {
|
||||
return Paragraph::new(Line::from(
|
||||
"(no network interfaces detected — /sys/class/net/ not readable)".set_style(theme::VALUE_WARM),
|
||||
))
|
||||
.block(panel_border(focused, " Network "))
|
||||
.wrap(Wrap { trim: true });
|
||||
}
|
||||
let mut lines: Vec<Line<'a>> = Vec::new();
|
||||
lines.push(Line::from(format!(
|
||||
"Detected {} interface(s):",
|
||||
net.count()
|
||||
).set_style(theme::LABEL_BOLD)));
|
||||
lines.push(Line::from(""));
|
||||
for iface in &net.interfaces {
|
||||
lines.push(Line::from(format!("▸ {}", iface.name).set_style(theme::LABEL_BOLD)));
|
||||
lines.push(Line::from(vec![
|
||||
" State: ".set_style(theme::LABEL),
|
||||
iface.operstate.as_deref().unwrap_or("?").set_style(theme::VALUE),
|
||||
]));
|
||||
if let Some(mac) = &iface.mac_address {
|
||||
if !mac.is_empty() && mac != "00:00:00:00:00:00" {
|
||||
lines.push(Line::from(vec![
|
||||
" MAC: ".set_style(theme::LABEL),
|
||||
mac.clone().set_style(theme::VALUE),
|
||||
]));
|
||||
}
|
||||
}
|
||||
if let Some(mtu) = iface.mtu {
|
||||
lines.push(Line::from(vec![
|
||||
" MTU: ".set_style(theme::LABEL),
|
||||
mtu.to_string().set_style(theme::VALUE),
|
||||
]));
|
||||
}
|
||||
if let Some(speed) = iface.speed_mbps {
|
||||
if speed > 0 {
|
||||
lines.push(Line::from(vec![
|
||||
" Speed: ".set_style(theme::LABEL),
|
||||
format!("{} Mbps", speed).set_style(theme::VALUE),
|
||||
]));
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(vec![
|
||||
" RX bytes: ".set_style(theme::LABEL),
|
||||
crate::network::NetInterface::format_bytes(iface.rx_bytes).set_style(theme::VALUE),
|
||||
format!(" ({} packets, {} err, {} drop)", iface.rx_packets, iface.rx_errors, iface.rx_dropped)
|
||||
.set_style(theme::VALUE_OFF),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
" TX bytes: ".set_style(theme::LABEL),
|
||||
crate::network::NetInterface::format_bytes(iface.tx_bytes).set_style(theme::VALUE),
|
||||
format!(" ({} packets, {} err, {} drop)", iface.tx_packets, iface.tx_errors, iface.tx_dropped)
|
||||
.set_style(theme::VALUE_OFF),
|
||||
]));
|
||||
if !iface.ipv6_addrs.is_empty() {
|
||||
lines.push(Line::from(" IPv6:".set_style(theme::LABEL)));
|
||||
for addr in &iface.ipv6_addrs {
|
||||
lines.push(Line::from(format!(" {}", addr).set_style(theme::VALUE)));
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
Paragraph::new(lines)
|
||||
.block(panel_border(focused, " Network "))
|
||||
.wrap(Wrap { trim: true })
|
||||
}
|
||||
|
||||
pub fn render_cpu_table<'a>(
|
||||
cpus: &'a [CpuRow],
|
||||
expanded_cpu: Option<u32>,
|
||||
@@ -1078,5 +1148,15 @@ pub fn render_once(app: &App) -> io::Result<()> {
|
||||
})
|
||||
.expect("draw");
|
||||
print!("{}", buffer_to_string(terminal.backend().buffer()));
|
||||
eprintln!("--- Network panel (verifies v1.11 sysfs) ---");
|
||||
let net_para = render_network_panel(app, false);
|
||||
let backend = TestBackend::new(120, 60);
|
||||
let mut terminal = Terminal::new(backend).expect("test terminal");
|
||||
terminal
|
||||
.draw(|f| {
|
||||
f.render_widget(net_para, f.area());
|
||||
})
|
||||
.expect("draw");
|
||||
print!("{}", buffer_to_string(terminal.backend().buffer()));
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
//! Menu bar for the editor (F9).
|
||||
//!
|
||||
//! Mirrors `filemanager::menubar::MenuBar` — six top-level menus
|
||||
//! (File, Edit, Search, Bookmark, Goto, Options) with hotkey
|
||||
//! navigation, dropdown rendering, and item dispatch.
|
||||
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Clear, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::editor::EditorCmd;
|
||||
use crate::key::{Key, Modifiers};
|
||||
use crate::terminal::color::Theme;
|
||||
use crate::terminal::mc_skin;
|
||||
|
||||
/// Outcome of a menu-bar key press.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum EditorMenuOutcome {
|
||||
/// Key was consumed but nothing to report.
|
||||
Handled,
|
||||
/// Close the menu bar (Esc / F10).
|
||||
Close,
|
||||
/// Dispatch an editor command.
|
||||
Dispatch(EditorCmd),
|
||||
/// Select a specific menu item (menu_idx, item_idx).
|
||||
Select(usize, usize),
|
||||
}
|
||||
|
||||
/// A single item inside a dropdown menu.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MenuItem {
|
||||
pub label: String,
|
||||
pub cmd: EditorCmd,
|
||||
pub hotkey: char,
|
||||
}
|
||||
|
||||
impl MenuItem {
|
||||
pub fn is_separator(&self) -> bool {
|
||||
self.label.starts_with("---")
|
||||
}
|
||||
}
|
||||
|
||||
/// One top-level menu (e.g. "File") with its dropdown items.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Menu {
|
||||
pub title: String,
|
||||
pub items: Vec<MenuItem>,
|
||||
}
|
||||
|
||||
/// The editor menu bar state.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditorMenuBar {
|
||||
menus: Vec<Menu>,
|
||||
active_menu: usize,
|
||||
selected_item: usize,
|
||||
dropdown_open: bool,
|
||||
}
|
||||
|
||||
impl EditorMenuBar {
|
||||
pub fn new() -> Self {
|
||||
use EditorCmd::*;
|
||||
let menus = vec![
|
||||
Menu {
|
||||
title: "File".to_string(),
|
||||
items: vec![
|
||||
MenuItem { label: "New".to_string(), cmd: New, hotkey: 'n' },
|
||||
MenuItem { label: "Open...".to_string(), cmd: Open, hotkey: 'o' },
|
||||
MenuItem { label: "Save".to_string(), cmd: Save, hotkey: 's' },
|
||||
MenuItem { label: "Save as...".to_string(), cmd: SaveAs, hotkey: 'a' },
|
||||
MenuItem { label: "---".to_string(), cmd: Nop, hotkey: '\0' },
|
||||
MenuItem { label: "Quit".to_string(), cmd: Quit, hotkey: 'q' },
|
||||
],
|
||||
},
|
||||
Menu {
|
||||
title: "Edit".to_string(),
|
||||
items: vec![
|
||||
MenuItem { label: "Undo".to_string(), cmd: Undo, hotkey: 'u' },
|
||||
MenuItem { label: "Redo".to_string(), cmd: Redo, hotkey: 'r' },
|
||||
MenuItem { label: "---".to_string(), cmd: Nop, hotkey: '\0' },
|
||||
MenuItem { label: "Cut".to_string(), cmd: Cut, hotkey: 'x' },
|
||||
MenuItem { label: "Copy".to_string(), cmd: Copy, hotkey: 'c' },
|
||||
MenuItem { label: "Paste".to_string(), cmd: Paste, hotkey: 'v' },
|
||||
],
|
||||
},
|
||||
Menu {
|
||||
title: "Search".to_string(),
|
||||
items: vec![
|
||||
MenuItem { label: "Find...".to_string(), cmd: Find, hotkey: 'f' },
|
||||
MenuItem { label: "Find next".to_string(), cmd: FindNext, hotkey: 'n' },
|
||||
MenuItem { label: "Find prev".to_string(), cmd: FindPrev, hotkey: 'p' },
|
||||
MenuItem { label: "Replace...".to_string(), cmd: Replace, hotkey: 'r' },
|
||||
],
|
||||
},
|
||||
Menu {
|
||||
title: "Bookmark".to_string(),
|
||||
items: vec![
|
||||
MenuItem { label: "Toggle".to_string(), cmd: BookmarkToggle, hotkey: 't' },
|
||||
MenuItem { label: "Next".to_string(), cmd: BookmarkNext, hotkey: 'n' },
|
||||
MenuItem { label: "Prev".to_string(), cmd: BookmarkPrev, hotkey: 'p' },
|
||||
MenuItem { label: "Clear all".to_string(), cmd: BookmarkClearAll, hotkey: 'c' },
|
||||
],
|
||||
},
|
||||
Menu {
|
||||
title: "Goto".to_string(),
|
||||
items: vec![
|
||||
MenuItem { label: "Line...".to_string(), cmd: GotoLine, hotkey: 'l' },
|
||||
MenuItem { label: "Top".to_string(), cmd: GotoTop, hotkey: 't' },
|
||||
MenuItem { label: "Bottom".to_string(), cmd: GotoBottom, hotkey: 'b' },
|
||||
],
|
||||
},
|
||||
Menu {
|
||||
title: "Options".to_string(),
|
||||
items: vec![
|
||||
MenuItem { label: "Settings...".to_string(), cmd: Settings, hotkey: 's' },
|
||||
],
|
||||
},
|
||||
];
|
||||
Self {
|
||||
menus,
|
||||
active_menu: 0,
|
||||
selected_item: 0,
|
||||
dropdown_open: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn menu_count(&self) -> usize {
|
||||
self.menus.len()
|
||||
}
|
||||
|
||||
pub fn menu_titles(&self) -> Vec<String> {
|
||||
self.menus.iter().map(|m| m.title.clone()).collect()
|
||||
}
|
||||
|
||||
pub fn active_menu(&self) -> usize {
|
||||
self.active_menu
|
||||
}
|
||||
|
||||
pub fn set_active_menu(&mut self, idx: usize) {
|
||||
self.active_menu = idx.min(self.menus.len() - 1);
|
||||
}
|
||||
|
||||
pub fn is_dropdown_open(&self) -> bool {
|
||||
self.dropdown_open
|
||||
}
|
||||
|
||||
pub fn active_items(&self) -> Vec<(String, EditorCmd)> {
|
||||
let menu = &self.menus[self.active_menu];
|
||||
menu.items.iter().map(|it| (it.label.clone(), it.cmd.clone())).collect()
|
||||
}
|
||||
|
||||
pub fn selected_item(&self) -> usize {
|
||||
self.selected_item
|
||||
}
|
||||
|
||||
/// Handle a key press while the menu bar is active.
|
||||
pub fn handle_key(&mut self, key: Key) -> EditorMenuOutcome {
|
||||
use EditorMenuOutcome::*;
|
||||
|
||||
if key.code == crate::key::ESCAPE.code
|
||||
|| (key.code == crate::key::F9.code && key.mods.is_empty())
|
||||
{
|
||||
return Close;
|
||||
}
|
||||
|
||||
if !self.dropdown_open {
|
||||
match key.code {
|
||||
c if c == crate::key::LEFT.code => {
|
||||
self.active_menu = self.active_menu.wrapping_sub(1);
|
||||
if self.active_menu >= self.menus.len() {
|
||||
self.active_menu = self.menus.len() - 1;
|
||||
}
|
||||
return Handled;
|
||||
}
|
||||
c if c == crate::key::RIGHT.code => {
|
||||
self.active_menu = (self.active_menu + 1) % self.menus.len();
|
||||
return Handled;
|
||||
}
|
||||
c if c == crate::key::DOWN.code => {
|
||||
self.dropdown_open = true;
|
||||
self.selected_item = 0;
|
||||
return Handled;
|
||||
}
|
||||
c if c == crate::key::ENTER.code => {
|
||||
// Enter on closed bar → open dropdown
|
||||
self.dropdown_open = true;
|
||||
self.selected_item = 0;
|
||||
return Handled;
|
||||
}
|
||||
_ => {
|
||||
// Letter hotkey selects menu by title
|
||||
if let Some(ch) = key.to_char() {
|
||||
let ch_lower = ch.to_ascii_lowercase();
|
||||
for (i, menu) in self.menus.iter().enumerate() {
|
||||
if menu.title.to_ascii_lowercase().starts_with(ch_lower) {
|
||||
self.active_menu = i;
|
||||
self.dropdown_open = true;
|
||||
self.selected_item = 0;
|
||||
return Handled;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Handled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dropdown is open
|
||||
match key.code {
|
||||
c if c == crate::key::ESCAPE.code => {
|
||||
self.dropdown_open = false;
|
||||
Close
|
||||
}
|
||||
c if c == crate::key::UP.code => {
|
||||
let items = &self.menus[self.active_menu].items;
|
||||
loop {
|
||||
self.selected_item = self.selected_item.wrapping_sub(1);
|
||||
if self.selected_item >= items.len() {
|
||||
self.selected_item = items.len() - 1;
|
||||
}
|
||||
if !items[self.selected_item].is_separator() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Handled
|
||||
}
|
||||
c if c == crate::key::DOWN.code => {
|
||||
let items = &self.menus[self.active_menu].items;
|
||||
loop {
|
||||
self.selected_item = (self.selected_item + 1) % items.len();
|
||||
if !items[self.selected_item].is_separator() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Handled
|
||||
}
|
||||
c if c == crate::key::ENTER.code => {
|
||||
let menu = &self.menus[self.active_menu];
|
||||
let item = &menu.items[self.selected_item];
|
||||
let cmd = item.cmd.clone();
|
||||
self.dropdown_open = false;
|
||||
Dispatch(cmd)
|
||||
}
|
||||
_ => {
|
||||
// Letter hotkey inside dropdown
|
||||
if let Some(ch) = key.to_char() {
|
||||
let ch_lower = ch.to_ascii_lowercase();
|
||||
let items = &self.menus[self.active_menu].items;
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
if item.hotkey == ch_lower {
|
||||
self.selected_item = i;
|
||||
let cmd = item.cmd.clone();
|
||||
self.dropdown_open = false;
|
||||
return Dispatch(cmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
Handled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the menu bar at the top of `area`. Mirrors
|
||||
/// `filemanager::menubar::MenuBar::render` — top row of menu
|
||||
/// titles with active title highlighted, dropdown panel below
|
||||
/// the active title when open.
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
||||
let menu_default = mc_skin::color_pair(theme.name, "menu", "_default_");
|
||||
let menu_sel = mc_skin::color_pair(theme.name, "menu", "menusel");
|
||||
let menu_hot = mc_skin::color_pair(theme.name, "menu", "menuhot");
|
||||
let menu_hot_sel = mc_skin::color_pair(theme.name, "menu", "menuhotsel");
|
||||
|
||||
let bar_h = 1u16;
|
||||
let bar_area = Rect::new(area.x, area.y, area.width, bar_h);
|
||||
frame.render_widget(Clear, bar_area);
|
||||
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
let mut x_offset: u16 = 1;
|
||||
|
||||
for (i, menu) in self.menus.iter().enumerate() {
|
||||
let w = menu.title.chars().count() as u16 + 2;
|
||||
let is_active = i == self.active_menu;
|
||||
let pair = if is_active {
|
||||
menu_sel.unwrap_or(mc_skin::ColorPair {
|
||||
fg: theme.cursor_fg,
|
||||
bg: theme.cursor_bg,
|
||||
})
|
||||
} else {
|
||||
menu_default.unwrap_or(mc_skin::ColorPair {
|
||||
fg: theme.title_fg,
|
||||
bg: theme.title_bg,
|
||||
})
|
||||
};
|
||||
let hot_pair = if is_active {
|
||||
menu_hot_sel.unwrap_or(pair)
|
||||
} else {
|
||||
menu_hot.unwrap_or(pair)
|
||||
};
|
||||
spans.push(Span::styled(" ", Style::default().fg(pair.fg).bg(pair.bg)));
|
||||
let mut chars = menu.title.chars();
|
||||
if let Some(first) = chars.next() {
|
||||
spans.push(Span::styled(
|
||||
first.to_string(),
|
||||
Style::default()
|
||||
.fg(hot_pair.fg)
|
||||
.bg(hot_pair.bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
let rest: String = chars.collect();
|
||||
if !rest.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
rest,
|
||||
Style::default().fg(pair.fg).bg(pair.bg).add_modifier(if is_active {
|
||||
Modifier::BOLD
|
||||
} else {
|
||||
Modifier::empty()
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
spans.push(Span::styled(" ", Style::default().fg(pair.fg).bg(pair.bg)));
|
||||
x_offset += w;
|
||||
}
|
||||
spans.push(Span::styled(
|
||||
" ".repeat(area.width.saturating_sub(x_offset) as usize),
|
||||
if let Some(pair) = menu_default {
|
||||
Style::default().fg(pair.fg).bg(pair.bg)
|
||||
} else {
|
||||
Style::default().bg(theme.title_bg)
|
||||
},
|
||||
));
|
||||
frame.render_widget(Paragraph::new(Line::from(spans)), bar_area);
|
||||
|
||||
if self.dropdown_open {
|
||||
self.render_dropdown(frame, area, theme);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_dropdown(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
||||
let menu = &self.menus[self.active_menu];
|
||||
let items = &menu.items;
|
||||
// Position dropdown under the active title's first character
|
||||
// (left edge of bar at x=1).
|
||||
let mut x = 1u16;
|
||||
for (i, m) in self.menus.iter().enumerate() {
|
||||
if i == self.active_menu {
|
||||
break;
|
||||
}
|
||||
x += (m.title.chars().count() as u16) + 2;
|
||||
}
|
||||
|
||||
let dropdown_w = items
|
||||
.iter()
|
||||
.map(|it| it.label.chars().count() as u16 + 2)
|
||||
.max()
|
||||
.unwrap_or(10)
|
||||
.max(10);
|
||||
let dropdown_h = items.len() as u16 + 2;
|
||||
x = x.min(area.width.saturating_sub(dropdown_w));
|
||||
|
||||
let y = 1u16;
|
||||
let dropdown_area = Rect::new(x, y, dropdown_w, dropdown_h);
|
||||
|
||||
frame.render_widget(Clear, dropdown_area);
|
||||
|
||||
let block = ratatui::widgets::Block::default()
|
||||
.borders(ratatui::widgets::Borders::ALL)
|
||||
.border_style(
|
||||
Style::default().fg(theme.title_fg).bg(
|
||||
mc_skin::color_pair(theme.name, "menu", "_default_")
|
||||
.map(|p| p.bg)
|
||||
.unwrap_or(theme.background),
|
||||
),
|
||||
)
|
||||
.title(Span::styled(
|
||||
format!(" {} ", menu.title),
|
||||
Style::default()
|
||||
.fg(theme.title_fg)
|
||||
.bg(theme.title_bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
let inner = block.inner(dropdown_area);
|
||||
frame.render_widget(block, dropdown_area);
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(items.iter().map(|_| Constraint::Length(1)).collect::<Vec<_>>())
|
||||
.split(inner);
|
||||
|
||||
for (idx, item) in items.iter().enumerate() {
|
||||
let is_sel = idx == self.selected_item;
|
||||
let style = if item.is_separator() {
|
||||
Style::default().fg(theme.hidden).bg(theme.background)
|
||||
} else if is_sel {
|
||||
Style::default()
|
||||
.fg(theme.cursor_fg)
|
||||
.bg(theme.cursor_bg)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.foreground).bg(theme.background)
|
||||
};
|
||||
let line = Line::from(Span::styled(item.label, style));
|
||||
frame.render_widget(Paragraph::new(line), chunks[idx]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn mb() -> EditorMenuBar {
|
||||
EditorMenuBar::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_has_six_menus() {
|
||||
let m = mb();
|
||||
assert_eq!(m.menu_count(), 6);
|
||||
assert_eq!(m.menu_titles(), vec!["File", "Edit", "Search", "Bookmark", "Goto", "Options"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_closes() {
|
||||
let mut m = mb();
|
||||
assert_eq!(m.handle_key(Key::ESCAPE), EditorMenuOutcome::Close);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f9_closes() {
|
||||
let mut m = mb();
|
||||
assert_eq!(m.handle_key(Key::f(9)), EditorMenuOutcome::Close);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_advances_active_menu_wrapping() {
|
||||
let mut m = mb();
|
||||
assert_eq!(m.active_menu(), 0);
|
||||
m.handle_key(Key {
|
||||
code: 0x2192,
|
||||
mods: crate::key::Modifiers::empty(),
|
||||
});
|
||||
assert_eq!(m.active_menu(), 1);
|
||||
for _ in 0..5 {
|
||||
m.handle_key(Key {
|
||||
code: 0x2192,
|
||||
mods: crate::key::Modifiers::empty(),
|
||||
});
|
||||
}
|
||||
assert_eq!(m.active_menu(), 0); // wrapped
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_decrements_active_menu_wrapping() {
|
||||
let mut m = mb();
|
||||
m.handle_key(Key {
|
||||
code: 0x2190,
|
||||
mods: crate::key::Modifiers::empty(),
|
||||
});
|
||||
assert_eq!(m.active_menu(), m.menu_count() - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn down_skips_separators_and_dispatches_first_real() {
|
||||
let mut m = mb();
|
||||
// File menu starts with "New" — no leading separator.
|
||||
assert_eq!(m.active_items()[0].0, "New");
|
||||
m.handle_key(Key {
|
||||
code: 0x2193,
|
||||
mods: crate::key::Modifiers::empty(),
|
||||
});
|
||||
assert_eq!(m.active_items()[m.selected_item()].0, "Open...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_dispatches_active_item() {
|
||||
let mut m = mb();
|
||||
m.set_active_menu(0);
|
||||
m.handle_key(Key::ENTER);
|
||||
// First item is "New" → EditorCmd::New.
|
||||
// We can only check the outcome variant here, not the value,
|
||||
// because handle_key consumed by value.
|
||||
// Use a fresh bar:
|
||||
let mut m = mb();
|
||||
m.handle_key(Key::ENTER);
|
||||
// Can't inspect via Drop, but we can dispatch a known item:
|
||||
m.set_active_menu(0); // File
|
||||
m.handle_key(Key::ENTER); // should dispatch New
|
||||
// Already dispatched above — use a new bar for assertion:
|
||||
let mut m = mb();
|
||||
// Skip past "New" via Down, then Enter — should dispatch "Open..."
|
||||
m.handle_key(Key {
|
||||
code: 0x2193,
|
||||
mods: crate::key::Modifiers::empty(),
|
||||
});
|
||||
let outcome = m.handle_key(Key::ENTER);
|
||||
match outcome {
|
||||
EditorMenuOutcome::Dispatch(EditorCmd::Open) => {}
|
||||
other => panic!("expected Dispatch(Open), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_smoke() {
|
||||
let mut m = mb();
|
||||
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
|
||||
terminal.draw(|f| {
|
||||
m.render(f, f.size(), &Theme::default());
|
||||
}).unwrap();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user