From 50fb67f718966890c189b2a615cb6a08787c9038 Mon Sep 17 00:00:00 2001 From: YassineYousfi Date: Tue, 1 Sep 2026 23:12:28 -0700 Subject: [PATCH 001/122] bump tg + TC_MIN_GLOBALS (#38751) bump op + TC min globals --- openpilot/selfdrive/modeld/SConscript | 2 +- tinygrad_repo | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index 5a99aa890c..af10467529 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -42,7 +42,7 @@ tg_devices = { # which device to put jit inputs to at runtime CHESTNUT = chestnut_present() if CHESTNUT: - chestnut_tg_flags = 'DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_OCCUPANCY_OPT=1' + chestnut_tg_flags = 'DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32' # the USB+AMD GPU takes an exclusive flock; serialize all targets that touch it chestnut_lock = File("models/.chestnut.lock").abspath diff --git a/tinygrad_repo b/tinygrad_repo index b87159cee1..f6fc4e3f2c 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit b87159cee1b137c327f901a6aef69f394aa629f6 +Subproject commit f6fc4e3f2c3db5fae1e19cbfbc3ad9fc579a12ae From 06d76bdb24b9f5f3e69120782e8e3eb54a2f4f7e Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:06:41 -0700 Subject: [PATCH 002/122] cabana: imgui port (#38725) * cabana: imgui frontend base infra (ui/) * cabana: line-for-line imgui ports of the Qt widgets and dialogs into ui/ * cabana ui: fixes from the side by side parity test * cabana ui: deqt.md status after the line-for-line ports * cabana ui: fixes from the line-for-line review * cabana ui: parity fixes from the end to end matrix, event driven frame pacing * cabana ui: preserve F1 help formatting * cabana ui: parity fixes from the end to end matrix, event driven frame pacing * cabana ui: deqt.md status * cabana: fix macOS build of imgui frontend - link Security.framework (libusb's darwin backend needs SecTaskCreateFromSelf/SecTaskCopyValueForEntitlement) - drop unused loop counter in SignalView::updateChartState - avoid -Wshadow on Sparkline::size and SignalView::highlight Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: parity round 3 fixes, thread pool and one malloc arena for flat memory * cabana ui: toolbar overflow budget, menu button carets, help overlay colors, signal editor validation * cabana ui: deqt.md status * cabana: let vsync pace the frame loop The loop threw away frames with glfwWaitEventsTimeout() to hit a 30fps (10fps idle) target while glfwSwapBuffers() was already blocking on vsync, so two clocks beat against each other. Input events also reset frameInterval() to 0 for a few frames, so a click swung the cadence from ~33ms to ~13ms and back -- the camera view redraws on the UI's schedule, so that showed up as a visible hitch on every message selection. Poll and draw every iteration and let glfwSwapInterval(1) do the pacing. Co-Authored-By: Claude Opus 5 (1M context) * cabana: fix macOS build of thread pool / malloc arena changes - malloc.h and mallopt(M_ARENA_MAX) are glibc only; guard on __GLIBC__. macOS has a single allocator zone, so there is nothing to cap. - capturing structured bindings in a lambda is a C++20 extension and clang rejects it under -Werror; bind the event range to plain locals. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: chart axis label precision, signal list click on empty space clears the selection * cabana ui: codespell * cabana ui: taller signal rows, system theme detection, full dark palette - signal rows are 1.5x a frame tall with the sparkline sized to the row, so the graphs are readable. The expanded sub-rows keep the Qt height. - "Automatic" resolved to the light theme regardless of the system: Qt gets this from QStyle::standardPalette(), which follows the macOS appearance, so read AppleInterfaceStyle and match it. Other platforms stay light, like standardPalette() does there. - widgets that tested settings.theme == DARK_THEME themselves went light under AUTO_THEME while the style went dark; they now ask isDarkTheme() for the theme applyTheme() actually resolved. - the dark branch only set 10 ImGuiCol_ entries, so buttons, scrollbars, tabs, title bars, separators, tables and grips still came from imgui's stock dark theme. Derive the whole palette from DarkTheme instead. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: drop the dead y axis alignment chain and chart cache stub ImPlot::BeginAlignedPlots aligns the chart y axes, so the hand-rolled alignment protocol ported from Qt no longer feeds anything: align_to was write-only. Remove the whole chain (y_label_width and its measuring loop in draw(), axisYLabelWidthChanged, align_timer, alignCharts), which reduces updatePlotArea(int, bool) to updatePlotArea(). resetChartCache() has been an empty stub since the port (imgui redraws every frame); drop it and its four call sites. Also replace the empty move_icon_rect branch in mousePressEvent with an early return. No behavior change. updateAxisY's "|| y_label_width == 0" guard only fired after a unit change, and y_precision/y_tick_count depend solely on min_y/max_y/tick_count, so the recompute it triggered was a no-op. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: stop the main window from scrolling The host window that holds the dockspace uses zero WindowPadding but the default ItemSpacing, so the dockspace and the status bar below it summed to ItemSpacing.y (5px) more than the viewport, leaving the whole UI scrollable by a few pixels. Reserve the spacing along with the status bar height, and mark the host window NoScrollbar/NoScrollWithMouse: it is pinned to the viewport work area and should never scroll, as QMainWindow does not. Full screen is unaffected, it draws no status bar and reserves no height. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: inset the status bar so its ends are not clipped A borderless BeginChild gets WindowPadding forced to zero, so the status bar text sat flush against both edges and the first glyph of "For Help, Press F1" was cut off. Inset both ends by WindowPadding.x, which lines the text up with the content of the docked panels above it. Right align the cached minutes / FPS label to the captured child width rather than calling GetContentRegionAvail() again after the message text, which only returned the full width because the text had already wrapped the cursor to a new line. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: drop the leftover imgui object census log The block printed an imgui/implot object count to stderr every 10s, which was instrumentation for tracking down a leak, not something the port needs. It was the only user of implot_internal.h in mainwin.cc, so drop that include with it. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: keep the selected row highlighted while hovered Selectable() picks its background as HeaderActive when held, else HeaderHovered when hovered, else Header. The hovered test comes before the selected one, so a selected row under the cursor takes the hover color. Both tables push a transparent HeaderHovered to suppress the hover highlight that QTreeView/QTableView do not have, which also blanked the selection: clicking a row walked press (HeaderActive, visible), release (HeaderHovered, transparent, looks unselected), then mouse off the row (Header, visible), so the selection appeared to flicker until the cursor moved away. Only make HeaderHovered transparent for rows that are not selected. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: draw the binary view hex column in bold and in the text color The Qt delegate never sets a pen for the hex column, so it inherited the black QPainter default and the two halves of a row did not match. Use paletteText(is_message_active), the same color the bit columns use, so both halves read as one row and dim together when the message goes inactive. JetBrains Mono ships no bold variant and imgui has no embolden option, so give drawStaticText a bold flag that redraws the glyphs a fraction of a pixel to the right. That keeps the monospace advance, which switching to the proportional bold face would not. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: highlight the whole row in the speed menu MenuItem spans from the cursor to the right edge, so the Indent() that made room for the radio bullet also pushed the highlight in by one FontSize and left the bullet column outside it. QMenu highlights the whole item, indicator included. Draw each row as one full width Selectable with the bullet and the label inside it. Every row declares the same width, so the popup is as wide as the widest entry and the highlights reach both edges; a label-less Selectable sized zero would declare no layout width and collapse the auto-sizing popup. Selectable auto-closes its parent popup like MenuItem does, so clicking still works in the dropdown and in the Speed submenu of the toolbar overflow menu, which shares this function. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: fix help menu overlay, Ctrl+F11, time label clipping, toolbar overflow and tool table parity * cabana ui: click to edit signal cells, light theme selection, logs cell selection and filter fixes * cabana ui: chart x label margin, zoom context menu items, video splitter collapse, signal editor close * cabana ui: signal editor lifetime and close, logs cell hover, tool table headers * cabana ui: branch indicator clicks, editor swallows shortcut and button clicks, selection colors * cabana ui: float dock windows into real OS windows Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: toolbar menu arrow spacing, only open the editor on editable cells Co-Authored-By: Claude Opus 5 (1M context) * op: add cabana2 to run the imgui cabana Co-Authored-By: Claude Opus 5 (1M context) * cabana: cabana2 launcher script builds _cabana_ui before running it Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: only float the dock panels into their own OS windows Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: open the video pane at the camera's natural aspect ratio Co-Authored-By: Claude Opus 5 (1M context) * cabana: default fps to 30 * cabana ui: fusion palette, shared fusion slider, chart grid colors * cabana ui: smaller checkbox indicator, closer to the Fusion size * cabana ui: video toolbar height and item centering, separator extent and menu arrow like Fusion * cabana ui: message and signal row heights match Qt * cabana ui: timeline slider height, groove and stacking match the Qt video layout * cabana ui: fix crash opening a live stream, the video pane default height read the missing camera widget * cabana: drop the FPS setting, the streams update the UI at a fixed 30 fps * cabana ui: video and charts pane run edge to edge with a uniform toolbar margin like the Qt frames * cabana ui: binary view M/L marker size and timeline event fills match Qt * cabana ui: signal view keeps its sparklines while filtering, number label size matches Qt * cabana ui: no modal dim fade, QDialog does not animate in * cabana ui: collapse all is an auto-raise tool button with the Qt icon size * cabana ui: dialogs and tool windows open as real OS windows like QDialog * cabana ui: hold back a focus loss while a mouse button is down so tearing a panel off does not abort the drag and create its window twice * cabana ui: speed menu rows pad the label on the right like QMenu * cabana ui: raise the dark theme contrast, the Darcula grays all sat on top of the background the chart y axis guides are opaque and 2 px in dark mode, and text, outlines, separators, table borders, scrollbar and slider grabs move away from the window and base grays. light theme is unchanged. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: simplify comments, drop the Qt references and the ones that only restate the code * cabana ui: keep the sparkline antialiased, thin peaks sparkled as the window slid Qt dropped antialiasing above 500 points because it rasterized into a pixmap. drawing straight into the frame, an aliased 1 px segment between two columns rounds into one of them and the rounding flips as the window advances, so the peaks of the dense signals popped between columns. updateState runs at STREAM_UPDATE_FPS, which at a 30s range moves the curve well under a pixel per update, so the flip is all that is visible. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: anchor the sparkline to the time window, it jittered sideways as samples expired x was measured from the first sample still inside the window, which changes whenever the oldest one falls out, so every update slid the whole curve sideways by the sample spacing. measuring from the start of the window pins the right edge and lets the samples flow left with the clock. the density heuristic wanted the data span, which is no longer the x of the last point, and the flat line started at x 0 instead of at its first sample. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: scroll the sparkline at the frame rate, it stepped at the message rate the window ended at the last message of the id and updateState only runs when one arrives, so a slow message held the sparkline still for several frames and then jumped it. the window now ends at the playback clock, and draw() slides the rendered polyline by the time that has passed since it was built, clipped to the cell. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: keep the signal value on the right of the column before there is a sparkline it was drawn left aligned from the start of the column, so the value sat next to the name until the first samples arrived and then jumped across the row. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: read a little past the sparkline window so the oldest points slide out of it a sample used to disappear the moment it aged out, which on a fixed rate signal is a point popping off the left edge. the query reaches back a bit further and the clip rect hides it instead. the lead in must not move the scale, so it is left out of the min and max. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: use the macOS fullscreen space, taking over the monitor rendered at the old size glfwSetWindowMonitor switched the display mode and returned a drawable at that mode, so the ui was rendered at the windowed size and stretched over the screen. NSWindow toggleFullScreen keeps the backing scale. the green button goes through the same space, so the state is read back from the style mask rather than tracked. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: let the signal value column shrink again, it held the widest value ever seen max_value_width only ever grew, so one long value left the sparklines ending well short of the text for the rest of the message. Track the current widest value instead, growing on demand and giving room back once it is a couple of characters too wide, which keeps the width from jittering per sample. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: make the signal rows a quarter taller so the sparklines read better Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: lift the binary view heatmap alphas in dark mode, the tints washed out The alphas were tuned against a white background; over the dark base the same values left the cells barely colored. The floor comes up and the ramp gets a gamma in dark mode only, which keeps the flip counts in the same order. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: saturate the sparkline color in light mode, the pastel lines vanished Signal colors are low saturation and high value, which is aimed at dark fills; a 1 px line of one of them on white is hard to see. Draw the sparkline with twice the saturation and less value when the theme is light. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: give the docked panels a minimum width, the dividers ran over content Co-Authored-By: Claude Opus 5 (1M context) * replay: capture the downloader's stderr so the progress lines reach the handler file_downloader.py writes PROGRESS:: to stderr while it streams a file, but runPython() only piped stdout and left stderr attached to the parent, so the progress handler only ever fired on failure and cabana downloaded a segment with no progress bar. Pipe stderr as well, turn the PROGRESS lines into handler calls, and pass everything else through to the parent's stderr. Co-Authored-By: Claude Opus 5 (1M context) * replay: read the downloader's stderr on a thread, the two fd select was overkill getline() handles the line splitting, partial lines and EOF, so the manual splitter, the second drain loop and the multiplexing bookkeeping go away and the stdout loop goes back to what it was. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: one toolButton helper in imgui_util.h * cabana ui: one toImU32/toImVec4/paletteBrightText in imgui_util.h * cabana ui: string helpers live in utils/strings.h * cabana ui: share the fusion slider handle from imgui_util.h * cabana ui: style.cc declarations move to imgui_util.h * cabana ui: validatedInput and the qt validators move to imgui_util.h * cabana ui: viewSelectable helper for the item view rows * cabana ui: drop the comboBox pass-through * cabana ui: tabbar moves to its own file, ready for the charts port * cabana ui: find signal searches on the main thread again, deferred one frame like QTimer::singleShot(0) * cabana ui: find signal computes its button and label state from the model instead of caching it * cabana ui: share the queued popup owner protocol between the message box and the file dialog * cabana ui: one beginDialog helper for the three centered modals, with the reopen recovery * cabana ui: tool dialogs share the title, begin/end and escape handling, and own their connections * cabana ui: comboBox helper replaces the hand rolled zero separated item buffers * cabana ui: drop the unused dialog isOpen accessors * cabana ui: one validatedText helper for the ip, double and int line edits * cabana ui: route info reads the route segments instead of caching a row copy * cabana ui: settings drops the phantom fps label, the constant spin flags and the raw log path buffer * cabana ui: find similar bits drops the no-op find button disable and the address round trip * cabana ui: message box warning overload without the empty detailed text * cabana ui: help overlay only collects rects while it is up, skips torn off panels, and the fingerprint compare is ordered * cabana ui: drop the glfw callbacks that only forward to the imgui backend, which already installs them * cabana ui: elided label renders with RenderTextEllipsis instead of a paint time cache * cabana ui: one save loop, inline cached minutes label, and drop the dead help parser branches * cabana ui: signal cell bands are built from the two corner notches instead of a region sweep * cabana ui: binary view items go through itemAt and updateItem assigns unconditionally * cabana ui: message bytes read the cell padding inline and colored bytes keep the qt default pen * cabana ui: detail widget drops createToolBar, the welcome widget flag and the unused whatsThis * cabana ui: messages widget owns its view, header and model directly, fixed size filter editors, from_chars range parsing * cabana ui: colored hex bytes keep the row pen, the qt delegate does not reset it per cell * cabana ui: closeEditor drops the queued commit, its item is about to be deleted * cabana ui: rowsInserted carries the insert position so the selected row follows its message * cabana ui: the log filter and the message name editors use the shared validators * cabana ui: the log header sizing and painting are free functions, no HeaderView object * cabana ui: the visible row range is the change detector, no mirrored scroll position * cabana ui: the line editor takes the focus flag as an argument and lets InputText revert on escape * cabana ui: signal view drops the unreachable column parameters, unused item values and repeated sparkline tests * cabana ui: rows outside the tree viewport are measured but not painted * cabana ui: one toolbar item table, playback state read in draw() * cabana ui: draw the stored thumbnail scaled by AddImage, no cpu prescaled copy * cabana ui: one radioMenuItem helper for the speed and series type menus * cabana ui: drop dead camera members, hoist the jpeg colour space branch, decode qlog thumbnails on the thread pool * cabana ui: stop the vipc thread when the splitter collapses the video pane * cabana ui: the toolbar strings and enabled state are computed in drawToolBar * cabana ui: chartswidget uses the shared tabbar widget * cabana ui: the chart header layout is recomputed in resizeEvent, no updateTitle * cabana ui: updateLayout has no force flag * cabana ui: one condition hides the value tip * cabana ui: signal selector uses the shared dialog buttons and row indexes * cabana ui: the chart tile geometry is one Layout struct saved and restored by drawGhost * cabana ui: drop the empty theme branch, the unused size hint and the charts container indirections * cabana ui: the chart holds its tip label by value * cabana ui: one toolbar item list for both toolbar groups * cabana ui: the sparkline polyline is emitted through the draw list path * cabana ui: the tab bar latches a programmatic selection and defers the close request past EndTabBar * cabana ui: validatedText refuses an invalid edit inside the imgui buffer and the ip field keeps its char filter * cabana ui: drop the ambiguous three argument warning overload * cabana ui: combobox reports a real change, menu rows span the popup, one popup protocol for the dialogs * cabana ui: the startup stream is owned by the window until the first frame opens it * cabana ui: the slider toolbar index has an out of range sentinel * cabana ui: the find signal search runs on the frame after the pending state is painted * cabana ui: the log clears its selection when the rows are dropped and cells are identified by their message * cabana ui: the open editor survives a scroll, collapse all commits it and escape in the size editor does not * cabana ui: the header display order is filled with iota * cabana ui: qlog thumbnails decode on their own threads, the hover thumbnail is mipmapped and the time tooltip appears on the first toggle * cabana ui: a collapsed video dock stops the vipc thread * cabana ui: one TOOLBAR_ITEM_SPACING, the signal view used the QCommonStyle 4 instead of Fusion's 1 * cabana ui: sparkline includes imgui_util.h for isDarkTheme * cabana ui: imgui_util.h becomes util.h and util.cc * cabana: delete deqt.md * cabana ui: panel min width, matching video panel padding, centered tabs The dock panels stop shrinking at the width where the signal view tool bar squishes instead of at a hardcoded 440. The video/charts panel keeps the standard window padding so it lines up with the other panels, and the Msg/Logs tabs are centered in their bar. The cached minutes label is gone from the status bar. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: persist imgui state in cabana.json and migrate the Qt frontend's layout blobs * cabana ui: 2px sparkline stroke a 1px antialiased line is all fringe and no solid core, so the curve faded and shimmered as the window scrolled. Co-Authored-By: Claude Opus 5 (1M context) * cabana: drop the Automatic color theme it only did anything on macOS: the Qt frontend fell through to standardPalette() for both Automatic and Light, so the two were identical everywhere else. LIGHT_THEME and DARK_THEME keep their values so a persisted dark theme still reads as dark; the old Automatic 0 falls back to light on load. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: window color between the binary and signal views, fixed split the binary view sits at its size hint and no longer resizes, and the gap below it takes the window color instead of leaving a white seam. also border the video and chart panels like the signal view. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: bigger tab bar scroll buttons imgui's built in scroll arrows are fixed at FontSize - 2 wide with the glyph drawn in a FontSize box, so they are small and sit off center. draw chevron buttons at the frame height instead, spaced and disabled at the ends of the scroll range like the rest of the tool buttons. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: 1.5px sparkline stroke * cabana ui: stable sparkline scrolling, one sample per column, aliased spikes Scroll the polyline by whole physical pixels with each sample's subpixel phase fixed to its timestamp, thin to one sample per pixel column, and draw near-vertical segments aliased so spikes stay crisp while smooth curves keep antialiasing. Co-Authored-By: Claude Fable 5.1 * cabana ui: fix spelling Co-Authored-By: Claude Fable 5.1 * cabana ui: size the value column for the widest value a signal can produce Sizing it to the values in the last message moved the sparklines every time a value changed length. Co-Authored-By: Claude Fable 5.1 * cabana ui: center the tab bar scroll chevrons and give the buttons a frame The icon font glyph sits off center in its padded advance, so the chevron is drawn in the button rect. The buttons take the theme's button background and border like the tool buttons next to them. Co-Authored-By: Claude Fable 5.1 * cabana ui: darker grid lines between table cells Co-Authored-By: Claude Fable 5.1 * cabana ui: the gap between the video and the charts matches the side padding Co-Authored-By: Claude Fable 5.1 * cabana ui: plus-square icon for the new chart button, like the remove all button Co-Authored-By: Claude Fable 5.1 * cabana ui: video toolbar buttons as tall as the charts toolbar buttons The buttons carried 10 px of vertical padding, so the hover rect was much taller than the glyph and the row looked off center. Co-Authored-By: Claude Fable 5.1 * cabana ui: the time display in the mono font at the ui font size With proportional digits the time changed width as it ticked and the items after it moved. Co-Authored-By: Claude Fable 5.1 * cabana ui: closing a floated side panel docks it back, no hide tab bar button, the charts container never scrolls Co-Authored-By: Claude Fable 5.1 * cabana ui: the message tabs use the TabBar widget, chevron scroll buttons for both tab bars The chevron scroll buttons move into a scrollable tab bar widget that both use. The wheel scrolls the tabs, both tab bars get Close Other Tabs, and the Msg tab is labeled Messages. Co-Authored-By: Claude Fable 5.1 * cabana ui: deliver a focus loss right away on macOS, holding it back swallowed the first click in a popup Co-Authored-By: Claude Fable 5.1 * cabana ui: draw the remote routes dialog at the modal's level, opened inside the tab's child window it never appeared Co-Authored-By: Claude Fable 5 * cabana ui: --no-cache turns off the local route file cache, same as replay Co-Authored-By: Claude Fable 5 * cabana: parse merged segments on replay's merge thread, the parse on the main thread dropped frames on every merge while downloading Co-Authored-By: Claude Fable 5 * cabana ui: Esc leaves full screen on all platforms Co-Authored-By: Claude Fable 5 * cabana ui: an app bundle on macOS so the menu bar and the dock show Cabana A bare binary shows its file name there. The build wraps _cabana_ui in a minimal Cabana.app with an Info.plist and the launcher runs that. Co-Authored-By: Claude Fable 5.1 * Revert "cabana ui: an app bundle on macOS so the menu bar and the dock show Cabana" This reverts commit 1a45540125847ca46fcd4d7ac0522918e315057d. * cabana ui: the app menu on macOS says Cabana A bare binary gets an info dictionary with its file name in it, which glfw reads for the app menu. The name is set there before glfw brings up cocoa. Co-Authored-By: Claude Fable 5.1 * cabana ui: macapp moves into ui/util Co-Authored-By: Claude Fable 5.1 * cabana ui: setMacAppName lives in ui/util.cc Co-Authored-By: Claude Fable 5.1 * cabana ui: apply the code review Bugs: an empty decode batch dereferenced vals.front(), the chart x comparator took a float, reserve used capacity(), the bit text went black while resizing a signal, byte cells under-sized non-multiple-of-8 payloads, the CAN speed lookup read the data speed table, the export tooltip never showed while disabled, and the thumbnail parser and the signal search joined ad-hoc threads on the render thread. Both now run off the pool; the search fans out over it from its own thread. Shared helpers in ui/util: the tool bar with its overflow menu, the menu button, modal dialog setup, Escape inside dialogButtons, drawText, drawElidedText, color markers, alignRight, clearableInput, disabledItemTooltip, inputTextMultiline, tableHeadersRow with the right click, withAlpha, boldFont, the Cocoa full screen glue, the messages panel id. utils gets hexByte, guarded and nonEmptyDBCFiles; threadpool gets parallelFor. The help overlay leaves mainwin.cc for its own file, the stream teardown is one releaseStream(), the charts own their views through unique_ptr, the find-similar-bits message list follows the source bus, the clipboard copies every open DBC, and the Qt vestiges (event names, model API, protected sections, restating comments, stale TODOs, mixed member naming) are gone. Co-Authored-By: Claude Fable 5.1 * cabana ui: collapse the model/view/delegate split The Qt-era model, view and delegate classes were ported one to one, but imgui draws a table in one function. MessageView, MessageViewHeader and MessageListModel's string API fold into MessagesWidget over a plain MessageList; HistoryLogModel folds into LogsWidget; BinaryViewModel and BinaryItemDelegate fold into BinaryView; SignalItemDelegate folds into SignalView, and SignalModel keeps only the item tree with three predicates instead of the flags bitmask; FindSignalModel becomes SignalSearch. The byte cell painter is a set of free functions shared by the two tables. Co-Authored-By: Claude Fable 5.1 * cabana ui: load the route behind the window The route file listing is an HTTPS round trip to the comma API and took the whole first second of startup before the window existed. The replay branch of main() now hands run() a loader that a worker executes after the first frame; the window maps in ~0.1 s instead of ~1 s. On a failed load the app stays open in the No Stream state with the error box instead of exiting before a window ever appeared. * cabana ui: fix the live stream video controls layout * cabana ui: only copy dbc to clipboard with a single file * cabana ui: report route loading failures instead of hanging * cabana: launch the imgui frontend by default, --legacy for Qt --------- Co-authored-by: Trey Moen Co-authored-by: Claude Opus 5 (1M context) --- openpilot/tools/cabana/.gitignore | 1 + openpilot/tools/cabana/SConscript | 79 +- openpilot/tools/cabana/cabana | 29 +- openpilot/tools/cabana/core/color.h | 1 - openpilot/tools/cabana/core/settings.h | 4 +- openpilot/tools/cabana/dbc/dbcmanager.cc | 11 +- openpilot/tools/cabana/dbc/dbcmanager.h | 1 + openpilot/tools/cabana/deqt.md | 53 - openpilot/tools/cabana/mainwin.cc | 2 +- openpilot/tools/cabana/messageswidget.cc | 2 +- openpilot/tools/cabana/settings.cc | 5 +- openpilot/tools/cabana/settings.h | 4 + openpilot/tools/cabana/settingsdialog.cc | 12 +- openpilot/tools/cabana/settingsdialog.h | 1 - .../tools/cabana/streams/abstractstream.cc | 6 +- .../tools/cabana/streams/abstractstream.h | 1 + openpilot/tools/cabana/streams/livestream.cc | 4 +- openpilot/tools/cabana/streams/livestream.h | 1 - .../tools/cabana/streams/replaystream.cc | 13 +- openpilot/tools/cabana/tests/test_cabana.cc | 55 + .../tools/cabana/tests/test_cabana_ui.py | 13 + openpilot/tools/cabana/ui/app.cc | 212 ++++ openpilot/tools/cabana/ui/app.h | 23 + openpilot/tools/cabana/ui/chart/chart.cc | 790 ++++++++++++++ openpilot/tools/cabana/ui/chart/chart.h | 142 +++ .../tools/cabana/ui/chart/chartswidget.cc | 674 ++++++++++++ .../tools/cabana/ui/chart/chartswidget.h | 168 +++ .../tools/cabana/ui/chart/signalselector.cc | 155 +++ .../tools/cabana/ui/chart/signalselector.h | 46 + openpilot/tools/cabana/ui/chart/sparkline.cc | 177 ++++ openpilot/tools/cabana/ui/chart/sparkline.h | 34 + openpilot/tools/cabana/ui/chart/tiplabel.cc | 69 ++ openpilot/tools/cabana/ui/chart/tiplabel.h | 35 + .../tools/cabana/ui/dialogs/filedialog.cc | 222 ++++ .../tools/cabana/ui/dialogs/filedialog.h | 19 + .../tools/cabana/ui/dialogs/messagebox.cc | 87 ++ .../tools/cabana/ui/dialogs/messagebox.h | 19 + .../tools/cabana/ui/dialogs/routesdialog.cc | 124 +++ .../tools/cabana/ui/dialogs/routesdialog.h | 45 + .../tools/cabana/ui/dialogs/settingsdialog.cc | 108 ++ .../tools/cabana/ui/dialogs/settingsdialog.h | 24 + .../tools/cabana/ui/dialogs/streamselector.cc | 322 ++++++ .../tools/cabana/ui/dialogs/streamselector.h | 106 ++ openpilot/tools/cabana/ui/helpoverlay.cc | 196 ++++ openpilot/tools/cabana/ui/helpoverlay.h | 24 + openpilot/tools/cabana/ui/icons.h | 40 + openpilot/tools/cabana/ui/inistate.cc | 138 +++ openpilot/tools/cabana/ui/inistate.h | 27 + openpilot/tools/cabana/ui/main.cc | 202 ++++ openpilot/tools/cabana/ui/mainwin.cc | 929 +++++++++++++++++ openpilot/tools/cabana/ui/mainwin.h | 138 +++ openpilot/tools/cabana/ui/qtstate.cc | 171 +++ openpilot/tools/cabana/ui/qtstate.h | 38 + openpilot/tools/cabana/ui/style.cc | 280 +++++ openpilot/tools/cabana/ui/threadpool.h | 81 ++ openpilot/tools/cabana/ui/tools/findsignal.cc | 325 ++++++ openpilot/tools/cabana/ui/tools/findsignal.h | 59 ++ .../tools/cabana/ui/tools/findsimilarbits.cc | 178 ++++ .../tools/cabana/ui/tools/findsimilarbits.h | 40 + openpilot/tools/cabana/ui/tools/routeinfo.cc | 50 + openpilot/tools/cabana/ui/tools/routeinfo.h | 14 + openpilot/tools/cabana/ui/tools/tooldialog.h | 52 + openpilot/tools/cabana/ui/util.cc | 466 +++++++++ openpilot/tools/cabana/ui/util.h | 201 ++++ .../tools/cabana/ui/widgets/binaryview.cc | 548 ++++++++++ .../tools/cabana/ui/widgets/binaryview.h | 98 ++ .../tools/cabana/ui/widgets/cameraview.cc | 176 ++++ .../tools/cabana/ui/widgets/cameraview.h | 88 ++ .../tools/cabana/ui/widgets/detailwidget.cc | 439 ++++++++ .../tools/cabana/ui/widgets/detailwidget.h | 112 ++ .../tools/cabana/ui/widgets/historylog.cc | 307 ++++++ .../tools/cabana/ui/widgets/historylog.h | 59 ++ .../tools/cabana/ui/widgets/messagebytes.cc | 60 ++ .../tools/cabana/ui/widgets/messagebytes.h | 20 + .../tools/cabana/ui/widgets/messageswidget.cc | 512 +++++++++ .../tools/cabana/ui/widgets/messageswidget.h | 93 ++ .../cabana/ui/widgets/scrollabletabbar.cc | 88 ++ .../cabana/ui/widgets/scrollabletabbar.h | 8 + .../tools/cabana/ui/widgets/signalview.cc | 975 ++++++++++++++++++ .../tools/cabana/ui/widgets/signalview.h | 201 ++++ openpilot/tools/cabana/ui/widgets/tabbar.cc | 92 ++ openpilot/tools/cabana/ui/widgets/tabbar.h | 45 + .../tools/cabana/ui/widgets/videowidget.cc | 612 +++++++++++ .../tools/cabana/ui/widgets/videowidget.h | 121 +++ openpilot/tools/cabana/utils/strings.h | 76 ++ openpilot/tools/cabana/utils/util.h | 9 + tools/op.sh | 2 +- 87 files changed, 12176 insertions(+), 113 deletions(-) delete mode 100644 openpilot/tools/cabana/deqt.md create mode 100644 openpilot/tools/cabana/tests/test_cabana_ui.py create mode 100644 openpilot/tools/cabana/ui/app.cc create mode 100644 openpilot/tools/cabana/ui/app.h create mode 100644 openpilot/tools/cabana/ui/chart/chart.cc create mode 100644 openpilot/tools/cabana/ui/chart/chart.h create mode 100644 openpilot/tools/cabana/ui/chart/chartswidget.cc create mode 100644 openpilot/tools/cabana/ui/chart/chartswidget.h create mode 100644 openpilot/tools/cabana/ui/chart/signalselector.cc create mode 100644 openpilot/tools/cabana/ui/chart/signalselector.h create mode 100644 openpilot/tools/cabana/ui/chart/sparkline.cc create mode 100644 openpilot/tools/cabana/ui/chart/sparkline.h create mode 100644 openpilot/tools/cabana/ui/chart/tiplabel.cc create mode 100644 openpilot/tools/cabana/ui/chart/tiplabel.h create mode 100644 openpilot/tools/cabana/ui/dialogs/filedialog.cc create mode 100644 openpilot/tools/cabana/ui/dialogs/filedialog.h create mode 100644 openpilot/tools/cabana/ui/dialogs/messagebox.cc create mode 100644 openpilot/tools/cabana/ui/dialogs/messagebox.h create mode 100644 openpilot/tools/cabana/ui/dialogs/routesdialog.cc create mode 100644 openpilot/tools/cabana/ui/dialogs/routesdialog.h create mode 100644 openpilot/tools/cabana/ui/dialogs/settingsdialog.cc create mode 100644 openpilot/tools/cabana/ui/dialogs/settingsdialog.h create mode 100644 openpilot/tools/cabana/ui/dialogs/streamselector.cc create mode 100644 openpilot/tools/cabana/ui/dialogs/streamselector.h create mode 100644 openpilot/tools/cabana/ui/helpoverlay.cc create mode 100644 openpilot/tools/cabana/ui/helpoverlay.h create mode 100644 openpilot/tools/cabana/ui/icons.h create mode 100644 openpilot/tools/cabana/ui/inistate.cc create mode 100644 openpilot/tools/cabana/ui/inistate.h create mode 100644 openpilot/tools/cabana/ui/main.cc create mode 100644 openpilot/tools/cabana/ui/mainwin.cc create mode 100644 openpilot/tools/cabana/ui/mainwin.h create mode 100644 openpilot/tools/cabana/ui/qtstate.cc create mode 100644 openpilot/tools/cabana/ui/qtstate.h create mode 100644 openpilot/tools/cabana/ui/style.cc create mode 100644 openpilot/tools/cabana/ui/threadpool.h create mode 100644 openpilot/tools/cabana/ui/tools/findsignal.cc create mode 100644 openpilot/tools/cabana/ui/tools/findsignal.h create mode 100644 openpilot/tools/cabana/ui/tools/findsimilarbits.cc create mode 100644 openpilot/tools/cabana/ui/tools/findsimilarbits.h create mode 100644 openpilot/tools/cabana/ui/tools/routeinfo.cc create mode 100644 openpilot/tools/cabana/ui/tools/routeinfo.h create mode 100644 openpilot/tools/cabana/ui/tools/tooldialog.h create mode 100644 openpilot/tools/cabana/ui/util.cc create mode 100644 openpilot/tools/cabana/ui/util.h create mode 100644 openpilot/tools/cabana/ui/widgets/binaryview.cc create mode 100644 openpilot/tools/cabana/ui/widgets/binaryview.h create mode 100644 openpilot/tools/cabana/ui/widgets/cameraview.cc create mode 100644 openpilot/tools/cabana/ui/widgets/cameraview.h create mode 100644 openpilot/tools/cabana/ui/widgets/detailwidget.cc create mode 100644 openpilot/tools/cabana/ui/widgets/detailwidget.h create mode 100644 openpilot/tools/cabana/ui/widgets/historylog.cc create mode 100644 openpilot/tools/cabana/ui/widgets/historylog.h create mode 100644 openpilot/tools/cabana/ui/widgets/messagebytes.cc create mode 100644 openpilot/tools/cabana/ui/widgets/messagebytes.h create mode 100644 openpilot/tools/cabana/ui/widgets/messageswidget.cc create mode 100644 openpilot/tools/cabana/ui/widgets/messageswidget.h create mode 100644 openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc create mode 100644 openpilot/tools/cabana/ui/widgets/scrollabletabbar.h create mode 100644 openpilot/tools/cabana/ui/widgets/signalview.cc create mode 100644 openpilot/tools/cabana/ui/widgets/signalview.h create mode 100644 openpilot/tools/cabana/ui/widgets/tabbar.cc create mode 100644 openpilot/tools/cabana/ui/widgets/tabbar.h create mode 100644 openpilot/tools/cabana/ui/widgets/videowidget.cc create mode 100644 openpilot/tools/cabana/ui/widgets/videowidget.h diff --git a/openpilot/tools/cabana/.gitignore b/openpilot/tools/cabana/.gitignore index 7f9ac0fde0..c5c752ba9c 100644 --- a/openpilot/tools/cabana/.gitignore +++ b/openpilot/tools/cabana/.gitignore @@ -6,6 +6,7 @@ assets.cc bootstrap_icons.cc _cabana +_cabana_ui dbc/car_fingerprint_to_dbc.json tests/test_cabana tests/test_dbc_core diff --git a/openpilot/tools/cabana/SConscript b/openpilot/tools/cabana/SConscript index 63b725a759..a324e09d67 100644 --- a/openpilot/tools/cabana/SConscript +++ b/openpilot/tools/cabana/SConscript @@ -3,10 +3,59 @@ import os import shutil import bootstrap_icons +import imgui import libusb +from openpilot.common.basedir import BASEDIR Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal', 'replay_lib', 'ffmpeg_libs') +opendbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % (env.Dir("../../../opendbc_repo/opendbc/dbc").abspath) + +# embed the bootstrap icons SVG into the binary +def build_bootstrap_icons_src(target, source, env): + data = open(str(source[0]), 'rb').read() + with open(str(target[0]), 'w') as f: + f.write('#include \n') + f.write('extern const unsigned char bootstrap_icons_svg[];\n') + f.write('extern const size_t bootstrap_icons_svg_len;\n') + f.write('const unsigned char bootstrap_icons_svg[] = {\n') + for i in range(0, len(data), 32): + f.write(','.join(str(b) for b in data[i:i+32]) + ',\n') + f.write('};\n') + f.write('const size_t bootstrap_icons_svg_len = sizeof(bootstrap_icons_svg);\n') + return None + +bootstrap_icons_src = env.Command('assets/bootstrap_icons.cc', str(bootstrap_icons.SVG_PATH), build_bootstrap_icons_src) + +# Qt-free sources shared by the imgui frontend and the core test. Compiled in the base env, +# so the object names must not collide with the Qt build of the same sources. +core_srcs = ['streams/pandastream.cc', 'streams/devicestream.cc', 'streams/livestream.cc', 'streams/abstractstream.cc', 'streams/replaystream.cc', + 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', 'utils/export.cc', 'utils/util.cc', 'utils/strings.cc', + 'commands.cc', 'settings.cc', 'routes.cc', 'panda.cc'] +if arch != "Darwin": + core_srcs += ['streams/socketcanstream.cc'] + +# imgui frontend (tools/cabana/ui), no Qt +ui_env = env.Clone() +ui_env['CPPPATH'] += [imgui.INCLUDE_DIR, libusb.INCLUDE_DIR] +ui_env['LIBPATH'] += [imgui.MESA_DIR, libusb.LIB_DIR] +ui_env['CXXFLAGS'] += [ + opendbc_path, + "-DGLFW_INCLUDE_NONE", + '-DCABANA_FONTS_DIR=\'"%s"\'' % os.path.join(os.path.realpath(BASEDIR), "openpilot", "selfdrive", "assets", "fonts"), + '-DBOOTSTRAP_ICONS_TTF=\'"%s"\'' % bootstrap_icons.TTF_PATH, +] +ui_objs = [ui_env.Object('ui/obj/' + src.replace('/', '_')[:-3], src) for src in core_srcs] +ui_objs += [ui_env.Object('ui/obj/bootstrap_icons', bootstrap_icons_src)] +ui_objs += ui_env.Glob('ui/*.cc') + ui_env.Glob('ui/widgets/*.cc') + ui_env.Glob('ui/dialogs/*.cc') + ui_env.Glob('ui/chart/*.cc') + ui_env.Glob('ui/tools/*.cc') +ui_libs = [replay_lib, common, messaging, visionipc, cereal, File(f"{imgui.LIB_DIR}/libimgui.a"), File(f"{imgui.LIB_DIR}/libglfw3.a")] + \ + ffmpeg_libs + ['zstd', 'm', 'pthread', 'usb-1.0'] +if arch == "Darwin": + ui_env['FRAMEWORKS'] = ['OpenGL', 'Cocoa', 'IOKit', 'CoreFoundation', 'CoreVideo', 'CoreMedia', 'Security', 'VideoToolbox'] +else: + ui_libs += ['GL', 'dl'] +cabana_ui = ui_env.Program('_cabana_ui', ui_objs, LIBS=ui_libs) + # Detect Qt - skip build if not available if arch == "Darwin": try: @@ -74,38 +123,18 @@ cabana_env['CPPPATH'] += [libusb.INCLUDE_DIR] cabana_env['LIBPATH'] += [libusb.LIB_DIR] cabana_libs = [cereal, messaging, visionipc, replay_lib] + ffmpeg_libs + ['usb-1.0'] + base_libs -opendbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % (cabana_env.Dir("../../../opendbc_repo/opendbc/dbc").abspath) cabana_env['CXXFLAGS'] += [opendbc_path] -# embed the bootstrap icons SVG into the binary -def build_bootstrap_icons_src(target, source, env): - data = open(str(source[0]), 'rb').read() - with open(str(target[0]), 'w') as f: - f.write('#include \n') - f.write('extern const unsigned char bootstrap_icons_svg[];\n') - f.write('extern const size_t bootstrap_icons_svg_len;\n') - f.write('const unsigned char bootstrap_icons_svg[] = {\n') - for i in range(0, len(data), 32): - f.write(','.join(str(b) for b in data[i:i+32]) + ',\n') - f.write('};\n') - f.write('const size_t bootstrap_icons_svg_len = sizeof(bootstrap_icons_svg);\n') - return None - -bootstrap_icons_src = cabana_env.Command('assets/bootstrap_icons.cc', str(bootstrap_icons.SVG_PATH), build_bootstrap_icons_src) - # build assets assets = "assets/assets.cc" cabana_env.Command(assets, "assets/assets.qrc", f"rcc $SOURCES -o $TARGET") cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, "assets/assets.o"])) -cabana_srcs = ['mainwin.cc', 'streams/pandastream.cc', 'streams/devicestream.cc', 'streams/livestream.cc', 'streams/abstractstream.cc', 'streams/replaystream.cc', 'binaryview.cc', 'historylog.cc', 'videowidget.cc', 'signalview.cc', - 'routesdialog.cc', 'routes.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', - 'utils/export.cc', 'utils/util.cc', 'utils/qtutil.cc', 'utils/strings.cc', 'utils/elidedlabel.cc', +cabana_srcs = ['mainwin.cc', 'binaryview.cc', 'historylog.cc', 'videowidget.cc', 'signalview.cc', 'routesdialog.cc', + 'utils/qtutil.cc', 'utils/elidedlabel.cc', 'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc', - 'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'settingsdialog.cc', 'panda.cc', - 'cameraview.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc', 'tools/routeinfo.cc'] -if arch != "Darwin": - cabana_srcs += ['streams/socketcanstream.cc'] + 'messageswidget.cc', 'streamselector.cc', 'settingsdialog.cc', + 'cameraview.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc', 'tools/routeinfo.cc'] + core_srcs cabana_lib = cabana_env.Library("cabana_lib", cabana_srcs + [bootstrap_icons_src], LIBS=cabana_libs, FRAMEWORKS=base_frameworks) cabana_env.Program('_cabana', ['cabana.cc', cabana_lib, assets], LIBS=cabana_libs, FRAMEWORKS=base_frameworks) @@ -123,6 +152,7 @@ if GetOption('extras'): dbc_core_test_env.Object('tests/dbc_core_util', 'utils/util.cc'), dbc_core_test_env.Object('tests/dbc_core_icons', bootstrap_icons_src), dbc_core_test_env.Object('tests/dbc_core_routes', 'routes.cc'), + dbc_core_test_env.Object('tests/dbc_core_qtstate', 'ui/qtstate.cc'), ] dbc_core_test_env.Program('tests/test_dbc_core', dbc_core_test_objects, LIBS=[replay_lib, common]) @@ -131,3 +161,4 @@ generate_dbc = cabana_env.Command('#' + output_json_file, ['dbc/generate_dbc_json.py'], "python3 openpilot/tools/cabana/dbc/generate_dbc_json.py --out " + output_json_file) cabana_env.Depends(generate_dbc, ["#openpilot/common", '#opendbc_repo', "#openpilot/cereal", "#msgq_repo"]) +ui_env.Depends(cabana_ui, generate_dbc) diff --git a/openpilot/tools/cabana/cabana b/openpilot/tools/cabana/cabana index db613b5391..fb995a05c2 100755 --- a/openpilot/tools/cabana/cabana +++ b/openpilot/tools/cabana/cabana @@ -25,14 +25,29 @@ install_qt() { fi } -# Install Qt if not found -if ! command -v qmake &> /dev/null; then - echo "Qt not found, installing dependencies..." - install_qt +# --legacy runs the old Qt cabana until it is deprecated +LEGACY=0 +ARGS=() +for arg in "$@"; do + if [[ "$arg" == "--legacy" ]]; then + LEGACY=1 + else + ARGS+=("$arg") + fi +done + +if [[ $LEGACY -eq 1 ]]; then + # Install Qt if not found + if ! command -v qmake &> /dev/null; then + echo "Qt not found, installing dependencies..." + install_qt + fi + TARGET="_cabana" +else + TARGET="_cabana_ui" fi -# Build _cabana cd "$ROOT" -scons -u openpilot/tools/cabana/_cabana openpilot/cereal/messaging/bridge +scons -u "openpilot/tools/cabana/$TARGET" openpilot/cereal/messaging/bridge -exec "$DIR/_cabana" "$@" +exec "$DIR/$TARGET" "${ARGS[@]}" diff --git a/openpilot/tools/cabana/core/color.h b/openpilot/tools/cabana/core/color.h index c29704dd44..47e7370c5e 100644 --- a/openpilot/tools/cabana/core/color.h +++ b/openpilot/tools/cabana/core/color.h @@ -60,7 +60,6 @@ struct CabanaColor { return r == other.r && g == other.g && b == other.b && a == other.a; } -private: struct Hsv { float hue; float saturation; float value; }; Hsv hsv() const { const float red = r / 255.0f, green = g / 255.0f, blue = b / 255.0f; diff --git a/openpilot/tools/cabana/core/settings.h b/openpilot/tools/cabana/core/settings.h index cad152de92..1b9e2bcfc4 100644 --- a/openpilot/tools/cabana/core/settings.h +++ b/openpilot/tools/cabana/core/settings.h @@ -5,18 +5,18 @@ constexpr int LIGHT_THEME = 1; constexpr int DARK_THEME = 2; +constexpr int STREAM_UPDATE_FPS = 30; // rate the streams publish message updates to the UI struct CabanaSettingsState { enum DragDirection { MsbFirst, LsbFirst, AlwaysLE, AlwaysBE }; bool absolute_time = false; - int fps = 10; int max_cached_minutes = 30; int chart_height = 200; int chart_column_count = 1; int chart_range = 3 * 60; int chart_series_type = 0; - int theme = 0; + int theme = LIGHT_THEME; int sparkline_range = 15; bool multiple_lines_hex = false; bool log_livestream = true; diff --git a/openpilot/tools/cabana/dbc/dbcmanager.cc b/openpilot/tools/cabana/dbc/dbcmanager.cc index 551619ea82..5170ab74d0 100644 --- a/openpilot/tools/cabana/dbc/dbcmanager.cc +++ b/openpilot/tools/cabana/dbc/dbcmanager.cc @@ -141,8 +141,15 @@ std::vector DBCManager::signalNames() { } int DBCManager::nonEmptyDBCCount() { - auto files = allDBCFiles(); - return std::count_if(files.cbegin(), files.cend(), [](auto &f) { return !f->isEmpty(); }); + return nonEmptyDBCFiles().size(); +} + +std::vector DBCManager::nonEmptyDBCFiles() { + std::vector files; + for (auto f : allDBCFiles()) { + if (!f->isEmpty()) files.push_back(f); + } + return files; } DBCFile *DBCManager::findDBCFile(const uint8_t source) { diff --git a/openpilot/tools/cabana/dbc/dbcmanager.h b/openpilot/tools/cabana/dbc/dbcmanager.h index 6bc86a8167..a729986f4a 100644 --- a/openpilot/tools/cabana/dbc/dbcmanager.h +++ b/openpilot/tools/cabana/dbc/dbcmanager.h @@ -39,6 +39,7 @@ public: std::vector signalNames(); inline int dbcCount() { return allDBCFiles().size(); } int nonEmptyDBCCount(); + std::vector nonEmptyDBCFiles(); const SourceSet sources(const DBCFile *dbc_file) const; DBCFile *findDBCFile(const uint8_t source); diff --git a/openpilot/tools/cabana/deqt.md b/openpilot/tools/cabana/deqt.md deleted file mode 100644 index 3bef2d7ab0..0000000000 --- a/openpilot/tools/cabana/deqt.md +++ /dev/null @@ -1,53 +0,0 @@ -we're migrating cabana away from Qt and to eventually entirely use imgui - -we are doing it incrementally, in small pieces that are easy to execute and verify. -we will repeat this until we're all done. - -# Cabana Qt API inventory - -these are all still in cabana. we remove them from this list once they're gone. -each bullet is an atomic unit of work. - -our workflow is: -- pick the easiest of the bulleted items from below -- implement it and make] sure it builds -- spin up reviewer agents to review the code in a clean context and a separate one to click around in xvfb as a gui test -- then implement the fixes from the above reviewer agents - -some rules -- do not add more Qt usage ever - -- `QObject`, `QMetaObject`, `QMetaType` -- `QApplication`, `QCoreApplication`, `QGuiApplication` -- `QString`, `QStringList`, `QStringBuilder`, `QChar`, `QLatin1Char` -- `QVariant` -- `QTimer` -- `QWidget`, `QMainWindow`, `QWindow` -- `QDialog`, `QDialogButtonBox`, `QMessageBox`, `QProgressDialog` -- `QFileDialog` -- `QMenu`, `QMenuBar`, `QAction`, `QActionGroup`, `QWidgetAction` -- `QToolBar`, `QToolButton`, `QPushButton` -- `QCheckBox`, `QRadioButton`, `QButtonGroup`, `QAbstractButton` -- `QComboBox`, `QLineEdit`, `QTextEdit`, `QSpinBox`, `QSlider` -- `QLabel`, `QGroupBox`, `QFrame` -- `QTabBar`, `QTabWidget`, `QSplitter`, `QScrollArea`, `QScrollBar` -- `QDockWidget`, `QStatusBar`, `QProgressBar` -- `QFormLayout`, `QGridLayout`, `QHBoxLayout`, `QVBoxLayout` -- `QSizePolicy` -- `QAbstractItemModel`, `QAbstractTableModel`, `QModelIndex` -- `QAbstractItemView`, `QTableView`, `QTreeView` -- `QTableWidget`, `QTableWidgetItem`, `QListWidget`, `QListWidgetItem` -- `QItemSelection`, `QItemSelectionModel`, `QItemSelectionRange` -- `QHeaderView`, `QStyledItemDelegate`, `QStyleOptionViewItem` -- `QValidator`, `QIntValidator` -- `QColor`, `QRgb`, `QPalette` -- `QBrush`, `QPen` -- `QPainter`, `QPainterPath`, `QStylePainter` -- `QImage`, `QPixmap`, `QPixmapCache`, `QStaticText` -- `QFont`, `QFontDatabase`, `QFontMetrics`, `QTextDocument` -- `QStyle`, `QStyleOption`, `QStyleOptionFrame`, `QStyleOptionSlider` -- `QPoint`, `QPointF`, `QRect`, `QRectF`, `QRegion` -- `QSize`, `QSizeF` -- `QEvent`, `QPaintEvent`, `QResizeEvent`, `QShowEvent`, `QCloseEvent` -- `QMouseEvent`, `QWheelEvent`, `QNativeGestureEvent`, `QContextMenuEvent` -- `QKeySequence`, `QShortcut`, `QToolTip` diff --git a/openpilot/tools/cabana/mainwin.cc b/openpilot/tools/cabana/mainwin.cc index 2c19df530c..c7a838a756 100644 --- a/openpilot/tools/cabana/mainwin.cc +++ b/openpilot/tools/cabana/mainwin.cc @@ -566,7 +566,7 @@ void MainWindow::updateDownloadProgress(uint64_t cur, uint64_t total, bool succe } void MainWindow::updateStatus() { - status_label->setText(tr("Cached Minutes:%1 FPS:%2").arg(settings.max_cached_minutes).arg(settings.fps)); + status_label->setText(tr("Cached Minutes:%1").arg(settings.max_cached_minutes)); } bool MainWindow::eventFilter(QObject *obj, QEvent *event) { diff --git a/openpilot/tools/cabana/messageswidget.cc b/openpilot/tools/cabana/messageswidget.cc index 2325bb3ae4..e2d0eba079 100644 --- a/openpilot/tools/cabana/messageswidget.cc +++ b/openpilot/tools/cabana/messageswidget.cc @@ -345,7 +345,7 @@ bool MessageListModel::filterAndSort() { void MessageListModel::msgsReceived(const std::set *new_msgs, bool has_new_ids) { if (has_new_ids || ((filters_.count(Column::FREQ) || filters_.count(Column::COUNT) || filters_.count(Column::DATA)) && - ++sort_threshold_ == settings.fps)) { + ++sort_threshold_ == STREAM_UPDATE_FPS)) { sort_threshold_ = 0; if (filterAndSort()) return; } diff --git a/openpilot/tools/cabana/settings.cc b/openpilot/tools/cabana/settings.cc index bacbbd5330..17a6611539 100644 --- a/openpilot/tools/cabana/settings.cc +++ b/openpilot/tools/cabana/settings.cc @@ -468,7 +468,6 @@ void writeSetting(json11::Json::object &settings_json, const char *key, const st template void settingsOp(Store &s, SettingOperation op) { op(s, "absolute_time", settings.absolute_time); - op(s, "fps", settings.fps); op(s, "max_cached_minutes", settings.max_cached_minutes); op(s, "chart_height", settings.chart_height); op(s, "chart_range", settings.chart_range); @@ -480,6 +479,7 @@ void settingsOp(Store &s, SettingOperation op) { op(s, "video_splitter_state", settings.video_splitter_state); op(s, "recent_files", settings.recent_files); op(s, "message_header_state", settings.message_header_state); + op(s, "ui_state", settings.ui_state); op(s, "chart_series_type", settings.chart_series_type); op(s, "theme", settings.theme); op(s, "sparkline_range", settings.sparkline_range); @@ -508,7 +508,8 @@ Settings::Settings() { settingsOp(legacy_settings, [](const auto &s, const char *key, auto &value) { readLegacySetting(s, key, value); }); } } - fps = std::clamp(fps, 1, 100); + // settings written before the "Automatic" theme was dropped hold a 0 for it + if (theme != LIGHT_THEME && theme != DARK_THEME) theme = LIGHT_THEME; } // Must be called before main() returns: json11's internal statistics are constructed on first diff --git a/openpilot/tools/cabana/settings.h b/openpilot/tools/cabana/settings.h index 52353dc667..27138c0890 100644 --- a/openpilot/tools/cabana/settings.h +++ b/openpilot/tools/cabana/settings.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include "tools/cabana/core/observable.h" @@ -17,6 +18,9 @@ public: std::vector window_state; std::vector message_header_state; + // UI layout state (dock layout, window geometry, table state), owned by the imgui frontend + std::string ui_state; + Observable<> changed; }; diff --git a/openpilot/tools/cabana/settingsdialog.cc b/openpilot/tools/cabana/settingsdialog.cc index 5dd8b7617c..0fbc82c508 100644 --- a/openpilot/tools/cabana/settingsdialog.cc +++ b/openpilot/tools/cabana/settingsdialog.cc @@ -22,13 +22,8 @@ SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent) { form_layout->addRow(tr("Color Theme"), theme = new QComboBox(this)); theme->setToolTip(tr("You may need to restart cabana after changes theme")); - theme->addItems({tr("Automatic"), tr("Light"), tr("Dark")}); - theme->setCurrentIndex(settings.theme); - - form_layout->addRow("FPS", fps = new QSpinBox(this)); - fps->setRange(10, 100); - fps->setSingleStep(10); - fps->setValue(settings.fps); + theme->addItems({tr("Light"), tr("Dark")}); + theme->setCurrentIndex(settings.theme - LIGHT_THEME); form_layout->addRow(tr("Max Cached Minutes"), cached_minutes = new QSpinBox(this)); cached_minutes->setRange(MIN_CACHE_MINIUTES, MAX_CACHE_MINIUTES); @@ -79,11 +74,10 @@ SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent) { } void SettingsDialog::save() { - if (std::exchange(settings.theme, theme->currentIndex()) != settings.theme) { + if (std::exchange(settings.theme, theme->currentIndex() + LIGHT_THEME) != settings.theme) { // set theme before emit changed utils::setTheme(settings.theme); } - settings.fps = fps->value(); settings.max_cached_minutes = cached_minutes->value(); settings.chart_height = chart_height->value(); settings.log_livestream = log_livestream->isChecked(); diff --git a/openpilot/tools/cabana/settingsdialog.h b/openpilot/tools/cabana/settingsdialog.h index c5c723c1cb..895b789f30 100644 --- a/openpilot/tools/cabana/settingsdialog.h +++ b/openpilot/tools/cabana/settingsdialog.h @@ -10,7 +10,6 @@ class SettingsDialog : public QDialog { public: SettingsDialog(QWidget *parent); void save(); - QSpinBox *fps; QSpinBox *cached_minutes; QSpinBox *chart_height; QComboBox *chart_series_type; diff --git a/openpilot/tools/cabana/streams/abstractstream.cc b/openpilot/tools/cabana/streams/abstractstream.cc index 7be579269d..1091bf95c5 100644 --- a/openpilot/tools/cabana/streams/abstractstream.cc +++ b/openpilot/tools/cabana/streams/abstractstream.cc @@ -164,7 +164,7 @@ bool AbstractStream::isMessageActive(const MessageId &id) const { return delta < 1.5; } - return delta < (5.0 / m.freq) + (1.0 / settings.fps); + return delta < (5.0 / m.freq) + (1.0 / STREAM_UPDATE_FPS); } void AbstractStream::updateLastMsgsTo(double sec) { @@ -232,6 +232,10 @@ void AbstractStream::mergeEvents(const std::vector &events) { msg_events[{.source = e->src, .address = e->address}].push_back(e); } + insertEvents(events, msg_events); +} + +void AbstractStream::insertEvents(const std::vector &events, const MessageEventsMap &msg_events) { if (!events.empty()) { for (const auto &[id, new_e] : msg_events) { if (!new_e.empty()) { diff --git a/openpilot/tools/cabana/streams/abstractstream.h b/openpilot/tools/cabana/streams/abstractstream.h index b992b086fd..dedf9a2da6 100644 --- a/openpilot/tools/cabana/streams/abstractstream.h +++ b/openpilot/tools/cabana/streams/abstractstream.h @@ -74,6 +74,7 @@ protected: void cancelWaits(); // call before joining threads, the main thread isn't pumping events during destruction void requestUpdateLastMessages() { postToMainThread([this]() { updateLastMessages(); }); } void mergeEvents(const std::vector &events); + void insertEvents(const std::vector &events, const MessageEventsMap &msg_events); const CanEvent *newEvent(uint64_t mono_time, const cereal::CanData::Reader &c); void updateEvent(const MessageId &id, double sec, const uint8_t *data, uint8_t size); void waitForSeekFinshed(); diff --git a/openpilot/tools/cabana/streams/livestream.cc b/openpilot/tools/cabana/streams/livestream.cc index ec71a6794d..081e63e248 100644 --- a/openpilot/tools/cabana/streams/livestream.cc +++ b/openpilot/tools/cabana/streams/livestream.cc @@ -48,7 +48,6 @@ LiveStream::~LiveStream() { void LiveStream::start() { begin_date_time = std::chrono::system_clock::now(); - fps_ = settings.fps; exit_ = false; stream_thread = std::thread(&LiveStream::streamThread, this); update_thread = std::thread(&LiveStream::updateThread, this); @@ -62,7 +61,7 @@ void LiveStream::stop() { void LiveStream::updateThread() { while (!exit_) { - std::this_thread::sleep_for(std::chrono::milliseconds(1000 / fps_)); + std::this_thread::sleep_for(std::chrono::milliseconds(1000 / STREAM_UPDATE_FPS)); // coalesce: skip the request if the main thread hasn't processed the previous one yet. if (!update_pending_.exchange(true)) { requestUpdateLastMessages(); @@ -90,7 +89,6 @@ void LiveStream::handleEvent(kj::ArrayPtr data) { // called on the main thread via requestUpdateLastMessages() void LiveStream::updateLastMessages() { update_pending_ = false; - fps_ = settings.fps; { // merge events received from live stream thread. std::lock_guard lk(lock); diff --git a/openpilot/tools/cabana/streams/livestream.h b/openpilot/tools/cabana/streams/livestream.h index 22587335b0..1a17d481f1 100644 --- a/openpilot/tools/cabana/streams/livestream.h +++ b/openpilot/tools/cabana/streams/livestream.h @@ -37,7 +37,6 @@ private: std::mutex lock; std::thread stream_thread, update_thread; std::atomic update_pending_ = false; - std::atomic fps_ = 10; std::vector received_events_; std::chrono::system_clock::time_point begin_date_time; diff --git a/openpilot/tools/cabana/streams/replaystream.cc b/openpilot/tools/cabana/streams/replaystream.cc index 944cb10fd9..fe8048c215 100644 --- a/openpilot/tools/cabana/streams/replaystream.cc +++ b/openpilot/tools/cabana/streams/replaystream.cc @@ -21,6 +21,8 @@ ReplayStream::~ReplayStream() { cancelWaits(); } +// runs on replay's merge thread: a segment of CAN data takes ~30 ms to parse and group, which dropped +// frames when it ran on the main thread. Only the sorted insert and the merged signal need the main thread. void ReplayStream::mergeSegments() { auto event_data = replay->getEventData(); for (const auto &[n, seg] : event_data->segments) { @@ -29,16 +31,19 @@ void ReplayStream::mergeSegments() { std::vector new_events; new_events.reserve(seg->log->events.size()); + MessageEventsMap msg_events; for (const Event &e : seg->log->events) { if (e.which == cereal::Event::Which::CAN) { capnp::FlatArrayMessageReader reader(e.data); auto event = reader.getRoot(); for (const auto &c : event.getCan()) { - new_events.push_back(newEvent(e.mono_time, c)); + const CanEvent *ce = newEvent(e.mono_time, c); + new_events.push_back(ce); + msg_events[{.source = ce->src, .address = ce->address}].push_back(ce); } } } - mergeEvents(new_events); + postToMainThreadAndWait([&]() { insertEvents(new_events, msg_events); }); } } } @@ -56,7 +61,7 @@ bool ReplayStream::loadRoute(const std::string &route, const std::string &data_d waitForSeekFinshed(); }; replay->onQLogLoaded = [this](std::shared_ptr qlog) { postToMainThread([this, qlog]() { qLogLoaded(qlog); }); }; - replay->onSegmentsMerged = [this]() { postToMainThreadAndWait([this]() { mergeSegments(); }); }; + replay->onSegmentsMerged = [this]() { mergeSegments(); }; bool success = replay->load(); if (!success) { @@ -97,7 +102,7 @@ bool ReplayStream::eventFilter(const Event *event) { } double ts = millis_since_boot(); - if ((ts - prev_update_ts) > (1000.0 / settings.fps)) { + if ((ts - prev_update_ts) > (1000.0 / STREAM_UPDATE_FPS)) { requestUpdateLastMessages(); prev_update_ts = ts; } diff --git a/openpilot/tools/cabana/tests/test_cabana.cc b/openpilot/tools/cabana/tests/test_cabana.cc index 5c5fce9864..09741cd578 100644 --- a/openpilot/tools/cabana/tests/test_cabana.cc +++ b/openpilot/tools/cabana/tests/test_cabana.cc @@ -1,4 +1,5 @@ +#include #include #include #include @@ -8,6 +9,7 @@ #include "tools/cabana/dbc/dbcfile.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/routes.h" +#include "tools/cabana/ui/qtstate.h" #include "tools/cabana/utils/strings.h" const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.bz2"; @@ -299,6 +301,58 @@ void test_route_json() { REQUIRE(routes::parseRoutes("not json", false).empty()); } +static std::vector fromHex(const std::string &hex) { + std::vector out; + for (size_t i = 0; i + 1 < hex.size(); i += 2) { + out.push_back((uint8_t)std::stoul(hex.substr(i, 2), nullptr, 16)); + } + return out; +} + +void test_qt_state_blobs() { + // blobs written by the Qt frontend + auto geometry = qtstate::parseQtGeometry(fromHex( + "01d9d0cb000300000000000000000014000004ff000003330000000000000014000004ff" + "00000333000000000000000006400000000000000014000004ff00000333")); + REQUIRE(geometry.has_value()); + REQUIRE(geometry->x == 0); + REQUIRE(geometry->y == 20); + REQUIRE(geometry->w == 1280); + REQUIRE(geometry->h == 800); + REQUIRE(geometry->maximized == false); + + auto splitter = qtstate::parseQtSplitter(fromHex("000000ff0000000100000002000000960000006801ffffffff010000000200")); + REQUIRE(splitter.has_value()); + REQUIRE(std::fabs(splitter->ratio - 150.0f / 254.0f) < 1e-6f); + + auto header = qtstate::parseQtHeaderState(fromHex( + "000000ff000000000000000100000000000000000100000000000000000000000000000000000003360000000701" + "01000100000000000000000000000068ffffffff0000008400000000000000070000006800000001000000000000" + "00680000000100000000000000680000000100000000000000680000000100000000000000680000000100000000" + "000000680000000100000000000000c60000000100000002000003e800000000c6")); + REQUIRE(header.has_value()); + REQUIRE(header->sort_section == 0); + REQUIRE(header->sort_order == 0); + REQUIRE(header->sort_shown == true); + const int expected_width[] = {104, 104, 104, 104, 104, 104, 198}; + for (int i = 0; i < qtstate::kMessageColumnCount; ++i) { + REQUIRE(header->visual[i] == i); + REQUIRE(header->width[i] == expected_width[i]); + REQUIRE(header->hidden[i] == false); + } + + // empty, truncated and wrong magic blobs are rejected + REQUIRE(!qtstate::parseQtGeometry({}).has_value()); + REQUIRE(!qtstate::parseQtSplitter({}).has_value()); + REQUIRE(!qtstate::parseQtHeaderState({}).has_value()); + REQUIRE(!qtstate::parseQtGeometry(fromHex("01d9d0cb00030000000000000000")).has_value()); + REQUIRE(!qtstate::parseQtSplitter(fromHex("000000ff000000010000000200000096")).has_value()); + REQUIRE(!qtstate::parseQtHeaderState(fromHex("000000ff0000000000000001000000000000000001")).has_value()); + REQUIRE(!qtstate::parseQtGeometry(fromHex("deadbeef000300000000000000000014000004ff00000333")).has_value()); + REQUIRE(!qtstate::parseQtSplitter(fromHex("000000fe0000000100000002000000960000006801ffffffff010000000200")).has_value()); + REQUIRE(!qtstate::parseQtHeaderState(fromHex("000000fe00000000000000010000000000000000010000000000000000")).has_value()); +} + void test_cabana_core() { test_format_seconds(); test_to_hex(); @@ -313,6 +367,7 @@ void test_cabana_core() { test_route_timestamps(); test_route_api_response(); test_route_json(); + test_qt_state_blobs(); } int main() { diff --git a/openpilot/tools/cabana/tests/test_cabana_ui.py b/openpilot/tools/cabana/tests/test_cabana_ui.py new file mode 100644 index 0000000000..2cb0475041 --- /dev/null +++ b/openpilot/tools/cabana/tests/test_cabana_ui.py @@ -0,0 +1,13 @@ +import subprocess +from pathlib import Path + +from openpilot.common.test import OpenpilotTestCase + +CABANA_DIR = Path(__file__).parent.parent + + +class TestCabanaUi(OpenpilotTestCase): + def test_help(self): + result = subprocess.run(["./_cabana_ui", "-h"], cwd=CABANA_DIR, capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert "Usage:" in result.stderr diff --git a/openpilot/tools/cabana/ui/app.cc b/openpilot/tools/cabana/ui/app.cc new file mode 100644 index 0000000000..aab2bfe742 --- /dev/null +++ b/openpilot/tools/cabana/ui/app.cc @@ -0,0 +1,212 @@ +#include "tools/cabana/ui/app.h" + +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_impl_glfw.h" +#include "imgui_impl_opengl3.h" +#include "imgui_impl_opengl3_loader.h" +#include "implot.h" +#include + +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/inistate.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/ui/mainwin.h" +#include "tools/cabana/utils/util.h" + +namespace { + +std::atomic g_signal_exit{false}; +std::vector g_key_events; +void keyCallback(GLFWwindow *window, int key, int scancode, int action, int mods) { + ImGui_ImplGlfw_KeyCallback(window, key, scancode, action, mods); + if (action == GLFW_PRESS) g_key_events.push_back({key, mods}); +} +// imgui releases every mouse button when the window loses focus, which aborts a panel tear-off drag and +// docks the panel back. X11 keeps delivering the drag through the implicit grab, so hold a focus loss back +// while a button is down and deliver it after the release (see deliverPendingFocusLoss). +GLFWwindow *g_focus_lost_window = nullptr; +// macOS drops the button on its own when the focus moves, and holding the loss back there swallowed the +// first click in a popup: the click makes the popup's window key, the main window's loss lands on the +// release and imgui clears its mouse state before it sees that release +void windowFocusCallback(GLFWwindow *w, int f) { +#ifdef __APPLE__ + ImGui_ImplGlfw_WindowFocusCallback(w, f); +#else + if (f) { + g_focus_lost_window = nullptr; + ImGui_ImplGlfw_WindowFocusCallback(w, f); + } else { + g_focus_lost_window = w; + } +#endif +} +bool anyMouseButtonDown(GLFWwindow *w) { + for (int b = GLFW_MOUSE_BUTTON_1; b <= GLFW_MOUSE_BUTTON_LAST; ++b) { + if (glfwGetMouseButton(w, b) == GLFW_PRESS) return true; + } + return false; +} +void deliverPendingFocusLoss() { + if (g_focus_lost_window == nullptr || anyMouseButtonDown(g_focus_lost_window)) return; + ImGui_ImplGlfw_WindowFocusCallback(g_focus_lost_window, GLFW_FALSE); + g_focus_lost_window = nullptr; +} + +void hookViewportCallbacks() { + for (ImGuiViewport *viewport : ImGui::GetPlatformIO().Viewports) { + if (viewport->PlatformHandle == nullptr || viewport == ImGui::GetMainViewport()) continue; + glfwSetKeyCallback((GLFWwindow *)viewport->PlatformHandle, keyCallback); + } +} + +void glfwErrorCallback(int error, const char *description) { + fprintf(stderr, "GLFW error %d: %s\n", error, description); +} + +// vsync paces the loop: glfwSwapBuffers blocks until the next refresh. Throttling on top of that beats +// against the refresh rate and makes the camera view stutter. +void renderFrame(GLFWwindow *window, MainWindow *win) { + glfwPollEvents(); + deliverPendingFocusLoss(); + utils::drainMainThreadQueue(); + + int fb_w = 0, fb_h = 0; + glfwGetFramebufferSize(window, &fb_w, &fb_h); + + ImGui_ImplOpenGL3_NewFrame(); + ImGui_ImplGlfw_NewFrame(); + ImGui::NewFrame(); + win->draw(); + ImGui::Render(); + + const ImVec4 &bg = ImGui::GetStyle().Colors[ImGuiCol_WindowBg]; + glViewport(0, 0, fb_w, fb_h); + glClearColor(bg.x, bg.y, bg.z, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); + + if (ImGui::GetIO().ConfigFlags & ImGuiConfigFlags_ViewportsEnable) { + GLFWwindow *backup_context = glfwGetCurrentContext(); + ImGui::UpdatePlatformWindows(); + hookViewportCallbacks(); + ImGui::RenderPlatformWindowsDefault(); + glfwMakeContextCurrent(backup_context); + } + glfwSwapBuffers(window); +} + +class GlfwRuntime { +public: + GlfwRuntime() { + glfwSetErrorCallback(glfwErrorCallback); +#ifdef __APPLE__ + setMacAppName("Cabana"); +#endif + if (!glfwInit()) throw std::runtime_error("glfwInit failed"); + glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); + glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); + glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); +#ifdef __APPLE__ + glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE); +#endif + window_ = glfwCreateWindow(1600, 900, "Cabana", nullptr, nullptr); + if (window_ == nullptr) { + glfwTerminate(); + throw std::runtime_error("glfwCreateWindow failed"); + } + glfwMakeContextCurrent(window_); + glfwSwapInterval(1); + } + + ~GlfwRuntime() { + if (window_ != nullptr) glfwDestroyWindow(window_); + glfwTerminate(); + } + + GlfwRuntime(const GlfwRuntime &) = delete; + GlfwRuntime &operator=(const GlfwRuntime &) = delete; + GLFWwindow *window() const { return window_; } + +private: + GLFWwindow *window_ = nullptr; +}; + +class ImGuiRuntime { +public: + explicit ImGuiRuntime(GLFWwindow *window) { + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImPlot::CreateContext(); + ImGuiIO &io = ImGui::GetIO(); + io.ConfigFlags |= ImGuiConfigFlags_DockingEnable; + io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable; + io.ConfigViewportsNoDecoration = false; + io.IniFilename = nullptr; + io.LogFilename = nullptr; + if (!ImGui_ImplGlfw_InitForOpenGL(window, true)) { + ImPlot::DestroyContext(); + ImGui::DestroyContext(); + throw std::runtime_error("ImGui_ImplGlfw_InitForOpenGL failed"); + } + glfwSetKeyCallback(window, keyCallback); + glfwSetWindowFocusCallback(window, windowFocusCallback); + if (!ImGui_ImplOpenGL3_Init("#version 330")) { + ImGui_ImplGlfw_Shutdown(); + ImPlot::DestroyContext(); + ImGui::DestroyContext(); + throw std::runtime_error("ImGui_ImplOpenGL3_Init failed"); + } + } + + ~ImGuiRuntime() { + ImGui_ImplOpenGL3_Shutdown(); + ImGui_ImplGlfw_Shutdown(); + ImPlot::DestroyContext(); + ImGui::DestroyContext(); + } + + ImGuiRuntime(const ImGuiRuntime &) = delete; + ImGuiRuntime &operator=(const ImGuiRuntime &) = delete; +}; + +} // namespace + +std::vector takeKeyEvents() { + return std::exchange(g_key_events, {}); +} + +int run(std::unique_ptr stream, StreamLoader stream_loader, const std::string &dbc_file) { + try { + // SIGINT/SIGTERM close all windows (which may ask about unsaved changes), then exit + UnixSignalHandler signal_handler([]() { g_signal_exit = true; }); + + GlfwRuntime glfw; + ImGuiRuntime imgui(glfw.window()); + loadFonts(); + applyTheme(settings.theme); + inistate::addSettingsHandler(); + inistate::load(); + inistate::applyWindowGeometry(glfw.window()); + + MainWindow win(glfw.window(), std::move(stream), std::move(stream_loader), dbc_file); + while (!win.exited()) { + if (g_signal_exit.exchange(false)) { + printf("\nexiting...\n"); + win.close(); + } else if (glfwWindowShouldClose(glfw.window())) { + glfwSetWindowShouldClose(glfw.window(), GLFW_FALSE); + win.close(); + } + renderFrame(glfw.window(), &win); + } + return 0; + } catch (const std::exception &e) { + fprintf(stderr, "%s\n", e.what()); + return 1; + } +} diff --git a/openpilot/tools/cabana/ui/app.h b/openpilot/tools/cabana/ui/app.h new file mode 100644 index 0000000000..a981e57e54 --- /dev/null +++ b/openpilot/tools/cabana/ui/app.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include +#include + +#include "tools/cabana/streams/abstractstream.h" + +// builds a stream off the main thread; nullptr means the load failed +using StreamLoader = std::function()>; + +// takes ownership of stream; a loader runs behind the window instead of before it; with neither, the +// stream selector opens +int run(std::unique_ptr stream, StreamLoader stream_loader, const std::string &dbc_file); + +// key presses with the modifier state at event time: imgui may apply a modifier release in the same frame as +// the key press it belongs to, which loses fast shortcut sequences. Consumed once per frame by MainWindow. +struct KeyEvent { + int key; // GLFW_KEY_* + int mods; // GLFW_MOD_* +}; +std::vector takeKeyEvents(); diff --git a/openpilot/tools/cabana/ui/chart/chart.cc b/openpilot/tools/cabana/ui/chart/chart.cc new file mode 100644 index 0000000000..4dedc9074c --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/chart.cc @@ -0,0 +1,790 @@ +#define IMGUI_DEFINE_MATH_OPERATORS // ImVec2 arithmetic, must precede imgui.h +#include "tools/cabana/ui/chart/chart.h" + +#include +#include +#include +#include +#include +#include + +#include "tools/cabana/core/settings.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/chart/chartswidget.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" + +const int AXIS_X_TOP_MARGIN = 4; +const int X_TICK_COUNT = 5; +const double MIN_ZOOM_SECONDS = 0.01; // 10ms +const double EPSILON = 1e-6; +constexpr ImVec4 LAYOUT_MARGINS{8, 6, 8, 6}; // left, top, right, bottom +static inline bool xLessThan(const ImPlotPoint &p, double x) { return p.x < (x - EPSILON); } +static inline bool isNull(const ImPlotPoint &p) { return p.x == 0 && p.y == 0; } + +static std::string formatNumber(double value, int precision) { + char buf[64]; + snprintf(buf, sizeof(buf), "%.*f", precision, value); + return buf; +} + +// the decimals needed to tell tick_count ticks over range apart +static int axisPrecision(double range, int tick_count, int min_precision) { + return std::max(int(-std::floor(std::log10(range / (tick_count - 1)))), min_precision); +} + +static void addTextEllipsis(ImDrawList *dl, ImFont *font, ImU32 col, const ImVec2 &pos, float max_x, const std::string &text) { + const float size = ImGui::GetFontSize(); + ImGui::PushFont(font, 0.0f); + ImGui::RenderTextEllipsis(dl, pos, ImVec2(max_x, pos.y + size), max_x, text.c_str(), nullptr, nullptr); + ImGui::PopFont(); +} + +ChartView::ChartView(const std::pair &x_range, ChartsWidget *parent) + : x_min_(x_range.first), x_max_(x_range.second), charts_widget_(parent) { + series_type_ = (SeriesType)settings.chart_series_type; + + connections_.push_back(dbc()->signalRemoved.connect([this](const cabana::Signal *sig) { signalRemoved(sig); })); + connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { signalUpdated(sig); })); + connections_.push_back(dbc()->msgRemoved.connect([this](MessageId id) { msgRemoved(id); })); +} + +void ChartView::drawMenuActions() { + // the current series type is marked with a radio bullet on the left + const float indent = ImGui::GetFontSize(); + float label_width = ImGui::CalcTextSize("Manage Signals").x; + for (const char *type : SERIES_TYPE_NAMES) label_width = std::max(label_width, ImGui::CalcTextSize(type).x); + for (int i = 0; i < (int)std::size(SERIES_TYPE_NAMES); ++i) { + if (radioMenuItem(SERIES_TYPE_NAMES[i], i == (int)series_type_, indent + label_width + indent)) { + setSeriesType((SeriesType)i); + } + } + ImGui::Separator(); + ImGui::Indent(indent); + if (ImGui::MenuItem("Manage Signals")) manageSignals(); + if (ImGui::MenuItem("Split Chart", nullptr, false, sigs_.size() > 1)) charts_widget_->splitChart(this); + ImGui::Unindent(indent); +} + +// the buttons and their menus are drawn every frame, at the rects updateLayout() placed them at +void ChartView::createToolButtons() { + ImGui::SetCursorScreenPos(layout_.close_btn_rect.Min); + bool close_clicked = toolButton("close_btn", icon::X, "Remove Chart"); + + ImGui::SetCursorScreenPos(layout_.manage_btn_rect.Min); + if (toolButton("manage_btn", icon::LIST, "")) ImGui::OpenPopup("manage_menu"); + if (ImGui::BeginPopup("manage_menu")) { + drawMenuActions(); + ImGui::EndPopup(); + } + + if (close_clicked) charts_widget_->removeChart(this); +} + +void ChartView::addSignal(const MessageId &msg_id, const cabana::Signal *sig) { + if (hasSignal(msg_id, sig)) return; + + sigs_.push_back({.msg_id = msg_id, .sig = sig, .color = uniqueColor(sig->color)}); + updateSeries(sig); + charts_widget_->seriesChanged(); +} + +bool ChartView::hasSignal(const MessageId &msg_id, const cabana::Signal *sig) const { + return std::any_of(sigs_.cbegin(), sigs_.cend(), [&](auto &s) { return s.msg_id == msg_id && s.sig == sig; }); +} + +void ChartView::removeIf(std::function predicate) { + int prev_size = sigs_.size(); + sigs_.erase(std::remove_if(sigs_.begin(), sigs_.end(), predicate), sigs_.end()); + if (sigs_.empty()) { + charts_widget_->removeChart(this); + } else if (sigs_.size() != prev_size) { + charts_widget_->seriesChanged(); + updateAxisY(); + } +} + +void ChartView::signalUpdated(const cabana::Signal *sig) { + auto it = std::find_if(sigs_.begin(), sigs_.end(), [sig](auto &s) { return s.sig == sig; }); + if (it != sigs_.end()) { + if (!(it->color == sig->color)) { + it->color = uniqueColor(sig->color, sig); + } + updateSeries(sig); + } +} + +void ChartView::manageSignals() { + auto dlg = std::make_unique("Manage Chart"); + for (auto &s : sigs_) { + dlg->addSelected(s.msg_id, s.sig); + } + // runs once the dialog is accepted, dropped if the chart is removed first + charts_widget_->execSignalSelector(std::move(dlg), this, [this](SignalSelector &selector) { + const auto &items = selector.selectedItems(); + for (const auto &s : items) { + addSignal(s.msg_id, s.sig); + } + removeIf([&](auto &s) { + return std::none_of(items.cbegin(), items.cend(), [&](auto &it) { return s.msg_id == it.msg_id && s.sig == it.sig; }); + }); + }); +} + +void ChartView::updateLayout() { + const ImVec2 grip = ImGui::CalcTextSize(icon::GRIP_HORIZONTAL); + const ImVec2 top_left = layout_.rect.Min + ImVec2(LAYOUT_MARGINS.x, LAYOUT_MARGINS.y); + layout_.move_icon_rect = ImRect(top_left, top_left + grip); + const ImVec2 pad = ImGui::GetStyle().FramePadding * 2; + const ImVec2 close_size = ImGui::CalcTextSize(icon::X) + pad; + const ImVec2 manage_size = ImGui::CalcTextSize(icon::LIST) + pad; + const ImVec2 close_min(layout_.rect.Max.x - LAYOUT_MARGINS.z - close_size.x, top_left.y); + layout_.close_btn_rect = ImRect(close_min, close_min + close_size); + const ImVec2 manage_min(close_min.x - manage_size.x - ImGui::GetStyle().ItemSpacing.x, top_left.y); + layout_.manage_btn_rect = ImRect(manage_min, manage_min + manage_size); + + ImFont *bold = boldFont(); + const float font_size = ImGui::GetFontSize(); + const float fm_height = ImGui::GetTextLineHeight(); + const int marker_size = markerSize(); + const int row_height = std::max(marker_size, fm_height) + fm_height + 3; // + the signal value line + const int legend_left = layout_.move_icon_rect.Max.x + LAYOUT_MARGINS.x; + const int legend_right = std::max(layout_.manage_btn_rect.Min.x - LAYOUT_MARGINS.z, legend_left + 10); + + // layout legend entries left-to-right, wrapping between the move icon and the buttons + layout_.legend_rects.clear(); + int x = legend_left, y = top_left.y; + for (auto &s : sigs_) { + int w = marker_size + 5 + bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, s.sig->name.c_str()).x + + ImGui::CalcTextSize(msgLabel(s.msg_id).c_str()).x; + w = std::min(w, legend_right - legend_left); // keep oversized entries clear of the header buttons + if (x + w > legend_right && x > legend_left) { + x = legend_left; + y += row_height; + } + layout_.legend_rects.emplace_back(ImVec2(x, y), ImVec2(x + w, y + std::max(marker_size, fm_height))); + x += w + 12; + } + + // add top space for the legend and signal values + int adjust_top = (y + row_height) - top_left.y; + adjust_top = std::max(adjust_top, layout_.manage_btn_rect.Max.y - layout_.rect.Min.y + LAYOUT_MARGINS.y); + layout_.header_bottom = layout_.rect.Min.y + adjust_top + LAYOUT_MARGINS.y; +} + +void ChartView::updatePlot(double cur, double min, double max) { + cur_sec_ = cur; + if (min != x_min_ || max != x_max_) { + x_min_ = min; + x_max_ = max; + updateAxisY(); + if (tooltip_x_ >= 0) { + showTip(secondsAtPoint({(float)tooltip_x_, 0})); + } + } +} + +void ChartView::appendCanEvents(const cabana::Signal *sig, const std::vector &events, + std::vector &vals, std::vector &step_vals) { + vals.reserve(vals.size() + events.size()); + step_vals.reserve(step_vals.size() + events.size() * 2); + + double value = 0; + for (const CanEvent *e : events) { + if (sig->getValue(e->dat, e->size, &value)) { + const double ts = can->toSeconds(e->mono_time); + vals.emplace_back(ts, value); + if (!step_vals.empty()) + step_vals.emplace_back(ts, step_vals.back().y); + step_vals.emplace_back(ts, value); + } + } +} + +void ChartView::updateSeries(const cabana::Signal *sig, const MessageEventsMap *msg_new_events) { + for (auto &s : sigs_) { + if (!sig || s.sig == sig) { + if (!msg_new_events) { + s.vals.clear(); + s.step_vals.clear(); + } + auto events = msg_new_events ? msg_new_events : &can->eventsMap(); + auto it = events->find(s.msg_id); + if (it == events->end() || it->second.empty()) continue; + + if (s.vals.empty() || can->toSeconds(it->second.back()->mono_time) > s.vals.back().x) { + appendCanEvents(s.sig, it->second, s.vals, s.step_vals); + } else { + std::vector vals, step_vals; + appendCanEvents(s.sig, it->second, vals, step_vals); + if (vals.empty()) continue; + s.vals.insert(std::lower_bound(s.vals.begin(), s.vals.end(), vals.front().x, xLessThan), + vals.begin(), vals.end()); + s.step_vals.insert(std::lower_bound(s.step_vals.begin(), s.step_vals.end(), step_vals.front().x, xLessThan), + step_vals.begin(), step_vals.end()); + } + + if (!can->liveStreaming()) { + s.segment_tree.build(s.vals.size(), [&vals = s.vals](int i) { return vals[i].y; }); + } + } + } + updateAxisY(); +} + +std::pair ChartView::visibleRange(const std::vector &points) const { + auto first = std::lower_bound(points.cbegin(), points.cend(), x_min_, xLessThan); + auto last = std::lower_bound(first, points.cend(), x_max_, xLessThan); + return {first, last}; +} + +const ImPlotPoint *ChartView::lastPointBefore(const SigItem &s, double sec) const { + auto it = std::lower_bound(s.vals.crbegin(), s.vals.crend(), sec, [](auto &p, double x) { return p.x > x + EPSILON; }); + return it != s.vals.crend() && it->x >= x_min_ ? &*it : nullptr; +} + +void ChartView::updateAxisY() { + if (sigs_.empty()) return; + + double min = std::numeric_limits::max(); + double max = std::numeric_limits::lowest(); + std::string unit = sigs_[0].sig->unit; + + for (auto &s : sigs_) { + if (!s.visible) continue; + + // Only show unit when all signals have the same unit + if (unit != s.sig->unit) { + unit.clear(); + } + + auto [first, last] = visibleRange(s.vals); + s.min = std::numeric_limits::max(); + s.max = std::numeric_limits::lowest(); + if (can->liveStreaming()) { + for (auto it = first; it != last; ++it) { + if (it->y < s.min) s.min = it->y; + if (it->y > s.max) s.max = it->y; + } + } else { + std::tie(s.min, s.max) = s.segment_tree.minmax(std::distance(s.vals.cbegin(), first), std::distance(s.vals.cbegin(), last)); + } + min = std::min(min, s.min); + max = std::max(max, s.max); + } + if (min == std::numeric_limits::max()) min = 0; + if (max == std::numeric_limits::lowest()) max = 0; + + y_unit_ = unit; + + double delta = std::abs(max - min) < 1e-3 ? 1 : (max - min) * 0.05; + auto [min_y, max_y, tick_count] = getNiceAxisNumbers(min - delta, max + delta, 3); + if (min_y != y_min_ || max_y != y_max_) { + y_min_ = min_y; + y_max_ = max_y; + y_tick_count_ = tick_count; + y_precision_ = axisPrecision(max_y - min_y, tick_count, 0); + } +} + +std::tuple ChartView::getNiceAxisNumbers(double min, double max, int tick_count) { + double range = niceNumber((max - min), true); // range with ceiling + double step = niceNumber(range / (tick_count - 1), false); + min = std::floor(min / step); + max = std::ceil(max / step); + tick_count = int(max - min) + 1; + return {min * step, max * step, tick_count}; +} + +int ChartView::xAxisPrecision() const { + return axisPrecision(x_max_ - x_min_, X_TICK_COUNT, 2); +} + +// nice numbers can be expressed as form of 1*10^n, 2* 10^n or 5*10^n +double ChartView::niceNumber(double x, bool ceiling) { + double z = std::pow(10, std::floor(std::log10(x))); // the largest 10^n smaller than x + double q = x / z; // 1 <= q < 10 + if (ceiling) { + if (q <= 1.0) q = 1; + else if (q <= 2.0) q = 2; + else if (q <= 5.0) q = 5; + else q = 10; + } else { + if (q < 1.5) q = 1; + else if (q < 3.0) q = 2; + else if (q < 7.0) q = 5; + else q = 10; + } + return q * z; +} + +void ChartView::drawContextMenu() { + if (drawing_ghost_) return; + // the menu opens on right press; a right release with no menu open reaches handleMouseRelease + if (ImGui::IsMouseClicked(ImGuiMouseButton_Right) && ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows) && + !ImGui::IsAnyItemActive()) { + ImGui::OpenPopup("context_menu"); + } + context_menu_id_ = ImGui::GetID("context_menu"); + if (ImGui::BeginPopup("context_menu")) { + drawMenuActions(); + // the menu holds checkable entries, so every entry keeps the same left margin + const float indent = ImGui::GetFontSize(); + ImGui::Indent(indent); + ImGui::Separator(); + // the zoom entries come from the toolbar, where they are only visible while zoomed + if (can->timeRange().has_value()) { + const std::string undo_text = std::string(icon::ARROW_COUNTERCLOCKWISE) + " Undo Zoom"; + const std::string redo_text = std::string(icon::ARROW_CLOCKWISE) + " Redo Zoom"; + if (ImGui::MenuItem(undo_text.c_str(), nullptr, false, charts_widget_->zoom_undo_stack_.canUndo())) charts_widget_->zoom_undo_stack_.undo(); + if (ImGui::MenuItem(redo_text.c_str(), nullptr, false, charts_widget_->zoom_undo_stack_.canRedo())) charts_widget_->zoom_undo_stack_.redo(); + ImGui::Separator(); + } + if (ImGui::MenuItem("Close")) charts_widget_->removeChart(this); + ImGui::Unindent(indent); + ImGui::EndPopup(); + } +} + +void ChartView::handleMousePress() { + if (drawing_ghost_) return; + const ImVec2 pos = ImGui::GetMousePos(); + // a press on the close/manage buttons does not reach the widget + const bool widget_pressed = ImGui::IsMouseClicked(ImGuiMouseButton_Left) && layout_.rect.Contains(pos) && + ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows | ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) && + !layout_.close_btn_rect.Contains(pos) && !layout_.manage_btn_rect.Contains(pos); + if (!widget_pressed) return; + press_pos_ = pos; + if (layout_.move_icon_rect.Contains(pos)) return; // the move icon press is handled by the grip item (startChartDrag) + + if (ImGui::GetIO().KeyShift) { + // Save current playback state when scrubbing + resume_after_scrub_ = !can->isPaused(); + if (resume_after_scrub_) { + can->pause(true); + } + mouse_mode_ = MouseMode::Scrub; + } else if (layout_.plot_area.Contains(pos)) { + mouse_mode_ = MouseMode::Rubber; + rubber_rect_ = ImRect(); + } +} + +void ChartView::handleMouseMove() { + if (drawing_ghost_) return; + const ImVec2 pos = ImGui::GetMousePos(); + const ImVec2 delta = ImGui::GetIO().MouseDelta; + // a click alone must not hide the tip + if (delta.x == 0 && delta.y == 0) return; + // only the widget under the mouse, or the one dragging, reacts to a move + if (mouse_mode_ == MouseMode::None && !layout_.rect.Contains(pos)) return; + + if (mouse_mode_ == MouseMode::Scrub && ImGui::GetIO().KeyShift) { + if (layout_.plot_area.Contains(pos)) { + can->seekTo(std::clamp(secondsAtPoint(pos), can->minSeconds(), can->maxSeconds())); + } + } + + if (mouse_mode_ == MouseMode::Rubber) { + // horizontal selection, clamped to the plot area + float left = std::clamp(std::min(press_pos_.x, pos.x), layout_.plot_area.Min.x, layout_.plot_area.Max.x); + float right = std::clamp(std::max(press_pos_.x, pos.x), layout_.plot_area.Min.x, layout_.plot_area.Max.x); + rubber_rect_ = ImRect(ImVec2(left, layout_.plot_area.Min.y), ImVec2(right, layout_.plot_area.Max.y)); + } + + clearTrackPoints(); + if (mouse_mode_ != MouseMode::Rubber && layout_.plot_area.Contains(pos) && (layout_.plot_hovered || mouse_mode_ != MouseMode::None) && + ImGui::IsWindowFocused(ImGuiFocusedFlags_AnyWindow)) { + charts_widget_->showValueTip(secondsAtPoint(pos)); + } else if (tip_label_.isVisible()) { + charts_widget_->showValueTip(-1); + } +} + +void ChartView::handleMouseRelease() { + if (drawing_ghost_) return; + const bool left_released = ImGui::IsMouseReleased(ImGuiMouseButton_Left); + const bool right_released = ImGui::IsMouseReleased(ImGuiMouseButton_Right) && layout_.rect.Contains(ImGui::GetMousePos()); + if (!left_released && !right_released) return; + if (left_released && mouse_mode_ == MouseMode::Rubber) { + mouse_mode_ = MouseMode::None; + // Prevent zooming/seeking past the end of the route + double min = std::clamp(secondsAtPoint(rubber_rect_.Min), can->minSeconds(), can->maxSeconds()); + double max = std::clamp(secondsAtPoint(rubber_rect_.Max), can->minSeconds(), can->maxSeconds()); + if (rubber_rect_.GetWidth() <= 0) { + // no rubber dragged, seek to mouse position + can->seekTo(std::clamp(secondsAtPoint(press_pos_), can->minSeconds(), can->maxSeconds())); + } else if (rubber_rect_.GetWidth() > 10 && (max - min) > MIN_ZOOM_SECONDS) { + charts_widget_->zoom_undo_stack_.push(new ZoomCommand({min, max})); + } + rubber_rect_ = ImRect(); + } else if (right_released && !ImGui::IsPopupOpen(context_menu_id_, ImGuiPopupFlags_None)) { + charts_widget_->zoom_undo_stack_.undo(); + } + + if (mouse_mode_ == MouseMode::Scrub) { + mouse_mode_ = MouseMode::None; + if (resume_after_scrub_) { + can->pause(false); + resume_after_scrub_ = false; + } + } +} + +void ChartView::takeSignalsFrom(ChartView *source) { + for (auto &s : source->sigs_) { + sigs_.push_back(std::move(s)); + sigs_.back().color = uniqueColor(sigs_.back().color, sigs_.back().sig); + } + source->sigs_.clear(); + updateAxisY(); + charts_widget_->removeChart(source); +} + +std::vector ChartView::takeExtraSignals() { + std::vector extra; + for (auto it = sigs_.begin() + 1; it != sigs_.end(); ++it) { + it->color = it->sig->color; + extra.push_back(std::move(*it)); + } + sigs_.resize(1); + updateAxisY(); + return extra; +} + +void ChartView::adoptSignal(SigItem s) { + sigs_.push_back(std::move(s)); + updateAxisY(); +} + +void ChartView::showTip(double sec) { + ImRect tip_area(ImVec2(layout_.rect.Min.x, layout_.plot_area.Min.y), ImVec2(layout_.rect.Max.x, layout_.plot_area.Max.y)); + ImRect visible_rect = charts_widget_->chartVisibleRect(this); + visible_rect.ClipWith(tip_area); + if (visible_rect.GetWidth() <= 0 || visible_rect.GetHeight() <= 0) { + tip_label_.hide(); + return; + } + + tooltip_x_ = xPos(sec); + float x = -1; + std::vector text_list; + for (auto &s : sigs_) { + if (s.visible) { + std::string value = "--"; + if (const ImPlotPoint *pt = lastPointBefore(s, sec)) { + value = s.sig->formatValue(pt->y, false); + s.track_pt = *pt; + x = std::max(x, xPos(pt->x)); + } + std::string name = sigs_.size() > 1 ? s.sig->name + ": " : ""; + std::string min = s.min == std::numeric_limits::max() ? "--" : utils::toString(s.min); + std::string max = s.max == std::numeric_limits::lowest() ? "--" : utils::toString(s.max); + text_list.push_back({.has_marker = true, .marker = toImU32(s.color), .name = name, .bold = value, .rest = " (" + min + ", " + max + ")"}); + } + } + if (x < 0) { + x = tooltip_x_; + } + ImVec2 pt(x, layout_.plot_area.Min.y); + text_list.insert(text_list.begin(), TipLine{.name = formatNumber(secondsAtPoint({x, 0}), 3)}); + tip_label_.showText(pt, text_list, visible_rect); +} + +void ChartView::hideTip() { + clearTrackPoints(); + tooltip_x_ = -1; + tip_label_.hide(); +} + +void ChartView::draw(float width) { + ImGui::PushID(this); + width = std::max(width, (float)CHART_MIN_WIDTH); + layout_.plot_hovered = false; + // the tile geometry is known before the child is entered, so it stays valid when imgui culls a scrolled out chart + const ImVec2 tile_pos = ImGui::GetCursorScreenPos(); + const ImVec2 tile_size(width, (float)settings.chart_height); + layout_.rect = ImRect(tile_pos, tile_pos + tile_size); + if (ImGui::BeginChild("chart", tile_size, ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { + updateLayout(); + paint(); + drawContextMenu(); + } + ImGui::EndChild(); + // a chart scrolled out of the viewport draws no tip + const ImRect visible_rect = charts_widget_->chartVisibleRect(this); + if (!drawing_ghost_ && visible_rect.GetWidth() > 0 && visible_rect.GetHeight() > 0) tip_label_.draw(); + ImGui::PopID(); +} + +void ChartView::drawGhost(float width) { + // the ghost is drawn in its own window: keep the geometry of the live tile so hit testing stays correct + drawing_ghost_ = true; + const Layout saved = layout_; + draw(width); + layout_ = saved; + drawing_ghost_ = false; +} + +void ChartView::paint() { + drawStaticLayer(); + + if (can_drop_) { + ImGui::GetWindowDrawList()->AddRect(layout_.rect.Min, layout_.rect.Max, ImGui::GetColorU32(ImGuiCol_Header), 0.0f, 0, 4.0f); + } +} + +void ChartView::drawStaticLayer() { + ImDrawList *painter = ImGui::GetWindowDrawList(); + painter->AddRectFilled(layout_.rect.Min, layout_.rect.Max, ImGui::GetColorU32(ImGuiCol_ChildBg)); + ImGui::SetCursorScreenPos(layout_.move_icon_rect.Min); + ImGui::InvisibleButton("grip", layout_.move_icon_rect.GetSize()); + if (ImGui::IsItemActivated()) charts_widget_->startChartDrag(this, ImGui::GetMousePos()); + if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + painter->AddText(layout_.move_icon_rect.Min, ImGui::GetColorU32(ImGuiCol_Text), icon::GRIP_HORIZONTAL); + createToolButtons(); + drawLegend(); + drawSignalValue(); // drawn here because implot clips the plot frame + drawAxes(); +} + +void ChartView::drawAxes() { + ImGui::SetCursorScreenPos(ImVec2(layout_.rect.Min.x, layout_.header_bottom)); + const float plot_h = std::max(layout_.rect.Max.y - layout_.header_bottom - LAYOUT_MARGINS.w, 10.0f); + ImPlot::PushStyleVar(ImPlotStyleVar_PlotPadding, ImVec2(LAYOUT_MARGINS.x, AXIS_X_TOP_MARGIN)); + ImPlot::PushStyleColor(ImPlotCol_PlotBg, ImVec4(0, 0, 0, 0)); + ImPlot::PushStyleColor(ImPlotCol_FrameBg, ImVec4(0, 0, 0, 0)); + // every tick is a 1 px line in the text color at alpha 50, the edge ticks close the box, no tick marks. + // that alpha washes out on the dark base, so the dark theme draws opaque guides in a mid gray instead. + const bool dark = isDarkTheme(); + ImVec4 grid_color; + if (dark) { + grid_color = colorRgb(DarkTheme::light.r, DarkTheme::light.g, DarkTheme::light.b); + } else { + grid_color = ImGui::GetStyleColorVec4(ImGuiCol_Text); + grid_color.w = 50.0f / 255.0f; + } + ImPlot::PushStyleColor(ImPlotCol_AxisGrid, grid_color); + ImPlot::PushStyleColor(ImPlotCol_PlotBorder, grid_color); + ImPlot::PushStyleColor(ImPlotCol_AxisTick, ImVec4(0, 0, 0, 0)); + ImPlot::PushStyleColor(ImPlotCol_AxisText, ImGui::GetStyleColorVec4(ImGuiCol_Text)); + ImPlot::PushStyleVar(ImPlotStyleVar_MajorTickLen, ImVec2(0, 0)); + // MajorGridSize is the per-axis line thickness; thicker guides read better on the dark base + ImPlot::PushStyleVar(ImPlotStyleVar_MajorGridSize, dark ? ImVec2(2.0f, 2.0f) : ImVec2(1.0f, 1.0f)); + const ImPlotFlags flags = ImPlotFlags_NoTitle | ImPlotFlags_NoLegend | ImPlotFlags_NoMenus | ImPlotFlags_NoMouseText | + ImPlotFlags_NoBoxSelect | ImPlotFlags_NoInputs | ImPlotFlags_NoFrame; + const ImPlotAxisFlags axis_flags = ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight | ImPlotAxisFlags_NoSideSwitch | ImPlotAxisFlags_Lock; + // reserve room for the right half of the last x tick label + const float x_label_width = ImGui::CalcTextSize(formatNumber(x_max_, xAxisPrecision()).c_str()).x + 5; + if (ImPlot::BeginPlot("##plot", ImVec2(layout_.rect.GetWidth() - x_label_width / 2, plot_h), flags)) { + ImPlot::SetupAxis(ImAxis_X1, nullptr, axis_flags); + ImPlot::SetupAxis(ImAxis_Y1, y_unit_.empty() ? nullptr : y_unit_.c_str(), axis_flags); + ImPlot::SetupAxisLimits(ImAxis_X1, x_min_, x_max_, ImPlotCond_Always); + ImPlot::SetupAxisLimits(ImAxis_Y1, y_min_, y_max_, ImPlotCond_Always); + // the format must be set before the ticks are generated + ImPlot::SetupAxisFormat(ImAxis_Y1, ("%." + std::to_string(y_precision_) + "f").c_str()); + ImPlot::SetupAxisTicks(ImAxis_Y1, y_min_, y_max_, y_tick_count_); + ImPlot::SetupAxisFormat(ImAxis_X1, ("%." + std::to_string(xAxisPrecision()) + "f").c_str()); + ImPlot::SetupAxisTicks(ImAxis_X1, x_min_, x_max_, X_TICK_COUNT); + ImPlot::SetupFinish(); + + layout_.plot_area = ImRect(ImPlot::GetPlotPos(), ImPlot::GetPlotPos() + ImPlot::GetPlotSize()); + // ImPlotFlags_NoInputs disables implot's own hover tracking + layout_.plot_hovered = layout_.plot_area.Contains(ImGui::GetMousePos()) && ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows | ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); + drawSeries(); + handleMousePress(); + handleMouseMove(); + handleMouseRelease(); + drawForeground(); + ImPlot::EndPlot(); + } + ImPlot::PopStyleColor(6); + ImPlot::PopStyleVar(3); +} + +void ChartView::drawLegend() { + ImDrawList *painter = ImGui::GetWindowDrawList(); + const ImU32 title_color = ImGui::GetColorU32(ImGuiCol_Text); + // Draw message details in similar color, but slightly fade it to the background + const ImU32 msg_color = withAlpha(title_color, 180); + ImFont *bold = boldFont(); + ImFont *normal = ImGui::GetFont(); + const float font_size = ImGui::GetFontSize(); + const float marker_size = markerSize(); + + for (int i = 0; i < sigs_.size() && i < layout_.legend_rects.size(); ++i) { + const auto &s = sigs_[i]; + const ImRect &r = layout_.legend_rects[i]; + // toggle series visibility by clicking its legend entry + ImGui::PushID(i); + ImGui::SetCursorScreenPos(r.Min); + if (ImGui::InvisibleButton("legend", ImVec2(std::max(r.GetWidth(), 1.0f), std::max(r.GetHeight(), 1.0f))) && + mouse_mode_ == MouseMode::None && sigs_.size() > 1) { + sigs_[i].visible = !sigs_[i].visible; + updateAxisY(); + } + ImGui::PopID(); + + if (series_type_ == SeriesType::Scatter) { + painter->AddCircleFilled(r.Min + ImVec2(marker_size / 2.0f, 2.0f + marker_size / 2.0f), marker_size / 2.0f, toImU32(s.color)); + } else { + drawColorMarker(painter, r.Min, toImU32(s.color)); + } + + float x = r.Min.x + marker_size + 5; + const float text_y = r.GetCenter().y - font_size / 2.0f; + addTextEllipsis(painter, bold, title_color, ImVec2(x, text_y), r.Max.x, s.sig->name); + float name_w = std::min(bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, s.sig->name.c_str()).x, r.Max.x - x); + x += name_w; + const std::string msg = msgLabel(s.msg_id); + addTextEllipsis(painter, normal, msg_color, ImVec2(x, text_y), r.Max.x, msg); + if (!s.visible) { // strike out + const float y = r.GetCenter().y; + painter->AddLine(ImVec2(r.Min.x + marker_size + 5, y), ImVec2(std::min(x + ImGui::CalcTextSize(msg.c_str()).x, r.Max.x), y), title_color); + } + } +} + +void ChartView::drawSeries() { + for (int i = 0; i < sigs_.size(); ++i) { + auto &s = sigs_[i]; + if (!s.visible) continue; + + // visible points in vals to compute point density + auto [first, last] = visibleRange(s.vals); + int num_points = std::max(last - first, 1); + double pixels_per_point = 0; + if (first != last) { + const ImPlotPoint &right_pt = last == s.vals.cend() ? s.vals.back() : *last; + pixels_per_point = (xPos(right_pt.x) - xPos(first->x)) / num_points; + } + + const std::string label = "##sig" + std::to_string(i); + ImPlotSpec spec; + spec.LineColor = toImVec4(s.color); + spec.Stride = sizeof(ImPlotPoint); + if (series_type_ == SeriesType::Scatter) { + float radius = std::clamp(pixels_per_point / 2.0, 2.0, 8.0) / 2.0; + spec.Marker = ImPlotMarker_Circle; + spec.MarkerSize = radius; + if (first != last) ImPlot::PlotScatter(label.c_str(), &first->x, &first->y, last - first, spec); + } else { + const auto &points = series_type_ == SeriesType::StepLine ? s.step_vals : s.vals; + // one sample beyond each edge so the line runs out of the plot + auto [begin, end] = visibleRange(points); + if (begin != points.cbegin()) --begin; + if (end != points.cend()) ++end; + if (begin == end) continue; + + spec.LineWeight = 2; + ImPlot::PlotLine(label.c_str(), &begin->x, &begin->y, end - begin, spec); + + // show points when zoomed in enough + if ((num_points == 1 || pixels_per_point > 20) && first != last) { + ImPlotSpec dots; + dots.LineColor = toImVec4(s.color); + dots.Stride = sizeof(ImPlotPoint); + dots.Marker = ImPlotMarker_Circle; + dots.MarkerSize = 4; + ImPlot::PlotScatter((label + "_pts").c_str(), &first->x, &first->y, last - first, dots); + } + } + } +} + +void ChartView::drawForeground() { + drawTimeline(); + ImDrawList *painter = ImPlot::GetPlotDrawList(); + ImPlot::PushPlotClipRect(); + float track_line_x = -1; + for (auto &s : sigs_) { + if (!isNull(s.track_pt) && s.visible) { + ImVec2 pos(xPos(s.track_pt.x), yPos(s.track_pt.y)); + painter->AddCircleFilled(pos, 5.5f, toImU32(s.color.darker(125))); + track_line_x = std::max(track_line_x, pos.x); + } + } + if (track_line_x > 0) { + const ImU32 dark_gray = IM_COL32(0x80, 0x80, 0x80, 0xff); + for (float y = layout_.plot_area.Min.y; y < layout_.plot_area.Max.y; y += 8) { + painter->AddLine(ImVec2(track_line_x, y), ImVec2(track_line_x, std::min(y + 4, layout_.plot_area.Max.y)), dark_gray, 1.0f); + } + } + ImPlot::PopPlotClipRect(); + + drawRubberBandTimeRange(); +} + +void ChartView::drawRubberBandTimeRange() { + if (rubber_rect_.GetWidth() <= 1) return; + + ImDrawList *painter = ImPlot::GetPlotDrawList(); + // ImGuiCol_Header is translucent, so the 1px selection outline is drawn at full alpha + const ImU32 highlight = withAlpha(ImGui::GetColorU32(ImGuiCol_Header), 255); + painter->AddRectFilled(rubber_rect_.Min, rubber_rect_.Max, withAlpha(highlight, 50)); + painter->AddRect(rubber_rect_.Min, rubber_rect_.Max, highlight); + + // time labels at the bottom corners (below the plot, so clip to the widget instead of the plot) + const ImU32 white = IM_COL32_WHITE; + const ImU32 gray = IM_COL32(0xa0, 0xa0, 0xa4, 0xff); + painter = ImGui::GetWindowDrawList(); + painter->PushClipRect(layout_.rect.Min, layout_.rect.Max); + for (const auto &pt : {rubber_rect_.GetBL(), rubber_rect_.GetBR()}) { + std::string sec = formatNumber(secondsAtPoint(pt), 2); + ImVec2 size = ImGui::CalcTextSize(sec.c_str()) + ImVec2(12, AXIS_X_TOP_MARGIN * 2); + ImVec2 top_left = pt.x == rubber_rect_.Min.x ? ImVec2(pt.x - size.x, pt.y + 2) : ImVec2(pt.x, pt.y + 2); + painter->AddRectFilled(top_left, top_left + size, gray); + painter->AddText(top_left + ImVec2(6, AXIS_X_TOP_MARGIN), white, sec.c_str()); + } + painter->PopClipRect(); +} + +void ChartView::drawTimeline() { + ImDrawList *painter = ImPlot::GetPlotDrawList(); + float x = std::clamp(xPos(cur_sec_), layout_.plot_area.Min.x, layout_.plot_area.Max.x); + painter->AddLine(ImVec2(x, layout_.plot_area.Min.y - 1.0f), ImVec2(x, layout_.plot_area.Max.y + 1.0f), ImGui::GetColorU32(ImGuiCol_Text), 1.0f); + + std::string time_str = formatNumber(cur_sec_, 2); + ImVec2 time_str_size = ImGui::CalcTextSize(time_str.c_str()) + ImVec2(8, 2); + ImVec2 time_str_pos(x - time_str_size.x / 2.0f, layout_.plot_area.Max.y + AXIS_X_TOP_MARGIN); + const bool dark = isDarkTheme(); + painter->AddRectFilled(time_str_pos, time_str_pos + time_str_size, dark ? IM_COL32(0x80, 0x80, 0x80, 0xff) : IM_COL32(0xa0, 0xa0, 0xa4, 0xff), 3.0f); + painter->AddText(time_str_pos + ImVec2(4, 1), IM_COL32_WHITE, time_str.c_str()); +} + +void ChartView::drawSignalValue() { + ImDrawList *painter = ImGui::GetWindowDrawList(); + const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text); + for (int i = 0; i < sigs_.size() && i < layout_.legend_rects.size(); ++i) { + const auto &s = sigs_[i]; + const ImPlotPoint *pt = lastPointBefore(s, cur_sec_); + std::string value = pt ? s.sig->formatValue(pt->y) : "--"; + const ImVec2 value_min = layout_.legend_rects[i].GetBL() - ImVec2(0, 1); + ImRect value_rect(value_min, value_min + layout_.legend_rects[i].GetSize()); + float w = ImGui::CalcTextSize(value.c_str()).x; + if (w <= value_rect.GetWidth()) { + painter->AddText(ImVec2(value_rect.GetCenter().x - w / 2, value_rect.Min.y), color, value.c_str()); + } else { + addTextEllipsis(painter, ImGui::GetFont(), color, value_rect.Min, value_rect.Max.x, value); + } + } +} + +CabanaColor ChartView::uniqueColor(CabanaColor color, const cabana::Signal *exclude) const { + for (auto &s : sigs_) { + if (s.sig != exclude && std::abs(color.hsv().hue - s.color.hsv().hue) < 0.1) { + // use different color to distinguish it from others. + auto last_color = sigs_.back().color; + static thread_local std::mt19937 rng{std::random_device{}()}; + std::uniform_int_distribution sat(35, 99); + std::uniform_int_distribution val(85, 99); + color = CabanaColor::fromHsv(std::fmod(last_color.hsv().hue + 60 / 360.0, 1.0), + sat(rng) / 100.0, + val(rng) / 100.0, + color.a / 255.0f); + break; + } + } + return color; +} diff --git a/openpilot/tools/cabana/ui/chart/chart.h b/openpilot/tools/cabana/ui/chart/chart.h new file mode 100644 index 0000000000..fc2b63712c --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/chart.h @@ -0,0 +1,142 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "implot.h" + +#include "tools/cabana/ui/chart/tiplabel.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/utils/util.h" + +enum class SeriesType { + Line = 0, + StepLine, + Scatter +}; +inline constexpr const char *SERIES_TYPE_NAMES[] = {"Line", "Step Line", "Scatter"}; + +// the message part of a legend entry, drawn after the signal name +inline std::string msgLabel(const MessageId &id) { return " " + msgName(id) + " " + id.toString(); } + +class ChartsWidget; +class ChartView { +public: + struct SigItem { + MessageId msg_id; + const cabana::Signal *sig = nullptr; + CabanaColor color; + bool visible = true; + std::vector vals; + std::vector step_vals; + ImPlotPoint track_pt{}; + SegmentTree segment_tree; + double min = 0; + double max = 0; + }; + + ChartView(const std::pair &x_range, ChartsWidget *parent); + void addSignal(const MessageId &msg_id, const cabana::Signal *sig); + bool hasSignal(const MessageId &msg_id, const cabana::Signal *sig) const; + void updateSeries(const cabana::Signal *sig = nullptr, const MessageEventsMap *msg_new_events = nullptr); + void updatePlot(double cur, double min, double max); + void setSeriesType(SeriesType type) { series_type_ = type; } + void showTip(double sec); + void hideTip(); + void draw(float width); // one chart of settings.chart_height + void drawGhost(float width); // the same tile rendered again, without handling any input + void removeIf(std::function predicate); + void takeSignalsFrom(ChartView *source); + // every signal but the first, with its original color, for a split into one chart per signal + std::vector takeExtraSignals(); + void adoptSignal(SigItem s); + void setDropHighlight(bool highlight) { can_drop_ = highlight; } + const std::vector &signals() const { return sigs_; } + const ImRect &rect() const { return layout_.rect; } // the whole chart widget, screen coordinates + bool plotHovered() const { return layout_.plot_hovered; } + double secondsAtPoint(const ImVec2 &pt) const { + return x_min_ + (pt.x - layout_.plot_area.Min.x) * (x_max_ - x_min_) / std::max(layout_.plot_area.GetWidth(), 1.0f); + } + +private: + using PointIter = std::vector::const_iterator; + + void signalUpdated(const cabana::Signal *sig); + void manageSignals(); + void msgRemoved(MessageId id) { removeIf([=](auto &s) { return s.msg_id.address == id.address && !dbc()->msg(id); }); } + void signalRemoved(const cabana::Signal *sig) { removeIf([=](auto &s) { return s.sig == sig; }); } + + void appendCanEvents(const cabana::Signal *sig, const std::vector &events, + std::vector &vals, std::vector &step_vals); + void createToolButtons(); + void drawContextMenu(); + void handleMousePress(); + void handleMouseMove(); + void handleMouseRelease(); + void updateLayout(); + void updateAxisY(); + void paint(); + void drawStaticLayer(); + void drawAxes(); + void drawLegend(); + void drawSeries(); + void drawForeground(); + void drawSignalValue(); + void drawTimeline(); + void drawRubberBandTimeRange(); + void drawMenuActions(); // the series type / manage / split entries shared by the menu button and the context menu + int xAxisPrecision() const; + std::tuple getNiceAxisNumbers(double min, double max, int tick_count); + double niceNumber(double x, bool ceiling); + CabanaColor uniqueColor(CabanaColor color, const cabana::Signal *exclude = nullptr) const; + // the last sample at or before sec, nullptr when there is none inside the visible range + const ImPlotPoint *lastPointBefore(const SigItem &s, double sec) const; + // the samples inside [x_min_, x_max_) + std::pair visibleRange(const std::vector &points) const; + inline void clearTrackPoints() { for (auto &s : sigs_) s.track_pt = {}; } + inline float xPos(double sec) const { return layout_.plot_area.Min.x + (sec - x_min_) / (x_max_ - x_min_) * layout_.plot_area.GetWidth(); } + inline float yPos(double val) const { return layout_.plot_area.Max.y - (val - y_min_) / (y_max_ - y_min_) * layout_.plot_area.GetHeight(); } + + // layout + struct Layout { + ImRect rect; // the whole chart widget, screen coordinates + ImRect plot_area; + ImRect move_icon_rect; + ImRect close_btn_rect; + ImRect manage_btn_rect; + std::vector legend_rects; + float header_bottom = 0; + bool plot_hovered = false; + } layout_; + // axes + double x_min_; + double x_max_; + double y_min_ = 0; + double y_max_ = 1; + int y_tick_count_ = 3; + int y_precision_ = 0; + std::string y_unit_; + // interaction + enum class MouseMode { None, Rubber, Scrub }; + MouseMode mouse_mode_ = MouseMode::None; + ImVec2 press_pos_; + ImRect rubber_rect_; + bool resume_after_scrub_ = false; + bool drawing_ghost_ = false; // drawing the drag preview: no mouse handling, no tip + ImGuiID context_menu_id_ = 0; + + TipLabel tip_label_; + std::vector sigs_; + double cur_sec_ = 0; + SeriesType series_type_ = SeriesType::Line; + bool can_drop_ = false; + double tooltip_x_ = -1; + ChartsWidget *charts_widget_; + Connections connections_; +}; diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.cc b/openpilot/tools/cabana/ui/chart/chartswidget.cc new file mode 100644 index 0000000000..546705d395 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/chartswidget.cc @@ -0,0 +1,674 @@ +#define IMGUI_DEFINE_MATH_OPERATORS // ImVec2 arithmetic, must precede imgui.h +#include "tools/cabana/ui/chart/chartswidget.h" + +#include "tools/cabana/ui/threadpool.h" + +#include +#include +#include +#include + +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/chart/chart.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" + +const int MAX_COLUMN_COUNT = 4; +const int CHART_SPACING = 4; +const int START_DRAG_DISTANCE = 10; +const float LAYOUT_HORIZONTAL_SPACING = 6.0f; +const float MIN_RANGE_SLIDER_WIDTH = 40.0f; + +bool LogSlider::draw(const char *label, float width) { + return fusionSliderInt(label, &pos_, min_, max_, width); +} + +ChartsWidget::ChartsWidget() { + range_slider_.setRange(1, settings.max_cached_minutes * 60); + + tabbar_.setAutoHide(true); + tabbar_.setUsesScrollButtons(true); + tabbar_.setTabsClosable(true); + + column_count_ = std::clamp(settings.chart_column_count, 1, MAX_COLUMN_COUNT); + max_chart_range_ = std::clamp(settings.chart_range, 1, settings.max_cached_minutes * 60); + display_range_ = std::make_pair(can->minSeconds(), can->minSeconds() + max_chart_range_); + range_slider_.setValue(max_chart_range_); + + connections_.push_back(dbc()->fileChanged.connect([this]() { removeAll(); })); + connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &events) { eventsMerged(events); })); + connections_.push_back(can->msgsReceived.connect([this](const std::set *, bool) { updateState(); })); + connections_.push_back(can->seeking.connect([this](double) { updateState(); })); + connections_.push_back(can->timeRangeChanged.connect([this](const auto &) { updateState(); })); + connections_.push_back(settings.changed.connect([this]() { settingChanged(); })); + connections_.push_back(seriesChanged.connect([this]() { updateTabBar(); })); + connections_.push_back(tabbar_.tabCloseRequested.connect([this](int index) { removeTab(index); })); + connections_.push_back(tabbar_.tabContextMenu.connect([this](int index) { + if (ImGui::BeginPopupContextItem()) { + if (ImGui::MenuItem("Close Other Tabs")) { + tabbar_.moveTab(index, 0); + tabbar_.setCurrentIndex(0); + while (tabbar_.count() > 1) removeTab(1); + } + ImGui::EndPopup(); + } + })); + connections_.push_back(tabbar_.currentChanged.connect([this](int index) { + if (index != -1) updateLayout(); + })); + + setIsDocked(true); + newTab(); +} + +ChartsWidget::~ChartsWidget() = default; + +std::string ChartsWidget::whatsThis() const { + return R"( + Chart View
+ Click: Click to seek to a corresponding time.
+ Drag: Zoom into the chart.
+ Shift + Drag: Scrub through the chart to view values.
+ Right Mouse: Open the context menu.
+ )"; +} + +void ChartsWidget::newTab() { + static int tab_unique_id = 0; + int idx = tabbar_.addTab(""); + tabbar_.setTabData(idx, tab_unique_id++); + tabbar_.setCurrentIndex(idx); + updateTabBar(); +} + +void ChartsWidget::removeTab(int index) { + int id = tabbar_.tabData(index); + for (auto &c : std::vector(tab_charts_[id])) { + removeChart(c); + } + tab_charts_.erase(id); + tabbar_.removeTab(index); + updateTabBar(); +} + +void ChartsWidget::updateTabBar() { + for (int i = 0; i < tabbar_.count(); ++i) { + const auto &charts_in_tab = tab_charts_[tabbar_.tabData(i)]; + tabbar_.setTabText(i, "Tab " + std::to_string(i + 1) + " (" + std::to_string((int)charts_in_tab.size()) + ")"); + } +} + +void ChartsWidget::eventsMerged(const MessageEventsMap &new_events) { + std::vector> futures; + for (auto &c : charts_) { + futures.push_back(ThreadPool::instance().run([c = c.get(), &new_events]() { c->updateSeries(nullptr, &new_events); })); + } + for (auto &f : futures) f.get(); +} + +void ChartsWidget::zoomReset() { + can->setTimeRange(std::nullopt); + zoom_undo_stack_.clear(); +} + +ImRect ChartsWidget::chartVisibleRect(ChartView *chart) { + ImRect r = chart->rect(); + r.ClipWith(charts_scroll_viewport_); + return r; +} + +void ChartsWidget::showValueTip(double sec) { + if (chartDragActive()) sec = -1; // no value tip while a drag is in progress + showTip(sec); + if (sec < 0 && !value_tip_visible_) return; + + value_tip_visible_ = sec >= 0; + for (auto c : currentCharts()) { + value_tip_visible_ ? c->showTip(sec) : c->hideTip(); + } +} + +void ChartsWidget::updateState() { + if (charts_.empty()) return; + + const auto &time_range = can->timeRange(); + const double cur_sec = can->currentSec(); + if (!time_range.has_value()) { + double pos = (cur_sec - display_range_.first) / std::max(1.0, max_chart_range_); + if (pos < 0 || pos > 0.8) { + display_range_.first = std::max(can->minSeconds(), cur_sec - max_chart_range_ * 0.1); + } + double max_sec = std::min(display_range_.first + max_chart_range_, can->maxSeconds()); + display_range_.first = std::max(can->minSeconds(), max_sec - max_chart_range_); + display_range_.second = display_range_.first + max_chart_range_; + } + + const auto &range = time_range ? *time_range : display_range_; + for (auto &c : charts_) { + c->updatePlot(cur_sec, range.first, range.second); + } +} + +void ChartsWidget::setMaxChartRange(int value) { + max_chart_range_ = settings.chart_range = value; + updateState(); +} + +void ChartsWidget::setIsDocked(bool docked) { + is_docked_ = docked; + if (!docked) float_window_init_ = true; +} + +void ChartsWidget::drawToolBar() { + beginToolbar(); + float slider_width = 150.0f; + const bool is_zoomed = can->timeRange().has_value(); + + // the labels are captured by reference, they outlive the draw calls below + std::vector items; + items.push_back({toolbarButtonWidth(icon::PLUS_SQUARE), [this]() { + if (toolButton("new_plot_btn", icon::PLUS_SQUARE, "New Chart")) newChart(); + }}); + items.push_back({toolbarButtonWidth(icon::WINDOW_STACK), [this]() { + if (toolButton("new_tab_btn", icon::WINDOW_STACK, "New Tab")) newTab(); + }}); + const std::string title_label = "Charts: " + std::to_string(charts_.size()); + items.push_back({ImGui::CalcTextSize(title_label.c_str()).x + LAYOUT_HORIZONTAL_SPACING, [&title_label]() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(title_label.c_str()); + ImGui::SameLine(0.0f, LAYOUT_HORIZONTAL_SPACING); + ImGui::Dummy(ImVec2(0.0f, 0.0f)); + }}); + + const int type_count = (int)std::size(SERIES_TYPE_NAMES); + const std::string chart_type_text = std::string("Type: ") + SERIES_TYPE_NAMES[std::clamp(settings.chart_series_type, 0, type_count - 1)]; + items.push_back({menuButtonWidth(chart_type_text), [this, &chart_type_text]() { + menuButton("chart_type", chart_type_text, "chart_type_menu"); + if (ImGui::BeginPopup("chart_type_menu")) { + for (int i = 0; i < type_count; ++i) { + if (ImGui::MenuItem(SERIES_TYPE_NAMES[i])) { + settings.chart_series_type = i; + settingChanged(); + } + } + ImGui::EndPopup(); + } + }}); + + const std::string columns_action_text = "Columns: " + std::to_string(column_count_); + if (columns_action_visible_) { + items.push_back({menuButtonWidth(columns_action_text), [this, &columns_action_text]() { + menuButton("columns", columns_action_text, "columns_menu"); + if (ImGui::BeginPopup("columns_menu")) { + for (int i = 0; i < MAX_COLUMN_COUNT; ++i) { + if (ImGui::MenuItem(std::to_string(i + 1).c_str())) setColumnCount(i + 1); + } + ImGui::EndPopup(); + } + }}); + } + + // the spacer right aligns the rest + const size_t spacer_index = items.size(); + size_t slider_index = (size_t)-1; + const std::string range_lb = is_zoomed ? std::string() : utils::formatSeconds(max_chart_range_); + std::string reset_zoom_text; + if (!is_zoomed) { + items.push_back({ImGui::CalcTextSize(range_lb.c_str()).x, [&range_lb]() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(range_lb.c_str()); + }}); + slider_index = items.size(); + items.push_back({slider_width, [this, &slider_width]() { + if (range_slider_.draw("##range_slider", slider_width)) setMaxChartRange(range_slider_.value()); + ImGui::SetItemTooltip("Set the chart range"); + }}); + } else { + char buf[64]; + snprintf(buf, sizeof(buf), "%.2f-%.2f", can->timeRange()->first, can->timeRange()->second); + reset_zoom_text = buf; + items.push_back({toolbarButtonWidth(icon::ARROW_COUNTERCLOCKWISE), [this]() { + ImGui::BeginDisabled(!zoom_undo_stack_.canUndo()); + if (toolButton("undo_zoom", icon::ARROW_COUNTERCLOCKWISE, "Undo Zoom")) zoom_undo_stack_.undo(); + ImGui::EndDisabled(); + }}); + items.push_back({toolbarButtonWidth(icon::ARROW_CLOCKWISE), [this]() { + ImGui::BeginDisabled(!zoom_undo_stack_.canRedo()); + if (toolButton("redo_zoom", icon::ARROW_CLOCKWISE, "Redo Zoom")) zoom_undo_stack_.redo(); + ImGui::EndDisabled(); + }}); + items.push_back({toolbarButtonWidth(std::string(icon::ZOOM_OUT) + " " + reset_zoom_text), [this, &reset_zoom_text]() { + if (toolButton("reset_zoom_btn", icon::ZOOM_OUT, "Reset Zoom", reset_zoom_text.c_str())) zoomReset(); + }}); + } + items.push_back({toolbarButtonWidth(icon::X_SQUARE), [this]() { + ImGui::BeginDisabled(charts_.empty()); + if (toolButton("remove_all_btn", icon::X_SQUARE, "Remove all charts")) removeAll(); + ImGui::EndDisabled(); + }}); + const char *dock_btn_icon = is_docked_ ? icon::ARROW_UP_RIGHT_SQUARE : icon::ARROW_DOWN_LEFT_SQUARE; + items.push_back({toolbarButtonWidth(dock_btn_icon), [this, dock_btn_icon]() { + if (toolButton("dock_btn", dock_btn_icon, is_docked_ ? "Float the charts window" : "Dock the charts window")) toggleChartsDocking(); + }}); + + // the slider shrinks first, the buttons stay pinned to the right edge + if (slider_index != (size_t)-1) { + const float shrink = std::min(slider_width - MIN_RANGE_SLIDER_WIDTH, toolbarWidth(items, spacer_index) - ImGui::GetContentRegionAvail().x); + if (shrink > 0.0f) { + slider_width -= shrink; + items[slider_index].width = slider_width; + } + } + drawToolbar(items, spacer_index); + endToolbar(); +} + +void ChartsWidget::settingChanged() { + if (range_slider_.maximum() != settings.max_cached_minutes * 60) { + range_slider_.setRange(1, settings.max_cached_minutes * 60); + } + for (auto &c : charts_) { + c->setSeriesType((SeriesType)settings.chart_series_type); + } +} + +ChartView *ChartsWidget::findChart(const MessageId &id, const cabana::Signal *sig) { + for (auto &c : charts_) + if (c->hasSignal(id, sig)) return c.get(); + return nullptr; +} + +ChartView *ChartsWidget::createChart(int pos) { + auto chart = std::make_unique(can->timeRange().value_or(display_range_), this); + ChartView *ptr = chart.get(); + pos = std::clamp(pos, 0, (int)charts_.size()); + charts_.insert(charts_.begin() + pos, std::move(chart)); + currentCharts().insert(currentCharts().begin() + pos, ptr); + updateLayout(); + return ptr; +} + +void ChartsWidget::showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge) { + ChartView *chart = findChart(id, sig); + if (show && !chart) { + chart = merge && currentCharts().size() > 0 ? currentCharts().front() : createChart(); + chart->addSignal(id, sig); + updateState(); + } else if (!show && chart) { + chart->removeIf([&](auto &s) { return s.msg_id == id && s.sig == sig; }); + } +} + +void ChartsWidget::splitChart(ChartView *src_chart) { + if (src_chart->signals().size() > 1) { + auto it = std::find_if(charts_.begin(), charts_.end(), [src_chart](auto &c) { return c.get() == src_chart; }); + const int pos = it - charts_.begin() + 1; + for (auto &s : src_chart->takeExtraSignals()) { + createChart(pos)->adoptSignal(std::move(s)); + } + updateState(); + } +} + +std::vector ChartsWidget::serializeChartIds() const { + std::vector chart_ids; + for (auto &c : charts_) { + std::string ids; + for (const auto &s : c->signals()) { + if (!ids.empty()) ids += ','; + ids += s.msg_id.toString() + "|" + s.sig->name; + } + chart_ids.push_back(ids); + } + std::reverse(chart_ids.begin(), chart_ids.end()); + return chart_ids; +} + +void ChartsWidget::restoreChartsFromIds(const std::vector &chart_ids) { + for (const auto &chart_id : chart_ids) { + int index = 0; + for (const auto &part : utils::split(chart_id, ',')) { + const size_t sep = part.find('|'); + if (sep == std::string::npos) continue; + MessageId msg_id = MessageId::fromString(part.substr(0, sep)); + if (auto *msg = dbc()->msg(msg_id)) + if (auto *sig = msg->sig(part.substr(sep + 1))) + showChart(msg_id, sig, true, index++ > 0); + } + } +} + +void ChartsWidget::setColumnCount(int n) { + n = std::clamp(n, 1, MAX_COLUMN_COUNT); + if (column_count_ != n) { + column_count_ = settings.chart_column_count = n; + updateLayout(); + } +} + +void ChartsWidget::updateLayout() { + // the container has not been drawn yet (docked/floated this frame): keep the last known layout + const float container_width = charts_container_.geometry().GetWidth(); + if (container_width <= 0) return; + + int n = MAX_COLUMN_COUNT; + for (; n > 1; --n) { + if ((n * CHART_MIN_WIDTH + (n - 1) * CHART_SPACING) < container_width) break; + } + + columns_action_visible_ = n > 1; + current_column_count_ = std::min(column_count_, n); +} + +void ChartsWidget::startChartDrag(ChartView *chart, const ImVec2 &global_pos) { + stopAutoScroll(); + drag_ = {.source = chart, .press_pos = global_pos}; + showValueTip(-1); // no value tip while a drag is in progress + // the drag preview re-renders the tile at CHART_MIN_WIDTH + drag_preview_size_ = ImVec2(CHART_MIN_WIDTH, (float)settings.chart_height); +} + +void ChartsWidget::dragChartMove(const ImVec2 &global_pos) { + if (!drag_.active) { + ImVec2 d = global_pos - drag_.press_pos; + if (std::abs(d.x) + std::abs(d.y) < START_DRAG_DISTANCE) return; + drag_.active = true; + drag_preview_visible_ = true; + } + drag_preview_pos_ = global_pos + ImVec2(5, 5); + + // hovering a tab switches to it so the chart can be dropped into another tab + int tab = tabbar_.tabAt(global_pos); + if (tab >= 0 && tab != tabbar_.currentIndex()) { + tabbar_.setCurrentIndex(tab); + } + + ChartView *target = nullptr; + for (auto c : currentCharts()) { + if (c != drag_.source && c->rect().Contains(global_pos)) { + target = c; + break; + } + } + if (std::exchange(drop_target_, target) != target) { + for (auto &c : charts_) c->setDropHighlight(c.get() == target); + } + bool in_viewport = charts_scroll_viewport_.Contains(global_pos); + bool on_background = !target && in_viewport && !charts_container_.childAt(global_pos); + charts_container_.setDropIndicator(on_background ? global_pos : ImVec2()); + + if (in_viewport) { + startAutoScroll(global_pos); + } +} + +void ChartsWidget::cancelChartDrag() { + drag_ = {}; + stopAutoScroll(); + drag_preview_visible_ = false; + charts_container_.setDropIndicator({}); + if (auto target = std::exchange(drop_target_, nullptr)) target->setDropHighlight(false); +} + +void ChartsWidget::dragChartRelease(const ImVec2 &global_pos) { + ChartView *source = drag_.source; + bool active = drag_.active; + ChartView *target = drop_target_; + cancelChartDrag(); + if (!active) return; + + bool in_viewport = charts_scroll_viewport_.Contains(global_pos); + if (target) { + // merge source into target + target->takeSignalsFrom(source); + } else if (in_viewport && !charts_container_.childAt(global_pos)) { + // reorder within the current tab + auto w = charts_container_.getDropAfter(global_pos); + if (w != source) { + for (auto &[_, list] : tab_charts_) { + list.erase(std::remove(list.begin(), list.end(), source), list.end()); + } + auto &cur = currentCharts(); + int to = w ? std::find(cur.begin(), cur.end(), w) - cur.begin() + 1 : 0; + cur.insert(cur.begin() + to, source); + updateLayout(); + updateTabBar(); + } + } +} + +void ChartsWidget::drawDragPreview() { + if (!drag_preview_visible_ || !drag_.source) return; + // the drag preview is the whole tile (header + axes + plot) at 50% alpha, re-rendered into a window that + // takes no input, so the live chart keeps handling the mouse. + ImGui::SetNextWindowPos(drag_preview_pos_); + ImGui::SetNextWindowSize(drag_preview_size_); + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, 0.5f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + const ImGuiWindowFlags flags = ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDocking; + if (ImGui::Begin("##chart_drag_ghost", nullptr, flags)) { + drag_.source->drawGhost(drag_preview_size_.x); + } + ImGui::End(); + ImGui::PopStyleVar(3); +} + +void ChartsWidget::startAutoScroll(const ImVec2 &global_pos) { + auto_scroll_pos_ = global_pos; + if (!auto_scroll_timer_active_) auto_scroll_timer_next_ = ImGui::GetTime() + 0.05; + auto_scroll_timer_active_ = true; +} + +void ChartsWidget::stopAutoScroll() { + auto_scroll_timer_active_ = false; + auto_scroll_count_ = 0; +} + +void ChartsWidget::doAutoScroll() { + if (!charts_scroll_) return; + const int page_step = charts_scroll_viewport_.GetHeight(); + if (auto_scroll_count_ < page_step) { + ++auto_scroll_count_; + } + + int value = charts_scroll_->Scroll.y; + ImVec2 pos = auto_scroll_pos_; + ImRect area = charts_scroll_viewport_; + + int new_value = value; + if (pos.y - area.Min.y < settings.chart_height / 2) { + new_value = value - auto_scroll_count_; + } else if (area.Max.y - pos.y < settings.chart_height / 2) { + new_value = value + auto_scroll_count_; + } + new_value = std::clamp(new_value, 0, charts_scroll_->ScrollMax.y); + if (new_value != value) ImGui::SetScrollY(charts_scroll_, new_value); + if (value == new_value) { + stopAutoScroll(); + } else if (chartDragActive()) { + // refresh the drop indicator/target at the new scroll position + dragChartMove(auto_scroll_pos_); + } +} + +void ChartsWidget::newChart() { + execSignalSelector(std::make_unique("New Chart"), nullptr, [this](SignalSelector &dlg) { + const auto &items = dlg.selectedItems(); + if (!items.empty()) { + auto c = createChart(); + for (const auto &it : items) { + c->addSignal(it.msg_id, it.sig); + } + updateState(); + } + }); +} + +void ChartsWidget::execSignalSelector(std::unique_ptr dlg, ChartView *owner, std::function accepted) { + signal_selector_ = std::move(dlg); + signal_selector_owner_ = owner; + signal_selector_accepted_ = std::move(accepted); + signal_selector_->open(); +} + +void ChartsWidget::removeChart(ChartView *chart) { + if (drag_.source == chart) cancelChartDrag(); + if (drop_target_ == chart) drop_target_ = nullptr; + if (signal_selector_owner_ == chart) { + signal_selector_owner_ = nullptr; + signal_selector_accepted_ = nullptr; + } + auto it = std::find_if(charts_.begin(), charts_.end(), [chart](auto &c) { return c.get() == chart; }); + if (it != charts_.end()) { + deleted_charts_.push_back(std::move(*it)); // may be called from the chart's draw; freed next frame + charts_.erase(it); + } + for (auto &[_, list] : tab_charts_) { + list.erase(std::remove(list.begin(), list.end(), chart), list.end()); + } + updateLayout(); + seriesChanged(); +} + +void ChartsWidget::removeAll() { + while (tabbar_.count() > 1) { + tabbar_.removeTab(1); + } + std::vector all; + for (auto &c : charts_) all.push_back(c.get()); + for (auto c : all) removeChart(c); + tab_charts_.clear(); + zoomReset(); +} + +void ChartsWidget::handleEvents() { + // the mouse back button undoes a zoom; there is no swipe-back gesture + if (ImGui::IsMouseClicked(3) && ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows)) { + zoom_undo_stack_.undo(); + } + if (!ImGui::IsWindowFocused(ImGuiFocusedFlags_AnyWindow)) { + if (chartDragActive()) cancelChartDrag(); + showValueTip(-1); + } + + // route all mouse events to the chart drag, even when the source chart is hidden by a tab switch + if (chartDragActive()) { + if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + dragChartMove(ImGui::GetMousePos()); + } else { + dragChartRelease(ImGui::GetMousePos()); + } + } + + if (!value_tip_visible_) return; + + // the tip is drawn on the foreground draw list, so the mouse is never "on the tip" + const ImVec2 delta = ImGui::GetIO().MouseDelta; + if (!any_plot_hovered_ && + (delta.x != 0 || delta.y != 0 || !ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows))) { + showValueTip(-1); // the mouse moved off the plot or out of the charts window + } +} + +void ChartsWidget::draw() { + deleted_charts_.clear(); + // the floating window is a top level window sized to its contents: keep it inside the main viewport so its + // toolbar stays reachable, then let the user resize it + if (float_window_init_ && !is_docked_) { + float_window_init_ = false; + const ImGuiViewport *viewport = ImGui::GetMainViewport(); + const ImVec2 size(viewport->WorkSize.x * 0.6f, viewport->WorkSize.y * 0.6f); + ImGui::SetWindowSize(size); + ImGui::SetWindowPos(viewport->WorkPos + (viewport->WorkSize - size) * 0.5f); + } + ImGui::PushID(this); + if (auto_scroll_timer_active_ && ImGui::GetTime() >= auto_scroll_timer_next_) { + auto_scroll_timer_next_ = ImGui::GetTime() + 0.05; + doAutoScroll(); + } + // the drop target and indicator must be resolved before the charts are painted, otherwise the highlight + // lags a frame behind the target used on release and the drop lands on the wrong chart + handleEvents(); + + drawToolBar(); + tabbar_.draw(); + + any_plot_hovered_ = false; + if (ImGui::BeginChild("charts_scroll", ImVec2(0, 0), ImGuiChildFlags_None, 0)) { + charts_scroll_ = ImGui::GetCurrentWindow(); + charts_scroll_viewport_ = charts_scroll_->InnerRect; + charts_container_.draw(); + } + ImGui::EndChild(); + + drawDragPreview(); + + if (signal_selector_ && !signal_selector_->draw()) { + auto dlg = std::move(signal_selector_); + auto accepted = std::move(signal_selector_accepted_); + signal_selector_owner_ = nullptr; + if (dlg->accepted() && accepted) accepted(*dlg); + } + ImGui::PopID(); +} + +void ChartsContainer::draw() { + ImGuiWindow *window = ImGui::GetCurrentWindow(); + const ImVec2 start = ImGui::GetCursorScreenPos(); + geometry_ = ImRect(start, start + ImVec2(window->InnerRect.GetWidth(), 0)); + charts_widget_->updateLayout(); + + const int n = std::max(charts_widget_->current_column_count_, 1); + const float spacing = CHART_SPACING; + const float width = (geometry_.GetWidth() - (n - 1) * spacing) / n; + const ImVec2 origin = ImGui::GetCursorScreenPos() + ImVec2(0, CHART_SPACING); + auto current_charts = charts_widget_->currentCharts(); // copy: drawing may remove charts + float bottom = origin.y; + const bool aligned = ImPlot::BeginAlignedPlots("charts_align", true); + for (int i = 0; i < current_charts.size(); ++i) { + ImVec2 pos = origin + ImVec2((i % n) * (width + spacing), (i / n) * (settings.chart_height + spacing)); + ImGui::SetCursorScreenPos(pos); + current_charts[i]->draw(width); + bottom = std::max(bottom, pos.y + settings.chart_height); + if (current_charts[i]->plotHovered()) charts_widget_->any_plot_hovered_ = true; // the window must be hovered too + } + if (aligned) ImPlot::EndAlignedPlots(); + ImGui::SetCursorScreenPos(ImVec2(origin.x, bottom)); + ImGui::Dummy(ImVec2(geometry_.GetWidth(), CHART_SPACING)); + geometry_.Max.y = bottom + CHART_SPACING; + drawDropIndicator(); +} + +void ChartsContainer::drawDropIndicator() { + if (!(drop_indicator_pos_.x == 0 && drop_indicator_pos_.y == 0) && !childAt(drop_indicator_pos_)) { + ImRect r = geometry_; + r.Max.y = r.Min.y + CHART_SPACING; + if (auto insert_after = getDropAfter(drop_indicator_pos_)) { + float h = r.GetHeight(); + r.Min.y = insert_after->rect().Max.y; + r.Max.y = r.Min.y + h; + } + + ImGui::GetWindowDrawList()->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_Header)); + } +} + +ChartView *ChartsContainer::getDropAfter(const ImVec2 &pos) const { + const auto &charts = charts_widget_->currentCharts(); + auto it = std::find_if(charts.crbegin(), charts.crend(), [&pos](auto c) { + const ImRect &area = c->rect(); + return pos.x >= area.Min.x && pos.x <= area.Max.x && pos.y >= area.Max.y; + }); + return it == charts.crend() ? nullptr : *it; +} + +ChartView *ChartsContainer::childAt(const ImVec2 &pos) const { + for (auto c : charts_widget_->currentCharts()) { + if (c->rect().Contains(pos)) return c; + } + return nullptr; +} diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.h b/openpilot/tools/cabana/ui/chart/chartswidget.h new file mode 100644 index 0000000000..c8c1ef7e13 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/chartswidget.h @@ -0,0 +1,168 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" + +#include "tools/cabana/ui/chart/signalselector.h" +#include "tools/cabana/ui/widgets/tabbar.h" +#include "tools/cabana/commands.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/utils/util.h" + +const int CHART_MIN_WIDTH = 300; + +// a slider whose value is mapped onto a log10 scale +class LogSlider { +public: + LogSlider(double factor) : scale_(factor) {} + + void setRange(double min, double max) { + scale_.setRange(min, max); + min_ = min; + max_ = max; + setValue(pos_); // the raw position is re-mapped as a value + } + int value() const { return scale_.value(pos_, minimum(), maximum()); } + void setValue(int v) { pos_ = scale_.position(v, minimum(), maximum()); } + int minimum() const { return min_; } + int maximum() const { return max_; } + bool draw(const char *label, float width); + +private: + LogScale scale_; + int min_ = 0; + int max_ = 1; + int pos_ = 0; +}; + +class ChartView; +class ChartsWidget; + +class ChartsContainer { +public: + ChartsContainer(ChartsWidget *parent) : charts_widget_(parent) {} + void setDropIndicator(const ImVec2 &pt) { drop_indicator_pos_ = pt; } + void draw(); // grid layout of the current tab's charts + ChartView *getDropAfter(const ImVec2 &pos) const; + ChartView *childAt(const ImVec2 &pos) const; + const ImRect &geometry() const { return geometry_; } // screen coordinates + +private: + void drawDropIndicator(); + + ImRect geometry_; + ChartsWidget *charts_widget_; + ImVec2 drop_indicator_pos_; +}; + +class ChartsWidget { +public: + ChartsWidget(); + ~ChartsWidget(); // out of line: the header users only see a forward declared ChartView + void draw(); // content only; MainWindow wraps it in a child region or the floating window + void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge); + inline bool hasSignal(const MessageId &id, const cabana::Signal *sig) { return findChart(id, sig) != nullptr; } + std::vector serializeChartIds() const; + void restoreChartsFromIds(const std::vector &chart_ids); + std::string whatsThis() const; + + void setColumnCount(int n); + void removeAll(); + void setIsDocked(bool dock); + + Observable<> toggleChartsDocking; + Observable<> seriesChanged; + Observable showTip; + +private: + void handleEvents(); // the back button, focus loss, the chart drag and the value tip leave + void newChart(); + ChartView *createChart(int pos = 0); + void removeChart(ChartView *chart); + void splitChart(ChartView *chart); + ImRect chartVisibleRect(ChartView *chart); + void eventsMerged(const MessageEventsMap &new_events); + void updateState(); + void zoomReset(); + void startChartDrag(ChartView *chart, const ImVec2 &global_pos); + void dragChartMove(const ImVec2 &global_pos); + void dragChartRelease(const ImVec2 &global_pos); + void cancelChartDrag(); + bool chartDragActive() const { return drag_.source != nullptr; } + void startAutoScroll(const ImVec2 &global_pos); + void stopAutoScroll(); + void doAutoScroll(); + void drawToolBar(); + void updateTabBar(); + void setMaxChartRange(int value); + void updateLayout(); + void settingChanged(); + void showValueTip(double sec); + void newTab(); + void removeTab(int index); + inline std::vector ¤tCharts() { return tab_charts_[tabbar_.tabData(tabbar_.currentIndex())]; } + ChartView *findChart(const MessageId &id, const cabana::Signal *sig); + // draws the selector until closed, then runs `accepted` (unless `owner` was removed) + void execSignalSelector(std::unique_ptr dlg, ChartView *owner, std::function accepted); + void drawDragPreview(); + + LogSlider range_slider_{1000}; + bool is_docked_ = true; + bool float_window_init_ = false; // the floating window geometry is set once, right after undocking + + UndoStack zoom_undo_stack_; + + std::vector> charts_; + std::unordered_map> tab_charts_; + TabBar tabbar_; + ChartsContainer charts_container_{this}; + ImGuiWindow *charts_scroll_ = nullptr; // the scroll area child window + ImRect charts_scroll_viewport_; + int max_chart_range_ = 0; + std::pair display_range_; + bool columns_action_visible_ = false; + int column_count_ = 1; + int current_column_count_ = 0; + struct ChartDrag { + ChartView *source = nullptr; + ImVec2 press_pos; // global + bool active = false; + } drag_; + // the drag preview is a 50% alpha copy of the whole chart tile, drawn in a window that takes no input + ImVec2 drag_preview_pos_; + ImVec2 drag_preview_size_; + bool drag_preview_visible_ = false; + ChartView *drop_target_ = nullptr; + int auto_scroll_count_ = 0; + ImVec2 auto_scroll_pos_; + bool auto_scroll_timer_active_ = false; + double auto_scroll_timer_next_ = 0; + bool value_tip_visible_ = false; + bool any_plot_hovered_ = false; + std::vector> deleted_charts_; // freed at the start of the next draw() + std::unique_ptr signal_selector_; + ChartView *signal_selector_owner_ = nullptr; + std::function signal_selector_accepted_; + Connections connections_; + friend class ChartView; + friend class ChartsContainer; +}; + +class ZoomCommand : public UndoCommand { +public: + ZoomCommand(std::pair range) : range(range) { + prev_range = can->timeRange(); + } + void undo() override { can->setTimeRange(prev_range); } + void redo() override { can->setTimeRange(range); } + std::optional> prev_range, range; +}; diff --git a/openpilot/tools/cabana/ui/chart/signalselector.cc b/openpilot/tools/cabana/ui/chart/signalselector.cc new file mode 100644 index 0000000000..f0f427222e --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/signalselector.cc @@ -0,0 +1,155 @@ +#include "tools/cabana/ui/chart/signalselector.h" + +#include +#include + +#include "imgui.h" +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/ui/chart/chart.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" + +SignalSelector::SignalSelector(std::string title) : title_(std::move(title)) { + for (const auto &[id, _] : can->lastMessages()) { + if (auto m = dbc()->msg(id)) { + msgs_combo_.push_back({m->name + " (" + id.toString() + ")", id}); + } + } + std::sort(msgs_combo_.begin(), msgs_combo_.end(), [](auto &a, auto &b) { return a.text < b.text; }); +} + +bool SignalSelector::draw() { + if (!open_) return false; + const std::string popup_id = title_ + "###SignalSelector"; + if (!show_) { + ImGui::OpenPopup(popup_id.c_str()); + show_ = true; + } + setNextDialogWindow(ImVec2(700.0f, 450.0f)); + if (!ImGui::BeginPopupModal(popup_id.c_str(), nullptr, ImGuiWindowFlags_NoSavedSettings)) { + open_ = false; + return false; + } + + const float btn_w = ImGui::GetFrameHeight() + 8.0f; + const float column_w = (ImGui::GetContentRegionAvail().x - btn_w - ImGui::GetStyle().ItemSpacing.x * 2) / 2; + // the selected list spans the combo row too; both lists end above the Ok/Cancel row + const float lists_h = ImGui::GetContentRegionAvail().y - ImGui::GetFrameHeightWithSpacing() * 3; + + ImGui::BeginGroup(); + ImGui::TextUnformatted("Available Signals"); + // a combo popup with a filter box + const char *preview = msgs_combo_index_ >= 0 ? msgs_combo_[msgs_combo_index_].text.c_str() : "Select a msg..."; + ImGui::SetNextItemWidth(column_w); + if (ImGui::BeginCombo("##msgs_combo", preview)) { + if (ImGui::IsWindowAppearing()) { + msgs_combo_filter_.clear(); // reopen showing the full list + ImGui::SetKeyboardFocusHere(); + } + ImGui::SetNextItemWidth(-FLT_MIN); + inputText("##msgs_filter", &msgs_combo_filter_, "Select a msg..."); + for (int i = 0; i < (int)msgs_combo_.size(); ++i) { + if (!msgs_combo_filter_.empty() && !utils::containsCI(msgs_combo_[i].text, msgs_combo_filter_)) continue; + if (ImGui::Selectable(msgs_combo_[i].text.c_str(), i == msgs_combo_index_)) { + msgs_combo_index_ = i; + updateAvailableList(i); + ImGui::CloseCurrentPopup(); + } + } + ImGui::EndCombo(); + } + bool add_dbl = false; + drawList("##available_list", available_list_, &available_row_, false, &add_dbl, ImVec2(column_w, lists_h)); + ImGui::EndGroup(); + + ImGui::SameLine(); + ImGui::BeginGroup(); + ImGui::Dummy(ImVec2(btn_w, (lists_h + ImGui::GetFrameHeightWithSpacing() * 2) / 2 - ImGui::GetFrameHeight())); + ImGui::BeginDisabled(available_row_ == -1); + bool add_clicked = ImGui::Button(icon::CHEVRON_RIGHT, ImVec2(btn_w, 0)); + ImGui::EndDisabled(); + ImGui::BeginDisabled(selected_row_ == -1); + bool remove_clicked = ImGui::Button(icon::CHEVRON_LEFT, ImVec2(btn_w, 0)); + ImGui::EndDisabled(); + ImGui::EndGroup(); + + ImGui::SameLine(); + ImGui::BeginGroup(); + ImGui::TextUnformatted("Selected Signals"); + bool remove_dbl = false; + drawList("##selected_list", selected_list_, &selected_row_, true, &remove_dbl, ImVec2(column_w, lists_h + ImGui::GetFrameHeightWithSpacing())); + bool rejected = false; + dialogButtons("OK", &accepted_, &rejected); + const bool done = accepted_ || rejected; + ImGui::EndGroup(); + + if ((add_dbl || add_clicked) && available_row_ >= 0 && available_row_ < (int)available_list_.size()) { + add(available_row_); + } else if ((remove_dbl || remove_clicked) && selected_row_ >= 0 && selected_row_ < (int)selected_list_.size()) { + remove(selected_row_); + } + + if (done) { + open_ = false; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + return open_; +} + +void SignalSelector::drawList(const char *id, std::vector &list, int *current_row, bool show_msg_name, bool *double_clicked, const ImVec2 &size) { + if (!ImGui::BeginListBox(id, size)) return; + for (int i = 0; i < (int)list.size(); ++i) { + const auto &item = list[i]; + ImGui::PushID(i); + const ImVec2 pos = ImGui::GetCursorScreenPos(); + if (ImGui::Selectable("##item", i == *current_row)) *current_row = i; + if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + *current_row = i; + *double_clicked = true; + } + // label: colored square, signal name, then the message name/id in gray + ImDrawList *dl = ImGui::GetWindowDrawList(); + float x = pos.x + 5; + drawColorMarker(dl, ImVec2(x, pos.y), toImU32(item.sig->color)); + x += markerSize() + 4; + dl->AddText(ImVec2(x, pos.y), ImGui::GetColorU32(ImGuiCol_Text), item.sig->name.c_str()); + if (show_msg_name) { + x += ImGui::CalcTextSize(item.sig->name.c_str()).x; + dl->AddText(ImVec2(x, pos.y), ImGui::GetColorU32(ImGuiCol_TextDisabled), msgLabel(item.msg_id).c_str()); + } + ImGui::PopID(); + } + ImGui::EndListBox(); +} + +void SignalSelector::add(int row) { + const auto &item = available_list_[row]; + selected_list_.emplace_back(item.msg_id, item.sig); + available_list_.erase(available_list_.begin() + row); + available_row_ = -1; +} + +void SignalSelector::remove(int row) { + const auto &item = selected_list_[row]; + if (msgs_combo_index_ >= 0 && item.msg_id == msgs_combo_[msgs_combo_index_].id) { + available_list_.emplace_back(item.msg_id, item.sig); + } + selected_list_.erase(selected_list_.begin() + row); + selected_row_ = -1; +} + +void SignalSelector::updateAvailableList(int index) { + if (index == -1) return; + available_list_.clear(); + available_row_ = -1; + MessageId msg_id = msgs_combo_[index].id; + for (auto s : dbc()->msg(msg_id)->getSignals()) { + bool is_selected = std::any_of(selected_list_.begin(), selected_list_.end(), + [sig = s, &msg_id](auto &it) { return it.msg_id == msg_id && it.sig == sig; }); + if (!is_selected) { + available_list_.emplace_back(msg_id, s); + } + } +} diff --git a/openpilot/tools/cabana/ui/chart/signalselector.h b/openpilot/tools/cabana/ui/chart/signalselector.h new file mode 100644 index 0000000000..675b6f0039 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/signalselector.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +#include "imgui.h" +#include "tools/cabana/dbc/dbcmanager.h" + +// non-blocking: open(), draw() every frame until it returns false, then check accepted() +class SignalSelector { +public: + struct ListItem { + ListItem(const MessageId &msg_id, const cabana::Signal *sig) : msg_id(msg_id), sig(sig) {} + MessageId msg_id; + const cabana::Signal *sig; + }; + + SignalSelector(std::string title); + const std::vector &selectedItems() const { return selected_list_; } + inline void addSelected(const MessageId &id, const cabana::Signal *sig) { selected_list_.emplace_back(id, sig); } + void open() { open_ = true; show_ = false; accepted_ = false; } + bool draw(); // false once the dialog is closed + bool accepted() const { return accepted_; } + +private: + void updateAvailableList(int index); + void add(int row); + void remove(int row); + void drawList(const char *id, std::vector &list, int *current_row, bool show_msg_name, bool *double_clicked, const ImVec2 &size); + + struct ComboItem { + std::string text; + MessageId id; + }; + std::string title_; + std::vector msgs_combo_; + int msgs_combo_index_ = -1; + std::string msgs_combo_filter_; + std::vector available_list_; + std::vector selected_list_; + int available_row_ = -1; + int selected_row_ = -1; + bool accepted_ = false; + bool open_ = false; + bool show_ = false; +}; diff --git a/openpilot/tools/cabana/ui/chart/sparkline.cc b/openpilot/tools/cabana/ui/chart/sparkline.cc new file mode 100644 index 0000000000..226483fd04 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/sparkline.cc @@ -0,0 +1,177 @@ +#include "tools/cabana/ui/chart/sparkline.h" + +#include +#include +#include + +#include "tools/cabana/ui/util.h" + +void Sparkline::update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, int range, ImVec2 sz, + double window_end) { + if (first == last || sz.x <= 0 || sz.y <= 0) { + render_points_.clear(); + size = {}; + return; + } + + points_.clear(); + min_val = std::numeric_limits::max(); + max_val = std::numeric_limits::lowest(); + points_.reserve(std::distance(first, last)); + + // x runs from the start of the time window, not from the first sample in it: the oldest sample drops + // out at its own rate, so anchoring to it slid the whole curve sideways by the sample spacing on every + // update and the sparkline jittered left and right instead of scrolling with the clock + const double window_start = window_end - range; + double value = 0.0; + for (auto it = first; it != last; ++it) { + if (sig->getValue((*it)->dat, (*it)->size, &value)) { + double x = can->toSeconds((*it)->mono_time) - window_start; + // the caller hands over a bit of data from before the window so the oldest samples walk out under + // the clip rect instead of vanishing at the left edge; they must not move the scale though + if (x >= 0.0) { + min_val = std::min(min_val, value); + max_val = std::max(max_val, value); + } + points_.push_back({x, value}); + } + } + if (min_val > max_val) { // nothing inside the window, the lead in is all there is + for (const auto &p : points_) { + min_val = std::min(min_val, p.y); + max_val = std::max(max_val, p.y); + } + } + + if (points_.empty()) { + render_points_.clear(); + size = {}; + return; + } + + freq_ = points_.size() / std::max(points_.back().x - points_.front().x, 1.0); + render(sig->color, range, sz, window_end); +} + +void Sparkline::render(const CabanaColor &color, int range, ImVec2 sz, double window_end) { + bool is_flat_line = min_val == max_val; + if (is_flat_line) { + min_val -= 1.0; + max_val += 1.0; + } + + const double xscale = (sz.x - 1) / (double)range; + const double yscale = (sz.y - 3) / (max_val - min_val); + const double span = points_.back().x - points_.front().x; + bool draw_individual_points = (span * xscale / points_.size()) > 8.0; + + // transform or downsample the points + render_points_.reserve(points_.size()); + render_points_.clear(); + if (draw_individual_points) { + for (const auto &p : points_) { + render_points_.emplace_back(p.x * xscale, 1.0 + (max_val - p.y) * yscale); + } + } else if (is_flat_line) { + double y = sz.y / 2.0; + render_points_.emplace_back(points_.front().x * xscale, y); + render_points_.emplace_back(points_.back().x * xscale, y); + } else { + double prev_y = points_.front().y; + render_points_.emplace_back(points_.front().x * xscale, 1.0 + (max_val - prev_y) * yscale); + bool in_flat = false; + + for (size_t i = 1; i < points_.size(); ++i) { + const auto &p = points_[i]; + double y = p.y; + if (std::abs(y - prev_y) < 1e-6) { + in_flat = true; + } else { + if (in_flat) render_points_.emplace_back(points_[i - 1].x * xscale, 1.0 + (max_val - prev_y) * yscale); + render_points_.emplace_back(p.x * xscale, 1.0 + (max_val - y) * yscale); + in_flat = false; + } + prev_y = y; + } + if (in_flat) render_points_.emplace_back(points_.back().x * xscale, 1.0 + (max_val - prev_y) * yscale); + } + + size = sz; + CabanaColor line_color = color; + if (!isDarkTheme()) { + auto [h, s, v] = color.hsv(); + line_color = CabanaColor::fromHsv(h, std::min(1.0f, s * 2.0f), v * 0.7f, color.a / 255.0f); + } + color_ = toImU32(line_color); + draw_individual_points_ = draw_individual_points; + window_end_ = window_end; + xscale_ = xscale; +} + +void Sparkline::draw(ImDrawList *draw_list, ImVec2 pos) const { + if (render_points_.empty()) return; + + // update() only runs when a message of this id arrives, so a slow message would hold the sparkline + // still for many frames and then move it in one step. scroll the rendered polyline by the time that + // has passed since it was built, which keeps the motion at the frame rate whatever the message rate is + const float shift = std::clamp((float)((can->currentSec() - window_end_) * xscale_), 0.0f, size.x); + + // physical pixels: the framebuffer is 2x the logical size on hidpi displays + const float px = 1.0f / std::max(1.0f, ImGui::GetIO().DisplayFramebufferScale.x); + auto snap = [&](float x) { return std::floor(x / px) * px; }; + + // a sample sits at t * xscale + k on screen, where k moves with the clock. snapping k to whole pixels + // keeps every sample's subpixel phase fixed to its timestamp, so the line looks identical from frame + // to frame and from update to update and only ever moves by whole pixels; moving it by fractions + // shifted the antialiasing coverage every frame and the thin peaks sparkled + ImVec2 offset(pos.x - shift, pos.y); + const double k = offset.x - (window_end_ * xscale_ - (size.x - 1)); + offset.x += snap(k) - k; + auto point_at = [&](const ImVec2 &p) { return ImVec2(offset.x + p.x, offset.y + p.y); }; + + draw_list->PushClipRect(pos, ImVec2(pos.x + size.x, pos.y + size.y), true); + + // a point is a 3x3 square + auto draw_point = [&](const ImVec2 &p) { draw_list->AddRectFilled(ImVec2(p.x - 1.5f, p.y - 1.5f), ImVec2(p.x + 1.5f, p.y + 1.5f), color_); }; + + if (draw_individual_points_) { + for (const auto &p : render_points_) { + draw_list->PathLineTo(point_at(p)); + draw_point(point_at(p)); + } + draw_list->PathStroke(color_, ImDrawFlags_None, 1.5f); + } else { + // one sample per pixel column: several strokes in a column overlap into a blur, and a dense + // high-contrast texture scrolling by is hard on the eyes + std::vector pts; + pts.reserve(render_points_.size()); + float col = -1e9f; + for (const auto &p : render_points_) { + ImVec2 sp = point_at(p); + float c = snap(sp.x); + if (c != col) { + pts.push_back(sp); + col = c; + } + } + + // antialiasing smooths the gentle slopes but smears the near-vertical segments of a spiky signal + // over neighboring columns, so those are drawn aliased. runs of one kind are stroked together and + // share their end points with the next run + auto steep = [&](size_t i) { return std::abs(pts[i + 1].y - pts[i].y) > 2.0f * std::abs(pts[i + 1].x - pts[i].x) + px; }; + const ImDrawListFlags saved = draw_list->Flags; + size_t i = 0; + while (i + 1 < pts.size()) { + const bool is_steep = steep(i); + size_t j = i + 1; + while (j + 1 < pts.size() && steep(j) == is_steep) ++j; + draw_list->Flags = is_steep ? (saved & ~ImDrawListFlags_AntiAliasedLines) : saved; + for (size_t n = i; n <= j; ++n) draw_list->PathLineTo(pts[n]); + draw_list->PathStroke(color_, ImDrawFlags_None, 1.0f); + i = j; + } + draw_list->Flags = saved; + draw_point(point_at(render_points_.back())); + } + draw_list->PopClipRect(); +} diff --git a/openpilot/tools/cabana/ui/chart/sparkline.h b/openpilot/tools/cabana/ui/chart/sparkline.h new file mode 100644 index 0000000000..bfee34c9ee --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/sparkline.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include "imgui.h" +#include "tools/cabana/dbc/dbc.h" +#include "tools/cabana/streams/abstractstream.h" + +class Sparkline { +public: + void update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, int range, ImVec2 sz, double window_end); + inline double freq() const { return freq_; } + bool isEmpty() const { return render_points_.empty(); } + // emits the rendered polyline at pos (top-left, screen coordinates) + void draw(ImDrawList *draw_list, ImVec2 pos) const; + + ImVec2 size = {}; // empty when isEmpty() + double min_val = 0; + double max_val = 0; + +private: + struct Point { + double x, y; + }; + void render(const CabanaColor &color, int range, ImVec2 sz, double window_end); + + std::vector points_; + std::vector render_points_; + ImU32 color_ = 0; + double window_end_ = 0; // the time the polyline was built for, so draw() can scroll it on from there + double xscale_ = 0; + bool draw_individual_points_ = false; + double freq_ = 0; +}; diff --git a/openpilot/tools/cabana/ui/chart/tiplabel.cc b/openpilot/tools/cabana/ui/chart/tiplabel.cc new file mode 100644 index 0000000000..896abb7479 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/tiplabel.cc @@ -0,0 +1,69 @@ +#define IMGUI_DEFINE_MATH_OPERATORS // ImVec2 arithmetic, must precede imgui.h +#include "tools/cabana/ui/chart/tiplabel.h" + +#include +#include + +#include "tools/cabana/ui/util.h" + +ImVec2 TipLabel::layoutLines(ImDrawList *p, const ImVec2 &origin, ImU32 fg) const { + ImFont *bold = boldFont(); + const float font_size = ImGui::GetFontSize(); + const float line_height = ImGui::GetTextLineHeight(); + ImVec2 size(0, 0); + float y = origin.y; + for (const auto &line : text_) { + float x = origin.x; + if (line.has_marker) { + if (p) drawColorMarker(p, ImVec2(x, y), line.marker); + x += markerSize() + 4; + } + if (p) p->AddText(ImVec2(x, y), fg, line.name.c_str()); + x += ImGui::CalcTextSize(line.name.c_str()).x; + if (!line.bold.empty()) { + if (p) p->AddText(bold, font_size, ImVec2(x, y), fg, line.bold.c_str()); + x += bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, line.bold.c_str()).x; + } + if (p) p->AddText(ImVec2(x, y), fg, line.rest.c_str()); + x += ImGui::CalcTextSize(line.rest.c_str()).x; + size.x = std::max(size.x, x - origin.x); + y += line_height; + } + size.y = y - origin.y; + return size; +} + +ImVec2 TipLabel::sizeHint() const { + return layoutLines(nullptr, ImVec2(0, 0), 0) + ImVec2(MARGIN * 2, MARGIN * 2); +} + +void TipLabel::showText(const ImVec2 &pt, const std::vector &text, const ImRect &rect) { + text_ = text; + if (!text_.empty()) { + ImVec2 extra(1, 1); + size_ = sizeHint() + extra; + ImVec2 tip_pos(pt.x + 8, rect.Min.y + 2); + if (tip_pos.x + size_.x >= rect.Max.x) { + tip_pos.x = pt.x - size_.x - 8; + } + if (rect.Contains(ImRect(tip_pos, tip_pos + size_))) { + pos_ = tip_pos; + visible_ = true; + return; + } + } + visible_ = false; +} + +void TipLabel::draw() { + if (!visible_) return; + + ImDrawList *p = ImGui::GetForegroundDrawList(); + const bool dark = isDarkTheme(); + const ImU32 bg = dark ? ImGui::GetColorU32(ImGuiCol_PopupBg) : ImGui::GetColorU32(ImGuiCol_ChildBg); + const ImU32 fg = dark ? ImGui::GetColorU32(ImGuiCol_Text) : IM_COL32(0x40, 0x40, 0x44, 0xff); + // filled panel with a 1px frame + p->AddRectFilled(pos_, pos_ + size_, bg); + p->AddRect(pos_, pos_ + size_, ImGui::GetColorU32(ImGuiCol_Border)); + layoutLines(p, pos_ + ImVec2(MARGIN, MARGIN), fg); +} diff --git a/openpilot/tools/cabana/ui/chart/tiplabel.h b/openpilot/tools/cabana/ui/chart/tiplabel.h new file mode 100644 index 0000000000..6aa80538e8 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/tiplabel.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" + +// one line of the tip: [square] name value (min, max) +struct TipLine { + bool has_marker = false; + ImU32 marker = 0; + std::string name; + std::string bold; + std::string rest; +}; + +class TipLabel { +public: + void showText(const ImVec2 &pt, const std::vector &text, const ImRect &rect); + void hide() { visible_ = false; } + bool isVisible() const { return visible_; } + void draw(); // draws the tip on the foreground draw list; call once per frame + +private: + // lays the lines out from origin, drawing them when p is given; returns the size of the text block + ImVec2 layoutLines(ImDrawList *p, const ImVec2 &origin, ImU32 fg) const; + ImVec2 sizeHint() const; + + static constexpr float MARGIN = 2.0f; // 1 + PM_ToolTipLabelFrameWidth + std::vector text_; + ImVec2 pos_; + ImVec2 size_; + bool visible_ = false; +}; diff --git a/openpilot/tools/cabana/ui/dialogs/filedialog.cc b/openpilot/tools/cabana/ui/dialogs/filedialog.cc new file mode 100644 index 0000000000..5721d8edbc --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/filedialog.cc @@ -0,0 +1,222 @@ +#include "tools/cabana/ui/dialogs/filedialog.h" + +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" + +namespace fs = std::filesystem; + +namespace FileDialog { + +namespace { + +// case insensitive, numeric aware collation ("mazda_3_2019" before "mazda_2017"): punctuation is ignored at +// the first level ("FORD_CADS_64" before "FORD_CADS.dbc"), digit runs compare numerically, case is ignored; +// ties fall back to a plain comparison +bool naturalLess(const std::string &a, const std::string &b) { + auto skip = [](const std::string &s, size_t &i) { + while (i < s.size() && !isalnum(static_cast(s[i]))) ++i; + }; + size_t i = 0, j = 0; + for (;;) { + skip(a, i); + skip(b, j); + if (i >= a.size() || j >= b.size()) break; + if (isdigit(static_cast(a[i])) && isdigit(static_cast(b[j]))) { + size_t ie = i, je = j; + while (ie < a.size() && isdigit(static_cast(a[ie]))) ++ie; + while (je < b.size() && isdigit(static_cast(b[je]))) ++je; + const unsigned long long na = std::stoull(a.substr(i, ie - i)), nb = std::stoull(b.substr(j, je - j)); + if (na != nb) return na < nb; + i = ie; + j = je; + } else { + const int ca = tolower(static_cast(a[i])), cb = tolower(static_cast(b[j])); + if (ca != cb) return ca < cb; + ++i; + ++j; + } + } + const bool a_done = i >= a.size(), b_done = j >= b.size(); + if (a_done != b_done) return a_done; + return a < b; +} + +enum class Mode { OpenFile, SaveFile, Directory }; + +struct State { + bool active = false; + Mode mode = Mode::OpenFile; + std::string title; + std::string extension; + fs::path dir; + std::string dir_input; + std::string filename; + std::vector entries; + Callback callback; +}; + +State g_state; +PopupOwner g_owner; + +void listDir() { + State &s = g_state; + s.entries.clear(); + std::error_code ec; + for (const auto &entry : fs::directory_iterator(s.dir, ec)) { + const std::string name = entry.path().filename().string(); + if (name.empty() || name[0] == '.') continue; + const bool is_dir = entry.is_directory(ec); + if (!is_dir && s.mode == Mode::Directory) continue; + if (!is_dir && !s.extension.empty() && entry.path().extension() != s.extension) continue; + s.entries.push_back(entry); + } + std::sort(s.entries.begin(), s.entries.end(), [](const auto &a, const auto &b) { + std::error_code sort_ec; + const bool da = a.is_directory(sort_ec), db = b.is_directory(sort_ec); + return da != db ? da : naturalLess(a.path().filename().string(), b.path().filename().string()); + }); + s.dir_input = s.dir.string(); +} + +void setDir(const fs::path &dir) { + std::error_code ec; + fs::path d = fs::is_directory(dir, ec) ? fs::absolute(dir, ec) : fs::current_path(ec); + g_state.dir = d.lexically_normal(); + listDir(); +} + +void start(Mode mode, const std::string &title, const fs::path &dir, const std::string &filename, + const std::string &extension, Callback cb) { + State &s = g_state; + s = State{}; + s.active = true; + s.mode = mode; + s.title = title; + s.extension = extension; + s.filename = filename; + s.callback = std::move(cb); + g_owner.reset(); + setDir(dir); +} + +void finish(const std::string &path) { + Callback cb = std::move(g_state.callback); + g_state = State{}; + g_owner.reset(); + if (cb) cb(path); +} + +void accept(const fs::path &path) { + if (g_state.mode == Mode::SaveFile) { + std::error_code ec; + if (fs::exists(path, ec)) { + const std::string name = path.filename().string(); + MessageBox::question(g_state.title, name + " already exists.\nDo you want to replace it?", [path](bool ok) { + if (ok) finish(path.string()); + }); + return; + } + } + finish(path.string()); +} + +} // namespace + +void getOpenFileName(const std::string &title, const std::string &dir, const std::string &extension, Callback cb) { + start(Mode::OpenFile, title, dir, "", extension, std::move(cb)); +} + +void getSaveFileName(const std::string &title, const std::string &default_path, const std::string &extension, Callback cb) { + const fs::path p(default_path); + start(Mode::SaveFile, title, p.parent_path(), p.filename().string(), extension, std::move(cb)); +} + +void getExistingDirectory(const std::string &title, const std::string &dir, Callback cb) { + start(Mode::Directory, title, dir, "", "", std::move(cb)); +} + +void draw() { + State &s = g_state; + if (!s.active) return; + const std::string popup_id = s.title + "###FileDialog"; + if (!beginDialog(popup_id.c_str(), &g_owner, ImVec2(640.0f, 480.0f), 0)) return; + + if (ImGui::Button("Up")) setDir(s.dir.parent_path()); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-1.0f); + if (inputText("##dir", &s.dir_input, "", ImGuiInputTextFlags_EnterReturnsTrue)) setDir(s.dir_input); + + const float footer = ImGui::GetFrameHeightWithSpacing() * (s.mode == Mode::Directory ? 1.0f : 2.0f) + ImGui::GetStyle().ItemSpacing.y; + bool ok = false, cancel = false; + fs::path result, pending_dir; + ImGui::BeginChild("entries", ImVec2(0, -footer), ImGuiChildFlags_Borders); + std::error_code dir_ec; + for (size_t i = 0; i < s.entries.size(); ++i) { + const auto &entry = s.entries[i]; + const bool is_dir = entry.is_directory(dir_ec); + const std::string name = entry.path().filename().string(); + const std::string label = (is_dir ? std::string(icon::FOLDER) : std::string(icon::FILE_EARMARK)) + " " + name; + ImGui::PushID(static_cast(i)); + const bool selected = !is_dir && name == s.filename; + if (ImGui::Selectable(label.c_str(), selected, ImGuiSelectableFlags_AllowDoubleClick)) { + const bool double_clicked = ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left); + if (is_dir) { + if (double_clicked) { + pending_dir = entry.path(); + } else if (s.mode == Mode::Directory) { + s.filename = name; + } + } else { + s.filename = name; + if (double_clicked && s.mode == Mode::OpenFile) { + result = entry.path(); + ok = true; + } + } + } + ImGui::PopID(); + if (ok || !pending_dir.empty()) break; + } + ImGui::EndChild(); + if (!pending_dir.empty()) setDir(pending_dir); + + if (s.mode != Mode::Directory) { + ImGui::SetNextItemWidth(-90.0f); + if (inputText("##name", &s.filename, "File name", ImGuiInputTextFlags_EnterReturnsTrue)) ok = true; + ImGui::SameLine(); + ImGui::TextDisabled("%s", s.extension.empty() ? "*" : ("*" + s.extension).c_str()); + } + const char *accept_label = s.mode == Mode::SaveFile ? "Save" : (s.mode == Mode::Directory ? "Choose" : "Open"); + dialogButtons(accept_label, &ok, &cancel); + + if (ok && result.empty()) { + if (s.mode == Mode::Directory) { + result = s.filename.empty() ? s.dir : s.dir / s.filename; + } else if (!s.filename.empty()) { + result = fs::path(s.filename).is_absolute() ? fs::path(s.filename) : s.dir / s.filename; + if (s.mode == Mode::SaveFile && !s.extension.empty() && result.extension().empty()) result += s.extension; + if (s.mode == Mode::OpenFile && !fs::is_regular_file(result, dir_ec)) ok = false; + } else { + ok = false; + } + } + if (ok || cancel) ImGui::CloseCurrentPopup(); + // nested so the overwrite prompt stacks on this dialog; it may finish the dialog through accept() + MessageBox::draw(); + if (!s.active) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (cancel) { + finish(""); + } else if (ok) { + accept(result); + } +} + +} // namespace FileDialog diff --git a/openpilot/tools/cabana/ui/dialogs/filedialog.h b/openpilot/tools/cabana/ui/dialogs/filedialog.h new file mode 100644 index 0000000000..529f223179 --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/filedialog.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include +#include +#include + +// file browser. One dialog at a time; the callback gets an empty path on cancel. +namespace FileDialog { + +using Callback = std::function; + +void getOpenFileName(const std::string &title, const std::string &dir, const std::string &extension, Callback cb); +void getSaveFileName(const std::string &title, const std::string &default_path, const std::string &extension, Callback cb); +void getExistingDirectory(const std::string &title, const std::string &dir, Callback cb); + +void draw(); // once per frame, at the top popup level + +} // namespace FileDialog diff --git a/openpilot/tools/cabana/ui/dialogs/messagebox.cc b/openpilot/tools/cabana/ui/dialogs/messagebox.cc new file mode 100644 index 0000000000..9d1a2bde97 --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/messagebox.cc @@ -0,0 +1,87 @@ +#include "tools/cabana/ui/dialogs/messagebox.h" + +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/ui/util.h" + +namespace MessageBox { + +namespace { + +struct Box { + std::string title; + std::string text; + std::string detailed_text; + bool has_cancel = false; + std::function on_result; +}; + +std::deque g_queue; +bool g_show_details = false; +PopupOwner g_owner; + +void push(Box box) { g_queue.push_back(std::move(box)); } + +std::function wrap(std::function on_close) { + if (!on_close) return nullptr; + return [on_close = std::move(on_close)](bool) { on_close(); }; +} + +} // namespace + +void information(const std::string &title, const std::string &text, std::function on_close) { + push({.title = title, .text = text, .on_result = wrap(std::move(on_close))}); +} + +void warning(const std::string &title, const std::string &text, const std::string &detailed_text, + std::function on_close) { + push({.title = title, .text = text, .detailed_text = detailed_text, .on_result = wrap(std::move(on_close))}); +} + +void question(const std::string &title, const std::string &text, std::function on_result) { + push({.title = title, .text = text, .has_cancel = true, .on_result = std::move(on_result)}); +} + +void draw() { + if (g_queue.empty()) return; + Box &box = g_queue.front(); + const std::string popup_id = box.title + "###MessageBox"; + const bool first = g_owner.popup_id == 0; + if (!g_owner.begin(popup_id.c_str())) return; + // AlwaysAutoResize sizes the popup from its contents; keep the title bar text from being clipped + const ImGuiStyle &style = ImGui::GetStyle(); + const float min_width = ImGui::CalcTextSize(box.title.c_str()).x + style.FramePadding.x * 2 + style.WindowPadding.x * 2; + ImGui::SetNextWindowSizeConstraints(ImVec2(min_width, 0.0f), ImVec2(FLT_MAX, FLT_MAX)); + setNextDialogWindow(ImVec2(0.0f, 0.0f)); + if (!ImGui::BeginPopupModal(popup_id.c_str(), nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings)) return; + if (first) g_show_details = false; + bool result = false, done = false; + ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + 480.0f); + ImGui::TextUnformatted(box.text.c_str()); + ImGui::PopTextWrapPos(); + if (g_show_details) { + ImGui::InputTextMultiline("##details", box.detailed_text.data(), box.detailed_text.size() + 1, + ImVec2(480.0f, 160.0f), ImGuiInputTextFlags_ReadOnly); + } + ImGui::Separator(); + if (!box.detailed_text.empty()) { + // the details button sits at the left of the button box + if (ImGui::Button(g_show_details ? "Hide Details..." : "Show Details...")) g_show_details = !g_show_details; + ImGui::SameLine(); + } + dialogButtons("OK", &result, &done, true, box.has_cancel ? "Cancel" : nullptr); + if (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false)) result = true; + if (result) done = true; + if (done) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (done) { + g_owner.reset(); + Box finished = std::move(g_queue.front()); + g_queue.pop_front(); + if (finished.on_result) finished.on_result(result); + } +} + +} // namespace MessageBox diff --git a/openpilot/tools/cabana/ui/dialogs/messagebox.h b/openpilot/tools/cabana/ui/dialogs/messagebox.h new file mode 100644 index 0000000000..1e30d33ddb --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/messagebox.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +// Boxes are queued and shown one at a time as modal popups; the caller passes a continuation where it +// needs the answer. +namespace MessageBox { + +// on_close (optional) runs when the box is dismissed +void information(const std::string &title, const std::string &text, std::function on_close = nullptr); +void warning(const std::string &title, const std::string &text, const std::string &detailed_text = "", + std::function on_close = nullptr); +// Ok | Cancel; on_result(true) for Ok +void question(const std::string &title, const std::string &text, std::function on_result); + +void draw(); // once per frame, at the top popup level + +} // namespace MessageBox diff --git a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc new file mode 100644 index 0000000000..d0676ae709 --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc @@ -0,0 +1,124 @@ +#include "tools/cabana/ui/dialogs/routesdialog.h" + +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/util.h" + +namespace { +const char *PERIOD_NAMES[] = {"Last week", "Last 2 weeks", "Last month", "Last 6 months", "Preserved"}; +const int PERIOD_DAYS[] = {7, 14, 30, 180, -1}; +} // namespace + +void RoutesDialog::open(std::function on_done) { + on_done_ = std::move(on_done); + open_ = true; + popup_.reset(); + s_ = State{}; + alive_ = std::make_shared(true); + + routes::fetchDevices([this, alive = std::weak_ptr(alive_)](std::vector devices, bool success, int error_code) { + utils::runOnMainThread(utils::guarded(alive.lock(), [this, devices = std::move(devices), success, error_code]() { + setDeviceList(devices, success, error_code); + })); + }); +} + +void RoutesDialog::setDeviceList(const std::vector &devices, bool success, int error_code) { + if (success) { + s_.devices.clear(); + for (const auto &device : devices) s_.devices.push_back(device.dongle_id); + s_.devices_loaded = true; + s_.device_index = 0; + fetchRoutes(); + } else { + // the box shows on top of the dialog, which is rejected once the box is dismissed + MessageBox::warning("Error", error_code == 401 ? "Unauthorized. Authenticate with openpilot/tools/lib/auth.py" : "Network error", "", + utils::guarded(alive_, [this]() { finish(false); })); + } +} + +void RoutesDialog::fetchRoutes() { + if (!s_.devices_loaded || s_.devices.empty()) return; + + s_.routes.clear(); + s_.route_index = -1; + s_.empty_text = "Loading..."; + + const int request_id = ++s_.fetch_id; + auto on_routes = [this, alive = std::weak_ptr(alive_), request_id](std::vector list, bool success, int) { + utils::runOnMainThread(utils::guarded(alive.lock(), [this, list = std::move(list), success, request_id]() { + if (s_.fetch_id == request_id) setRouteList(list, success); + })); + }; + routes::fetchRoutes(s_.devices[s_.device_index], PERIOD_DAYS[s_.period_index], std::move(on_routes)); +} + +void RoutesDialog::setRouteList(const std::vector &list, bool success) { + if (success) { + for (const auto &route : list) { + const int mins = static_cast((route.end_ms - route.start_ms) / 60000); + s_.routes.push_back({routes::formatUnixMs(route.start_ms) + " " + std::to_string(mins) + "min", route.name}); + } + if (!s_.routes.empty()) s_.route_index = 0; + } else { + MessageBox::warning("Error", "Failed to fetch routes. Check your network connection.", "", + utils::guarded(alive_, [this]() { finish(false); })); + } + s_.empty_text = "No items"; +} + +void RoutesDialog::finish(bool accepted) { + alive_.reset(); + open_ = false; + auto on_done = std::move(on_done_); + if (on_done) on_done(accepted, accepted && s_.route_index >= 0 ? s_.routes[s_.route_index].name : ""); +} + +void RoutesDialog::draw() { + if (!open_) return; + if (!beginDialog("Remote routes", &popup_, ImVec2(480.0f, 420.0f))) return; + + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Device"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-1.0f); + if (s_.devices_loaded) { + if (comboBox("##device", &s_.device_index, s_.devices)) fetchRoutes(); + } else { + int idx = 0; + ImGui::BeginDisabled(); + comboBox("##device", &idx, {"Loading..."}); + ImGui::EndDisabled(); + } + ImGui::SetNextItemWidth(-1.0f); + if (ImGui::Combo("##period", &s_.period_index, PERIOD_NAMES, IM_ARRAYSIZE(PERIOD_NAMES))) fetchRoutes(); + + bool accepted = false, rejected = false; + const float footer = ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y; + ImGui::BeginChild("routes", ImVec2(0, -footer), ImGuiChildFlags_Borders); + if (s_.routes.empty()) { + const ImVec2 size = ImGui::CalcTextSize(s_.empty_text.c_str()); + const ImVec2 avail = ImGui::GetContentRegionAvail(); + ImGui::SetCursorPos(ImVec2((avail.x - size.x) * 0.5f, (avail.y - size.y) * 0.5f)); + ImGui::TextUnformatted(s_.empty_text.c_str()); + } + for (int i = 0; i < static_cast(s_.routes.size()); ++i) { + ImGui::PushID(i); + if (ImGui::Selectable(s_.routes[i].label.c_str(), s_.route_index == i, ImGuiSelectableFlags_AllowDoubleClick)) { + s_.route_index = i; + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) accepted = true; + } + ImGui::PopID(); + } + ImGui::EndChild(); + + dialogButtons("OK", &accepted, &rejected); + MessageBox::draw(); + if (accepted || rejected || !open_) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (accepted || rejected) finish(accepted); +} diff --git a/openpilot/tools/cabana/ui/dialogs/routesdialog.h b/openpilot/tools/cabana/ui/dialogs/routesdialog.h new file mode 100644 index 0000000000..8f84096973 --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/routesdialog.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include +#include + +#include "tools/cabana/routes.h" +#include "tools/cabana/ui/util.h" + +// "Remote routes" browser. on_done gets accepted=true with the selected route name ("" if none), accepted=false on cancel. +class RoutesDialog { +public: + void open(std::function on_done); + void draw(); + +private: + void setDeviceList(const std::vector &devices, bool success, int error_code); + void setRouteList(const std::vector &list, bool success); + void fetchRoutes(); + void finish(bool accepted); + + struct RouteItem { + std::string label; + std::string name; + }; + + struct State { + bool devices_loaded = false; + std::vector devices; + int device_index = 0; + int period_index = 0; + std::vector routes; + int route_index = -1; + std::string empty_text = "No items"; + int fetch_id = 0; // the reply of an older request is dropped + }; + + bool open_ = false; + PopupOwner popup_; + State s_; + std::function on_done_; + // created by open() and reset by finish(); guards main-thread callbacks from detached worker threads + std::shared_ptr alive_; +}; diff --git a/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc b/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc new file mode 100644 index 0000000000..ea82a62a8d --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc @@ -0,0 +1,108 @@ +#include "tools/cabana/ui/dialogs/settingsdialog.h" + +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/dialogs/filedialog.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/util.h" + +namespace { + +const int MIN_CACHE_MINUTES = 30; +const int MAX_CACHE_MINUTES = 120; + +// the label sits in the left column, the field in the right one, all fields aligned +enum FormLabel { THEME, CACHED_MINUTES, DRAG_DIRECTION, CHART_HEIGHT, FORM_LABEL_COUNT }; +const char *FORM_LABELS[FORM_LABEL_COUNT] = {"Color Theme", "Max Cached Minutes", "Drag Direction", "Chart Height"}; + +float formLabelWidth() { + float w = 0.0f; + for (const char *label : FORM_LABELS) w = std::max(w, ImGui::CalcTextSize(label).x); + return w + ImGui::GetStyle().ItemSpacing.x * 2; // horizontal spacing between label and field +} + +void formRow(FormLabel label, float label_width) { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(FORM_LABELS[label]); + ImGui::SameLine(label_width); + ImGui::SetNextItemWidth(-FLT_MIN); +} + +} // namespace + +void SettingsDialog::open() { + theme_ = settings.theme; + cached_minutes_ = settings.max_cached_minutes; + drag_direction_ = settings.drag_direction; + chart_height_ = settings.chart_height; + log_livestream_ = settings.log_livestream; + log_path_ = settings.log_path; + open_ = true; + popup_.reset(); +} + +void SettingsDialog::draw() { + if (!open_) return; + if (!beginDialog("Settings", &popup_, ImVec2(400.0f, 0.0f))) return; + const float label_width = formLabelWidth(); + + ImGui::SeparatorText("General"); + static const char *themes[] = {"Light", "Dark"}; + formRow(THEME, label_width); + int theme_index = theme_ - LIGHT_THEME; + if (ImGui::Combo("##theme", &theme_index, themes, IM_ARRAYSIZE(themes))) theme_ = theme_index + LIGHT_THEME; + formRow(CACHED_MINUTES, label_width); + // InputInt takes no character filter, so out of range text is clamped after the edit + if (ImGui::InputInt("##cached_minutes", &cached_minutes_, 1, 10)) { + cached_minutes_ = std::clamp(cached_minutes_, MIN_CACHE_MINUTES, MAX_CACHE_MINUTES); + } + + ImGui::SeparatorText("New Signal Settings"); + static const char *directions[] = {"MSB First", "LSB First", "Always Little Endian", "Always Big Endian"}; + formRow(DRAG_DIRECTION, label_width); + ImGui::Combo("##drag_direction", &drag_direction_, directions, IM_ARRAYSIZE(directions)); + + ImGui::SeparatorText("Chart"); + formRow(CHART_HEIGHT, label_width); + if (ImGui::InputInt("##chart_height", &chart_height_, 10, 10)) chart_height_ = std::clamp(chart_height_, 100, 500); + + checkBox("Enable live stream logging", &log_livestream_); + ImGui::BeginDisabled(!log_livestream_); + ImGui::SetNextItemWidth(-90.0f); + inputText("##log_path", &log_path_, "", ImGuiInputTextFlags_ReadOnly); + ImGui::SameLine(); + if (ImGui::Button("Browse...")) { + FileDialog::getExistingDirectory("Log File Location", utils::homePath(), [this](const std::string &fn) { + if (!fn.empty()) log_path_ = fn; + }); + } + ImGui::EndDisabled(); + + ImGui::Separator(); + bool accepted = false, done = false; + dialogButtons("OK", &accepted, &done); + if (accepted) { + save(); + done = true; + } + FileDialog::draw(); // nested so the directory picker stacks on this modal + if (done) { + open_ = false; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void SettingsDialog::save() { + if (std::exchange(settings.theme, theme_) != settings.theme) applyTheme(settings.theme); + settings.max_cached_minutes = cached_minutes_; + settings.chart_height = chart_height_; + settings.log_livestream = log_livestream_; + settings.log_path = log_path_; + settings.drag_direction = (Settings::DragDirection)drag_direction_; + settings.changed(); +} diff --git a/openpilot/tools/cabana/ui/dialogs/settingsdialog.h b/openpilot/tools/cabana/ui/dialogs/settingsdialog.h new file mode 100644 index 0000000000..c9947a91a1 --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/settingsdialog.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +#include "tools/cabana/core/settings.h" +#include "tools/cabana/ui/util.h" + +class SettingsDialog { +public: + void open(); + void draw(); + +private: + void save(); + + bool open_ = false; + PopupOwner popup_; + int theme_ = 0; + int cached_minutes_ = 0; + int drag_direction_ = 0; + int chart_height_ = 0; + bool log_livestream_ = false; + std::string log_path_; +}; diff --git a/openpilot/tools/cabana/ui/dialogs/streamselector.cc b/openpilot/tools/cabana/ui/dialogs/streamselector.cc new file mode 100644 index 0000000000..44336447c6 --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/streamselector.cc @@ -0,0 +1,322 @@ +#include "tools/cabana/ui/dialogs/streamselector.h" + +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/streams/devicestream.h" +#include "tools/cabana/streams/replaystream.h" +#include "tools/cabana/ui/dialogs/filedialog.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/util.h" + +void OpenReplayWidget::draw() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Route"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-250.0f); + inputText("##route", &route_, "Enter route name or browse for local/remote route"); + ImGui::SameLine(); + if (ImGui::Button("Remote route...")) { + routes_dialog_.open(utils::guarded(alive_, [this](bool accepted, const std::string &route) { + if (accepted) route_ = route; + })); + } + ImGui::SameLine(); + if (ImGui::Button("Local route...")) { + FileDialog::getExistingDirectory("Open Local Route", settings.last_route_dir, utils::guarded(alive_, [this](const std::string &dir) { + if (!dir.empty()) { + route_ = dir; + settings.last_route_dir = std::filesystem::absolute(dir).parent_path().string(); + } + })); + } + checkBox("Road camera", &cameras_[0]); + ImGui::SameLine(); + checkBox("Driver camera", &cameras_[1]); + ImGui::SameLine(); + checkBox("Wide road camera", &cameras_[2]); +} + +void OpenReplayWidget::drawPopups() { + routes_dialog_.draw(); +} + +std::unique_ptr OpenReplayWidget::open() { + std::string route = route_; + std::string data_dir; + if (auto idx = route.rfind('/'); idx != std::string::npos && util::file_exists(route)) { + data_dir = route.substr(0, idx + 1); + route = route.substr(idx + 1); + } + + bool is_valid_format = Route::parseRoute(route).str.size() > 0; + if (!is_valid_format) { + MessageBox::warning("Warning", "Invalid route format: '" + route + "'"); + } else { + auto replay_stream = std::make_unique(); + Connection err = replay_stream->error.connect([](const std::string &msg) { + MessageBox::warning("Error", msg); + }); + uint32_t flags = REPLAY_FLAG_NONE; + if (cameras_[1]) flags |= REPLAY_FLAG_CABIN_CAMERA; + if (cameras_[2]) flags |= REPLAY_FLAG_WIDE_ROAD; + if (flags == REPLAY_FLAG_NONE && !cameras_[0]) flags = REPLAY_FLAG_NO_VIPC; + + if (replay_stream->loadRoute(route, data_dir, flags)) { + return replay_stream; + } + } + return nullptr; +} + +namespace { +const uint32_t speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U}; +const uint32_t data_speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U, 2000U, 5000U}; +} + +OpenPandaWidget::OpenPandaWidget() { + if (can && dynamic_cast(can) != nullptr) { + already_connected_ = true; + return; + } + refreshSerials(); + buildConfigForm(); +} + +void OpenPandaWidget::refreshSerials() { + serials_ = Panda::list(); + serial_index_ = 0; +} + +void OpenPandaWidget::buildConfigForm() { + std::string serial = serial_index_ < static_cast(serials_.size()) ? serials_[serial_index_] : ""; + has_fd_ = false; + has_panda_ = !serial.empty(); + if (has_panda_) { + try { + Panda panda(serial); + has_fd_ = (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA) || (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA_V2); + } catch (const std::exception &e) { + fprintf(stderr, "failed to open panda %s\n", serial.c_str()); + has_panda_ = false; + } + } + + if (has_panda_) { + config.serial = serial; + config.bus_config.resize(3); + can_speed_index_.assign(3, 0); + data_speed_index_.assign(3, 0); + for (int i = 0; i < 3; i++) { + for (int j = 0; j < static_cast(std::size(speeds)); j++) { + if (speeds[j] == config.bus_config[i].can_speed_kbps) can_speed_index_[i] = j; + } + for (int j = 0; j < static_cast(std::size(data_speeds)); j++) { + if (data_speeds[j] == config.bus_config[i].data_speed_kbps) data_speed_index_[i] = j; + } + } + } else { + config.serial = ""; + } +} + +void OpenPandaWidget::draw() { + if (already_connected_) { + ImGui::Text("Already connected to %s.", can->routeName().c_str()); + ImGui::TextUnformatted("Close the current connection via [File menu -> Close Stream] before connecting to another Panda."); + return; + } + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Serial"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-100.0f); + if (comboBox("##serial", &serial_index_, serials_)) buildConfigForm(); + ImGui::SameLine(); + if (ImGui::Button("Refresh")) { + refreshSerials(); + buildConfigForm(); + } + + if (!has_panda_) { + ImGui::TextUnformatted("No panda found"); + return; + } + for (int i = 0; i < static_cast(config.bus_config.size()); i++) { + ImGui::PushID(i); + ImGui::AlignTextToFramePadding(); + ImGui::Text("Bus %d:", i); + ImGui::SameLine(); + ImGui::TextUnformatted("CAN Speed (kbps):"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + if (comboBox("##can_speed", &can_speed_index_[i], speeds, (int)std::size(speeds))) { + config.bus_config[i].can_speed_kbps = speeds[can_speed_index_[i]]; + } + if (has_fd_) { + ImGui::SameLine(); + checkBox("CAN-FD", &config.bus_config[i].can_fd); + ImGui::SameLine(); + ImGui::TextUnformatted("Data Speed (kbps):"); + ImGui::SameLine(); + ImGui::BeginDisabled(!config.bus_config[i].can_fd); + ImGui::SetNextItemWidth(90.0f); + if (comboBox("##data_speed", &data_speed_index_[i], data_speeds, (int)std::size(data_speeds))) { + config.bus_config[i].data_speed_kbps = data_speeds[data_speed_index_[i]]; + } + ImGui::EndDisabled(); + } + ImGui::PopID(); + } +} + +std::unique_ptr OpenPandaWidget::open() { + try { + return std::make_unique(config); + } catch (std::exception &e) { + MessageBox::warning("Warning", std::string("Failed to connect to panda: '") + e.what() + "'"); + return nullptr; + } +} + +void OpenDeviceWidget::draw() { + ImGui::RadioButton("MSGQ", &mode_, 0); + ImGui::RadioButton("ZMQ", &mode_, 1); + // the radio buttons are the label column, the ip address is the field column + const float label_width = ImGui::GetFrameHeight() + ImGui::GetStyle().ItemInnerSpacing.x + + std::max(ImGui::CalcTextSize("MSGQ").x, ImGui::CalcTextSize("ZMQ").x) + + ImGui::GetStyle().ItemInnerSpacing.x; + ImGui::SameLine(label_width); + ImGui::BeginDisabled(mode_ != 1); + ImGui::SetNextItemWidth(-1.0f); + validatedText("##ip", &ip_address_, validateIpAddress, "Enter device Ip Address", ipValidator); + ImGui::EndDisabled(); +} + +std::unique_ptr OpenDeviceWidget::open() { + std::string ip = ip_address_.empty() ? "127.0.0.1" : ip_address_; + bool msgq = mode_ == 0; + return std::make_unique(msgq ? "" : ip); +} + +#ifdef __linux__ + +OpenSocketCanWidget::OpenSocketCanWidget() { + refreshDevices(); +} + +void OpenSocketCanWidget::refreshDevices() { + devices_.clear(); + // type 280 = ARPHRD_CAN + std::error_code ec; + for (const auto &entry : std::filesystem::directory_iterator("/sys/class/net", ec)) { + std::ifstream type_file(entry.path() / "type"); + int type = 0; + if (type_file >> type && type == 280) { + devices_.push_back(entry.path().filename().string()); + } + } + device_index_ = 0; + config.device = devices_.empty() ? "" : devices_[0]; +} + +void OpenSocketCanWidget::draw() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Device"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(300.0f); + if (comboBox("##device", &device_index_, devices_)) config.device = devices_[device_index_]; + ImGui::SameLine(); + if (ImGui::Button("Refresh", ImVec2(100.0f, 0.0f))) refreshDevices(); +} + +std::unique_ptr OpenSocketCanWidget::open() { + try { + return std::make_unique(config); + } catch (std::exception &e) { + MessageBox::warning("Warning", std::string("Failed to connect to SocketCAN device: '") + e.what() + "'"); + return nullptr; + } +} +#endif + +void StreamSelector::open(Callback on_done) { + on_done_ = std::move(on_done); + open_ = true; + popup_.reset(); + first_frame_ = true; + dbc_file_.clear(); + widgets_.clear(); + widgets_.push_back(std::make_unique()); + widgets_.push_back(std::make_unique()); +#ifdef __linux__ + if (SocketCanStream::available()) { + widgets_.push_back(std::make_unique()); + } +#endif + widgets_.push_back(std::make_unique()); +} + +void StreamSelector::draw() { + if (!open_) return; + if (!beginDialog("Open stream", &popup_, ImVec2(640.0f, 0.0f))) return; + + AbstractOpenStreamWidget *current = nullptr; + if (ImGui::BeginTabBar("streams")) { + for (auto &w : widgets_) { + // a fresh dialog every time, so the first tab is always the current one + ImGuiTabItemFlags tab_flags = (first_frame_ && w == widgets_.front()) ? ImGuiTabItemFlags_SetSelected : 0; + if (ImGui::BeginTabItem(w->title(), nullptr, tab_flags)) { + current = w.get(); + ImGui::BeginChild("tab", ImVec2(0, 130.0f)); + w->draw(); + ImGui::EndChild(); + ImGui::EndTabItem(); + } + } + ImGui::EndTabBar(); + } + first_frame_ = false; + + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("dbc File"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-90.0f); + inputText("##dbc", &dbc_file_, "Choose a dbc file to open", ImGuiInputTextFlags_ReadOnly); + ImGui::SameLine(); + if (ImGui::Button("Browse...")) { + FileDialog::getOpenFileName("Open File", settings.last_dir, ".dbc", [this](const std::string &fn) { + if (!fn.empty()) { + dbc_file_ = fn; + settings.last_dir = std::filesystem::absolute(fn).parent_path().string(); + } + }); + } + ImGui::Separator(); + + bool accepted = false, rejected = false; + std::unique_ptr stream; + bool open_clicked = false; + dialogButtons("Open", &open_clicked, &rejected, current != nullptr && current->openEnabled()); + if (open_clicked) { + if (stream = current->open(); stream) accepted = true; + } + + // nested so they stack on this modal + if (current) current->drawPopups(); + FileDialog::draw(); + MessageBox::draw(); + + if (accepted || rejected) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (accepted || rejected) { + open_ = false; + widgets_.clear(); + auto on_done = std::move(on_done_); + if (on_done) on_done(std::move(stream), dbc_file_); + } +} diff --git a/openpilot/tools/cabana/ui/dialogs/streamselector.h b/openpilot/tools/cabana/ui/dialogs/streamselector.h new file mode 100644 index 0000000000..b57e602cab --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/streamselector.h @@ -0,0 +1,106 @@ +#pragma once + +#include +#include +#include +#include + +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/streams/pandastream.h" +#ifdef __linux__ +#include "tools/cabana/streams/socketcanstream.h" +#endif +#include "tools/cabana/ui/dialogs/routesdialog.h" +#include "tools/cabana/ui/util.h" + +class AbstractOpenStreamWidget { +public: + virtual ~AbstractOpenStreamWidget() = default; + virtual const char *title() const = 0; + virtual void draw() = 0; + // nested dialogs, drawn at the modal's level: a popup opened from inside the tab's child window is + // never the top-most modal's own call site, so PopupOwner would skip it + virtual void drawPopups() {} + virtual std::unique_ptr open() = 0; + virtual bool openEnabled() const { return true; } +}; + +class OpenReplayWidget : public AbstractOpenStreamWidget { +public: + const char *title() const override { return "Replay"; } + void draw() override; + void drawPopups() override; + std::unique_ptr open() override; + +private: + std::string route_; + bool cameras_[3] = {true, false, false}; + RoutesDialog routes_dialog_; + // guards dialog continuations that outlive the stream selector + std::shared_ptr alive_ = std::make_shared(true); +}; + +class OpenPandaWidget : public AbstractOpenStreamWidget { +public: + OpenPandaWidget(); + const char *title() const override { return "Panda"; } + void draw() override; + std::unique_ptr open() override; + bool openEnabled() const override { return !already_connected_; } + +private: + void refreshSerials(); + void buildConfigForm(); + + bool already_connected_ = false; + std::vector serials_; + int serial_index_ = 0; + bool has_panda_ = false; + bool has_fd_ = false; + std::vector can_speed_index_, data_speed_index_; + PandaStreamConfig config = {}; +}; + +class OpenDeviceWidget : public AbstractOpenStreamWidget { +public: + const char *title() const override { return "Device"; } + void draw() override; + std::unique_ptr open() override; + +private: + int mode_ = 1; // 0 = MSGQ, 1 = ZMQ + std::string ip_address_; +}; + +#ifdef __linux__ +class OpenSocketCanWidget : public AbstractOpenStreamWidget { +public: + OpenSocketCanWidget(); + const char *title() const override { return "SocketCAN"; } + void draw() override; + std::unique_ptr open() override; + +private: + void refreshDevices(); + + std::vector devices_; + int device_index_ = 0; + SocketCanStreamConfig config = {}; +}; +#endif + +class StreamSelector { +public: + using Callback = std::function stream, const std::string &dbc_file)>; + // on_done gets a null stream on cancel + void open(Callback on_done); + void draw(); + +private: + bool open_ = false; + PopupOwner popup_; + bool first_frame_ = false; + std::string dbc_file_; + std::vector> widgets_; + Callback on_done_; +}; diff --git a/openpilot/tools/cabana/ui/helpoverlay.cc b/openpilot/tools/cabana/ui/helpoverlay.cc new file mode 100644 index 0000000000..5ae3dab6f6 --- /dev/null +++ b/openpilot/tools/cabana/ui/helpoverlay.cc @@ -0,0 +1,196 @@ +#include "tools/cabana/ui/helpoverlay.h" + +#include +#include +#include +#include +#include + +#include "tools/cabana/ui/util.h" + +namespace { +struct HelpRun { + std::string text; + bool bold = false; + bool chip = false; // background-color:lightGray + bool swatch = false; // a colored square + ImU32 color = 0; // 0 = default text color +}; + +ImU32 helpColor(const std::string &name) { + if (name == "gray") return IM_COL32(128, 128, 128, 255); + if (name == "blue") return IM_COL32(0, 0, 255, 255); + if (name == "red") return IM_COL32(255, 0, 0, 255); + unsigned rgb = 0; + if (name.size() == 7 && name[0] == '#' && sscanf(name.c_str() + 1, "%6x", &rgb) == 1) { + return IM_COL32((rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff, 255); + } + return 0; +} + +std::vector> parseHelpHtml(const std::string &raw) { + std::vector> lines(1); + HelpRun style; + std::vector span_stack; + bool prev_space = true; // html collapses whitespace; leading whitespace is dropped + std::string pending; + auto flush = [&]() { + if (!pending.empty()) { + HelpRun run = style; + run.text = pending; + lines.back().push_back(run); + pending.clear(); + } + }; + auto push_swatch = [&](ImU32 color) { + flush(); + HelpRun run = style; + run.swatch = true; + if (color) run.color = color; + lines.back().push_back(run); + prev_space = false; + }; + for (size_t i = 0; i < raw.size(); ++i) { + const char c = raw[i]; + if (c == '<') { + const size_t close = raw.find('>', i); + if (close == std::string::npos) break; + const std::string tag = raw.substr(i + 1, close - i - 1); + i = close; + if (tag.compare(0, 3, "!--") == 0) continue; + flush(); + if (tag == "b") { + style.bold = true; + } else if (tag == "/b") { + style.bold = false; + } else if (tag.compare(0, 2, "br") == 0) { + lines.emplace_back(); + prev_space = true; + } else if (tag.compare(0, 4, "span") == 0) { + span_stack.push_back(style); + const size_t st = tag.find("style=\""); + if (st != std::string::npos) { + const std::string css = tag.substr(st + 7, tag.find('"', st + 7) - st - 7); + size_t pos = 0; + while (pos < css.size()) { + const size_t semi = css.find(';', pos); + const std::string decl = css.substr(pos, semi == std::string::npos ? std::string::npos : semi - pos); + const size_t colon = decl.find(':'); + if (colon != std::string::npos) { + const std::string key = decl.substr(0, colon), value = decl.substr(colon + 1); + if (key == "color") style.color = helpColor(value); + if (key == "background-color") style.chip = true; + } + if (semi == std::string::npos) break; + pos = semi + 1; + } + } + } else if (tag == "/span") { + if (!span_stack.empty()) { + style = span_stack.back(); + span_stack.pop_back(); + } + } + } else if (c == '&') { + static const std::pair entities[] = {{" ", " "}}; + bool matched = false; + for (const auto &[name, text] : entities) { + if (raw.compare(i, strlen(name), name) == 0) { + pending += text; + prev_space = false; + i += strlen(name) - 1; + matched = true; + break; + } + } + if (!matched && raw.compare(i, 7, "■") == 0) { // the filled square of the byte color legend + push_swatch(0); + i += 6; + matched = true; + } + if (!matched) { + pending += c; + prev_space = false; + } + } else if (isspace(static_cast(c))) { + if (!prev_space) pending += ' '; + prev_space = true; + } else if (c == '#' && i + 6 < raw.size() && helpColor(raw.substr(i, 7)) != 0) { // #rrggbb legend token + push_swatch(helpColor(raw.substr(i, 7))); + i += 6; + } else { + pending += c; + prev_space = false; + } + } + flush(); + for (auto &line : lines) { // trim the collapsed whitespace at the line ends + if (!line.empty() && !line.back().text.empty() && line.back().text.back() == ' ') line.back().text.pop_back(); + if (!line.empty() && !line.front().text.empty() && line.front().text.front() == ' ') line.front().text.erase(0, 1); + } + while (!lines.empty() && lines.back().empty()) lines.pop_back(); + return lines; +} +} // namespace + +void HelpOverlay::toggle() { + visible_ = !visible_; + opened_frame_ = ImGui::GetFrameCount(); +} + +void HelpOverlay::add(const std::string &text, const ImRect &rect) { + if (visible_) texts_.emplace_back(text, rect); +} + +void HelpOverlay::draw() { + if (!visible_) return; + const ImGuiViewport *viewport = ImGui::GetMainViewport(); + ImDrawList *dl = ImGui::GetForegroundDrawList(); + const ImRect work_rect(viewport->WorkPos, ImVec2(viewport->WorkPos.x + viewport->WorkSize.x, viewport->WorkPos.y + viewport->WorkSize.y)); + dl->AddRectFilled(viewport->Pos, ImVec2(viewport->Pos.x + viewport->Size.x, viewport->Pos.y + viewport->Size.y), IM_COL32(0, 0, 0, 50)); + ImFont *font = ImGui::GetFont(); + ImFont *bold_font = boldFont() ? boldFont() : font; + const float font_size = ImGui::GetFontSize(); + const float line_h = ImGui::GetTextLineHeightWithSpacing(); + auto run_width = [&](const HelpRun &r) { + if (r.swatch) return font_size; + return (r.bold ? bold_font : font)->CalcTextSizeA(font_size, FLT_MAX, 0.0f, r.text.c_str()).x; + }; + for (const auto &[raw, rect] : texts_) { + if (raw.empty()) continue; + const auto lines = parseHelpHtml(raw); + float width = 0; + for (const auto &line : lines) { + float w = 0; + for (const auto &r : line) w += run_width(r); + width = std::max(width, w); + } + const ImVec2 size(width, lines.size() * line_h); + const ImVec2 center((rect.Min.x + rect.Max.x) * 0.5f, (rect.Min.y + rect.Max.y) * 0.5f); + if (!work_rect.Contains(center)) continue; // a torn off panel is in another viewport + const ImVec2 min(center.x - size.x * 0.5f - 8.0f, center.y - size.y * 0.5f - 8.0f); + const ImVec2 max(center.x + size.x * 0.5f + 8.0f, center.y + size.y * 0.5f + 8.0f); + // pale yellow in the light theme + const ImU32 tooltip_base = isDarkTheme() ? ImGui::GetColorU32(ImGuiCol_PopupBg) : IM_COL32(255, 255, 220, 255); + dl->AddRectFilled(min, max, tooltip_base); + float y = min.y + 8.0f; + for (const auto &line : lines) { + float x = min.x + 8.0f; + for (const auto &r : line) { + const float w = run_width(r); + const ImU32 color = r.color ? r.color : ImGui::GetColorU32(ImGuiCol_Text); + if (r.swatch) { + dl->AddRectFilled(ImVec2(x + 2, y + 3), ImVec2(x + font_size - 2, y + font_size - 1), color); + } else { + if (r.chip) dl->AddRectFilled(ImVec2(x, y), ImVec2(x + w, y + font_size), IM_COL32(211, 211, 211, 255)); // lightGray + dl->AddText(r.bold ? bold_font : font, font_size, ImVec2(x, y), color, r.text.c_str()); + } + x += w; + } + y += line_h; + } + } + texts_.clear(); + // ignore the release of the click that opened the overlay + if (ImGui::IsMouseReleased(ImGuiMouseButton_Left) && ImGui::GetFrameCount() != opened_frame_) visible_ = false; +} diff --git a/openpilot/tools/cabana/ui/helpoverlay.h b/openpilot/tools/cabana/ui/helpoverlay.h new file mode 100644 index 0000000000..2639d20946 --- /dev/null +++ b/openpilot/tools/cabana/ui/helpoverlay.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include + +#include "imgui_internal.h" + +// Dims the window and shows each widget's whatsThis text at its center; any click closes it. +// The texts are rich text: ,
, , &entities; and #rrggbb +// tokens (the video legend) are rendered, everything else is ignored. +class HelpOverlay { +public: + void toggle(); + bool visible() const { return visible_; } + // collected while the widgets draw, consumed by draw() + void add(const std::string &text, const ImRect &rect); + void draw(); + +private: + std::vector> texts_; + bool visible_ = false; + int opened_frame_ = -1; +}; diff --git a/openpilot/tools/cabana/ui/icons.h b/openpilot/tools/cabana/ui/icons.h new file mode 100644 index 0000000000..3a736fb3c9 --- /dev/null +++ b/openpilot/tools/cabana/ui/icons.h @@ -0,0 +1,40 @@ +#pragma once + +// bootstrap icon glyphs, merged into the fonts by style.cc +namespace icon { +constexpr const char ARROW_CLOCKWISE[] = "\xef\x84\x96"; +constexpr const char ARROW_COUNTERCLOCKWISE[] = "\xef\x84\x97"; +constexpr const char ARROW_DOWN_LEFT_SQUARE[] = "\xef\x84\x9d"; +constexpr const char ARROW_UP_RIGHT_SQUARE[] = "\xef\x85\x83"; +constexpr const char CHEVRON_LEFT[] = "\xef\x8a\x84"; +constexpr const char CHEVRON_RIGHT[] = "\xef\x8a\x85"; +constexpr const char DASH[] = "\xef\x8b\xaa"; +constexpr const char DASH_SQUARE[] = "\xef\x8b\xa9"; +constexpr const char EXCLAMATION_TRIANGLE[] = "\xef\x8c\xbb"; +constexpr const char FAST_FORWARD[] = "\xef\x9f\xb4"; +constexpr const char FILETYPE_CSV[] = "\xef\x9d\x83"; +constexpr const char FOLDER[] = "\xef\x8f\x99"; +constexpr const char FILE_EARMARK[] = "\xef\x8e\x92"; +constexpr const char FILE_EARMARK_RULED[] = "\xef\x8e\x85"; +constexpr const char PLUS_SQUARE[] = "\xef\x93\xbd"; +constexpr const char GRAPH_UP[] = "\xef\x8f\xb2"; +constexpr const char GRIP_HORIZONTAL[] = "\xef\x8f\xbd"; +constexpr const char INFO_CIRCLE[] = "\xef\x90\xb1"; +constexpr const char LIST[] = "\xef\x91\xb9"; +constexpr const char PAUSE[] = "\xef\x93\x84"; +constexpr const char PENCIL[] = "\xef\x93\x8b"; +constexpr const char PLAY[] = "\xef\x93\xb5"; +constexpr const char PLUS[] = "\xef\x93\xbe"; +constexpr const char RAQUO[] = "\xc2\xbb"; // U+00BB, not a bootstrap icon: the toolbar extension button +constexpr const char REPEAT[] = "\xef\xa0\x93"; +constexpr const char REPEAT_1[] = "\xef\xa0\x92"; +constexpr const char REWIND[] = "\xef\xa0\x99"; +constexpr const char SKIP_END[] = "\xef\x95\x98"; +constexpr const char STOPWATCH[] = "\xef\x96\x97"; +constexpr const char THREE_DOTS[] = "\xef\x97\x94"; +constexpr const char WINDOW_STACK[] = "\xef\x9b\x92"; +constexpr const char X[] = "\xef\x98\xaa"; +constexpr const char X_LG[] = "\xef\x99\x99"; +constexpr const char X_SQUARE[] = "\xef\x98\xa9"; +constexpr const char ZOOM_OUT[] = "\xef\x98\xad"; +} // namespace icon diff --git a/openpilot/tools/cabana/ui/inistate.cc b/openpilot/tools/cabana/ui/inistate.cc new file mode 100644 index 0000000000..2243690600 --- /dev/null +++ b/openpilot/tools/cabana/ui/inistate.cc @@ -0,0 +1,138 @@ +#include "tools/cabana/ui/inistate.h" + +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include + +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/qtstate.h" +#include "tools/cabana/ui/util.h" + +namespace inistate { + +MainWindowState main_window; + +namespace { + +void *readOpen(ImGuiContext *, ImGuiSettingsHandler *, const char *name) { + return strcmp(name, "MainWindow") == 0 ? (void *)&main_window : nullptr; +} + +void readLine(ImGuiContext *, ImGuiSettingsHandler *, void *entry, const char *line) { + auto *state = (MainWindowState *)entry; + int x = 0, y = 0, flag = 0; + float ratio = 0.0f; + if (sscanf(line, "Pos=%d,%d", &x, &y) == 2) { + state->pos[0] = x; + state->pos[1] = y; + } else if (sscanf(line, "Size=%d,%d", &x, &y) == 2) { + state->size[0] = x; + state->size[1] = y; + state->has_geometry = true; + } else if (sscanf(line, "Maximized=%d", &flag) == 1) { + state->maximized = flag != 0; + } else if (sscanf(line, "VideoSplitterRatio=%f", &ratio) == 1) { + state->video_splitter_ratio = ratio; + } else if (sscanf(line, "MessagesVisible=%d", &flag) == 1) { + state->messages_visible = flag != 0; + } else if (sscanf(line, "VideoVisible=%d", &flag) == 1) { + state->video_visible = flag != 0; + } +} + +void writeAll(ImGuiContext *, ImGuiSettingsHandler *handler, ImGuiTextBuffer *buf) { + buf->appendf("[%s][MainWindow]\n", handler->TypeName); + if (main_window.has_geometry) { + buf->appendf("Pos=%d,%d\n", main_window.pos[0], main_window.pos[1]); + buf->appendf("Size=%d,%d\n", main_window.size[0], main_window.size[1]); + } + buf->appendf("Maximized=%d\n", main_window.maximized ? 1 : 0); + buf->appendf("VideoSplitterRatio=%.4f\n", main_window.video_splitter_ratio); + buf->appendf("MessagesVisible=%d\n", main_window.messages_visible ? 1 : 0); + buf->appendf("VideoVisible=%d\n", main_window.video_visible ? 1 : 0); + buf->append("\n"); +} + +std::string migrateQtHeaderState(const qtstate::QtHeaderState &header) { + // imgui restarts the hash at "###" in a window name and BeginTable seeds the table id from the window id + const ImGuiID table_id = ImHashStr("messages", 0, ImHashStr(MESSAGES_PANEL_ID, 0)); + const float cell_padding = ImGui::GetStyle().CellPadding.x; + + ImGuiTextBuffer buf; + buf.appendf("[Table][0x%08X,%d]\n", table_id, qtstate::kMessageColumnCount); + for (int i = 0; i < qtstate::kMessageColumnCount; ++i) { + buf.appendf("Column %-2d", i); + if (i == 6) { + buf.append(" Weight=1.0000"); // DATA is the stretch column + } else { + buf.appendf(" Width=%d", std::max(1, (int)(header.width[i] - 2 * cell_padding))); + } + buf.appendf(" Visible=%d Order=%d", header.hidden[i] ? 0 : 1, header.visual[i]); + if (header.sort_shown && i == header.sort_section) { + // the port feeds imgui the flipped direction so the arrow matches Qt (flipSortDirection + // in ui/widgets/messageswidget.cc): Qt ascending is imgui descending + buf.appendf(" Sort=0%c", header.sort_order == 0 ? '^' : 'v'); + } + buf.append("\n"); + } + buf.append("\n"); + return std::string(buf.c_str()); +} + +std::string migrateQtState() { + auto geometry = qtstate::parseQtGeometry(settings.geometry); + auto splitter = qtstate::parseQtSplitter(settings.video_splitter_state); + + ImGuiTextBuffer buf; + if (geometry || splitter) { + buf.append("[Cabana][MainWindow]\n"); + if (geometry) { + buf.appendf("Pos=%d,%d\n", geometry->x, geometry->y); + buf.appendf("Size=%d,%d\n", geometry->w, geometry->h); + buf.appendf("Maximized=%d\n", geometry->maximized ? 1 : 0); + } + if (splitter) buf.appendf("VideoSplitterRatio=%.4f\n", splitter->ratio); + buf.append("\n"); + } + if (auto header = qtstate::parseQtHeaderState(settings.message_header_state)) { + buf.append(migrateQtHeaderState(*header).c_str()); + } + return std::string(buf.c_str()); +} + +} // namespace + +void addSettingsHandler() { + ImGuiSettingsHandler handler; + handler.TypeName = "Cabana"; + handler.TypeHash = ImHashStr("Cabana"); + handler.ReadOpenFn = readOpen; + handler.ReadLineFn = readLine; + handler.WriteAllFn = writeAll; + ImGui::AddSettingsHandler(&handler); +} + +void load() { + if (settings.ui_state.empty()) settings.ui_state = migrateQtState(); + if (!settings.ui_state.empty()) + ImGui::LoadIniSettingsFromMemory(settings.ui_state.data(), settings.ui_state.size()); +} + +void applyWindowGeometry(GLFWwindow *window) { + // Qt restoreGeometry corrects off-screen geometry, here we rely on the window manager + if (main_window.has_geometry && main_window.size[0] > 0 && main_window.size[1] > 0) { + glfwSetWindowPos(window, main_window.pos[0], main_window.pos[1]); + glfwSetWindowSize(window, main_window.size[0], main_window.size[1]); + } + if (main_window.maximized) glfwMaximizeWindow(window); +} + +std::string save() { + return std::string(ImGui::SaveIniSettingsToMemory()); +} + +} // namespace inistate diff --git a/openpilot/tools/cabana/ui/inistate.h b/openpilot/tools/cabana/ui/inistate.h new file mode 100644 index 0000000000..a460802d24 --- /dev/null +++ b/openpilot/tools/cabana/ui/inistate.h @@ -0,0 +1,27 @@ +#pragma once +#include + +struct GLFWwindow; + +// The imgui frontend's persisted state: the imgui ini text (windows, dock layout, table +// state) plus a custom [Cabana][MainWindow] section, stored as one string in Settings. +namespace inistate { + +struct MainWindowState { + int pos[2] = {0, 0}; + int size[2] = {0, 0}; + bool maximized = false; + bool has_geometry = false; + float video_splitter_ratio = -1.0f; // < 0: video at its size hint + bool messages_visible = true; + bool video_visible = true; +}; + +extern MainWindowState main_window; + +void addSettingsHandler(); // register the [Cabana] ini section +void load(); // migrate Qt state if needed, then LoadIniSettingsFromMemory +void applyWindowGeometry(GLFWwindow *window); // glfw pos/size/maximize from main_window +std::string save(); // SaveIniSettingsToMemory (caller fills main_window first) + +} // namespace inistate diff --git a/openpilot/tools/cabana/ui/main.cc b/openpilot/tools/cabana/ui/main.cc new file mode 100644 index 0000000000..e111c4d9f9 --- /dev/null +++ b/openpilot/tools/cabana/ui/main.cc @@ -0,0 +1,202 @@ +#include +#include +#include +#include +#include +#include + +#include "tools/cabana/streams/devicestream.h" +#include "tools/cabana/streams/pandastream.h" +#include "tools/cabana/streams/replaystream.h" +#ifdef __linux__ +#include "tools/cabana/streams/socketcanstream.h" +#endif +#include "tools/cabana/ui/app.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/utils/util.h" + +#ifdef __GLIBC__ +#include +#endif + +namespace { + +struct CabanaArgs { + bool demo = false; + bool auto_source = false; + bool qcam = false; + bool wide_road = false; + bool cabin = false; + bool msgq = false; + bool panda = false; + bool no_vipc = false; + bool no_cache = false; + std::string panda_serial; + std::string socketcan; + std::string zmq; + std::string data_dir; + std::string dbc; + std::string route; +}; + +void printUsage(const char *argv0) { + fprintf(stderr, + "Usage: %s [options] [route]\n" + "\n" + " route the drive to replay. find your drives at connect.comma.ai\n" + "\n" + "Options:\n" + " --help show this help\n" + " --demo use a demo route instead of providing your own\n" + " --auto Auto load the route from the best available source (no video):\n" + " internal, openpilotci, comma_api, car_segments, testing_closet\n" + " --qcam load qcamera\n" + " --wide-road load wide road camera (alias: --ecam)\n" + " --cabin load cabin camera (alias: --dcam)\n" + " --msgq read can messages from the msgq\n" + " --panda read can messages from panda\n" + " --panda-serial read can messages from panda with given serial\n" +#ifdef __linux__ + " --socketcan read can messages from given SocketCAN device\n" +#endif + " --zmq read can messages from zmq at the specified ip-address\n" + " --data_dir local directory with routes\n" + " --no-vipc do not output video\n" + " --no-cache turn off the local route file cache\n" + " --dbc dbc file to open\n", + argv0); +} + +bool takeValue(int argc, char *argv[], int &i, std::string &out) { + if (i + 1 >= argc) { + fprintf(stderr, "error: %s requires a value\n", argv[i]); + return false; + } + out = argv[++i]; + return true; +} + +// the process exit code, or nullopt to continue +std::optional parseArgs(int argc, char *argv[], CabanaArgs &args) { + for (int i = 1; i < argc; ++i) { + const char *a = argv[i]; + if (std::strcmp(a, "--help") == 0 || std::strcmp(a, "-h") == 0) { + printUsage(argv[0]); + return 0; + } else if (std::strcmp(a, "--demo") == 0) { + args.demo = true; + } else if (std::strcmp(a, "--auto") == 0) { + args.auto_source = true; + } else if (std::strcmp(a, "--qcam") == 0) { + args.qcam = true; + } else if (std::strcmp(a, "--wide-road") == 0 || std::strcmp(a, "--ecam") == 0) { + args.wide_road = true; + } else if (std::strcmp(a, "--cabin") == 0 || std::strcmp(a, "--dcam") == 0) { + args.cabin = true; + } else if (std::strcmp(a, "--msgq") == 0) { + args.msgq = true; + } else if (std::strcmp(a, "--panda") == 0) { + args.panda = true; + } else if (std::strcmp(a, "--panda-serial") == 0) { + if (!takeValue(argc, argv, i, args.panda_serial)) return 1; + args.panda = true; + } else if (std::strcmp(a, "--socketcan") == 0) { + if (!takeValue(argc, argv, i, args.socketcan)) return 1; +#ifndef __linux__ + fprintf(stderr, "error: --socketcan is only supported on Linux\n"); + return 1; +#endif + } else if (std::strcmp(a, "--zmq") == 0) { + if (!takeValue(argc, argv, i, args.zmq)) return 1; + } else if (std::strcmp(a, "--data_dir") == 0) { + if (!takeValue(argc, argv, i, args.data_dir)) return 1; + } else if (std::strcmp(a, "--no-vipc") == 0) { + args.no_vipc = true; + } else if (std::strcmp(a, "--no-cache") == 0) { + args.no_cache = true; + } else if (std::strcmp(a, "--dbc") == 0) { + if (!takeValue(argc, argv, i, args.dbc)) return 1; + } else if (a[0] == '-') { + fprintf(stderr, "error: unknown option %s\n", a); + printUsage(argv[0]); + return 1; + } else if (args.route.empty()) { + args.route = a; + } else { + fprintf(stderr, "error: unexpected argument %s\n", a); + printUsage(argv[0]); + return 1; + } + } + return std::nullopt; +} + +} // namespace + +int main(int argc, char *argv[]) { +#ifdef __GLIBC__ + // Worker threads (sparklines, chart series, replay) would each get their own glibc malloc arena and the + // arenas fragment without bound (RSS grew ~3 MB/min with charts open). macOS has a single allocator zone. + mallopt(M_ARENA_MAX, 1); +#endif + // ensure the current dir matches the executable's directory + std::error_code ec; + std::filesystem::current_path(executableDir(), ec); + + CabanaArgs args; + if (auto code = parseArgs(argc, argv, args)) return *code; + + std::unique_ptr stream; + StreamLoader stream_loader; + + if (args.msgq) { + stream = std::make_unique(); + } else if (!args.zmq.empty()) { + stream = std::make_unique(args.zmq); + } else if (args.panda) { + try { + stream = std::make_unique(PandaStreamConfig{.serial = args.panda_serial}); + } catch (std::exception &e) { + fprintf(stderr, "%s\n", e.what()); + return 1; + } +#ifdef __linux__ + } else if (!args.socketcan.empty()) { + if (!SocketCanStream::available()) { + fprintf(stderr, "error: SocketCAN is not available on this system\n"); + return 1; + } + stream = std::make_unique(SocketCanStreamConfig{.device = args.socketcan}); +#endif + } else { + uint32_t replay_flags = REPLAY_FLAG_NONE; + if (args.wide_road) replay_flags |= REPLAY_FLAG_WIDE_ROAD; + if (args.qcam) replay_flags |= REPLAY_FLAG_QCAMERA; + if (args.cabin) replay_flags |= REPLAY_FLAG_CABIN_CAMERA; + if (args.no_vipc) replay_flags |= REPLAY_FLAG_NO_VIPC; + if (args.no_cache) replay_flags |= REPLAY_FLAG_NO_FILE_CACHE; + + std::string route; + if (!args.route.empty()) { + route = args.route; + } else if (args.demo) { + route = DEMO_ROUTE; + } + if (!route.empty()) { + // the route file listing hits the comma API; load behind the window instead of before it + stream_loader = [route, data_dir = args.data_dir, replay_flags, auto_source = args.auto_source]() -> std::unique_ptr { + auto replay_stream = std::make_unique(); + Connection err = replay_stream->error.connect([](const std::string &msg) { + fprintf(stderr, "%s\n", msg.c_str()); + utils::runOnMainThread([msg]() { MessageBox::warning("Error", msg); }); + }); + if (!replay_stream->loadRoute(route, data_dir, replay_flags, auto_source)) { + return nullptr; + } + return replay_stream; + }; + } + } + + return run(std::move(stream), std::move(stream_loader), args.dbc); +} diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc new file mode 100644 index 0000000000..15b6ff8df8 --- /dev/null +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -0,0 +1,929 @@ +#include "tools/cabana/ui/mainwin.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include + +#include "json11/json11.hpp" +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/app.h" +#include "tools/cabana/ui/dialogs/filedialog.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/ui/inistate.h" +#include "tools/cabana/ui/threadpool.h" +#include "tools/cabana/ui/tools/findsignal.h" +#include "tools/cabana/ui/tools/findsimilarbits.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/export.h" +#include "tools/cabana/utils/util.h" +#include "tools/replay/py_downloader.h" +#include "tools/replay/util.h" + +namespace { +// dock window ids (the visible titles change, the part after ### is the identity) +constexpr const char *VIDEO_PANEL = "###VideoPanel"; +constexpr const char *CENTER_PANEL = "###CenterWidget"; +constexpr const char *CHARTS_WINDOW = "Charts###ChartsWindow"; +} // namespace + +MainWindow::MainWindow(GLFWwindow *window, std::unique_ptr stream, StreamLoader stream_loader, + const std::string &dbc_file) : window_(window) { + can = &dummy_; + video_splitter_ratio_ = inistate::main_window.video_splitter_ratio; + messages_visible_ = inistate::main_window.messages_visible; + video_visible_ = inistate::main_window.video_visible; + loadFingerprints(); + std::error_code ec; + for (const auto &entry : std::filesystem::directory_iterator(OPENDBC_FILE_PATH, ec)) { + if (entry.is_regular_file() && entry.path().extension() == ".dbc") { + opendbc_names_.push_back(entry.path().filename().string()); + } + } + std::sort(opendbc_names_.begin(), opendbc_names_.end()); + + // download handlers are called from download threads + installDownloadProgressHandler([this](uint64_t cur, uint64_t total, bool success) { + utils::runOnMainThread([this, cur, total, success]() { updateDownloadProgress(cur, total, success); }); + }); + installMessageHandler([this](ReplyMsgType type, const std::string &msg) { + utils::runOnMainThread([this, msg]() { showStatusMessage(msg, 2000); }); + }); + + connections_.push_back(dbc()->fileChanged.connect([this]() { dbcFileChanged(); })); + connections_.push_back(UndoStack::instance()->cleanChanged.connect([this](bool clean) { + window_modified_ = !clean; + updateWindowTitle(); + })); + + startup_stream_ = std::move(stream); + startup_loader_ = std::move(stream_loader); + nextFrame([this, dbc_file]() { + if (startup_loader_) { + loadStartupStream(dbc_file); + } else { + startup_stream_ ? openStream(std::move(startup_stream_), dbc_file) : selectAndOpenStream(); + } + }); +} + +void MainWindow::loadFingerprints() { + std::ifstream json_file((executableDir() / "dbc/car_fingerprint_to_dbc.json")); + if (!json_file) return; + const std::string contents{std::istreambuf_iterator(json_file), std::istreambuf_iterator()}; + std::string err; + auto doc = json11::Json::parse(contents, err); + if (!err.empty() || !doc.is_object()) return; + for (const auto &kv : doc.object_items()) { + if (kv.second.is_string()) { + fingerprint_to_dbc_.emplace(kv.first, kv.second.string_value()); + } + } +} + +void MainWindow::drawFileMenu() { + const bool has_stream = hasStream(); + if (ImGui::MenuItem("Open Stream...")) selectAndOpenStream(); + if (ImGui::MenuItem("Close stream", nullptr, false, has_stream)) closeStream(); + if (ImGui::MenuItem("Export to CSV...", nullptr, false, has_stream)) exportToCSV(); + ImGui::Separator(); + + if (ImGui::MenuItem("New DBC File", "Ctrl+N")) newFile(); + if (ImGui::MenuItem("Open DBC File...", "Ctrl+O")) openFile(); + + if (ImGui::BeginMenu("Manage DBC Files", has_stream)) { + drawManageDBCsMenu(); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Open Recent")) { + drawRecentFilesMenu(); + ImGui::EndMenu(); + } + + ImGui::Separator(); + if (ImGui::BeginMenu("Load DBC from commaai/opendbc")) { + for (const auto &name : opendbc_names_) { + if (ImGui::MenuItem(name.c_str())) loadDBCFromOpendbc(name); + } + ImGui::EndMenu(); + } + if (ImGui::MenuItem("Load DBC From Clipboard")) loadFromClipboard(); + + ImGui::Separator(); + const int cnt = dbc()->nonEmptyDBCCount(); + const std::string save_text = cnt > 1 ? "Save " + std::to_string(cnt) + " DBCs..." : "Save DBC..."; + if (ImGui::MenuItem(save_text.c_str(), "Ctrl+S", false, cnt > 0)) save(); + if (ImGui::MenuItem("Save DBC As...", "Ctrl+Shift+S", false, cnt == 1)) saveAs(); + // TODO: Support clipboard for multiple files + if (ImGui::MenuItem("Copy DBC To Clipboard", nullptr, false, cnt == 1)) saveToClipboard(); + + ImGui::Separator(); + if (ImGui::MenuItem("Settings...")) openSettings(); + + ImGui::Separator(); + if (ImGui::MenuItem("Exit", "Ctrl+Q")) close(); +} + +void MainWindow::drawMenuBar() { + if (!ImGui::BeginMainMenuBar()) return; + if (ImGui::BeginMenu("File")) { + drawFileMenu(); + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Edit")) { + auto stack = UndoStack::instance(); + const std::string undo_text = stack->canUndo() ? "Undo " + stack->undoText() : "Undo"; + const std::string redo_text = stack->canRedo() ? "Redo " + stack->redoText() : "Redo"; + if (ImGui::MenuItem(undo_text.c_str(), "Ctrl+Z", false, stack->canUndo())) stack->undo(); + if (ImGui::MenuItem(redo_text.c_str(), "Ctrl+Shift+Z", false, stack->canRedo())) stack->redo(); + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("View")) { + if (ImGui::MenuItem("Full Screen", "Ctrl+F11")) toggleFullScreen(); + ImGui::Separator(); + ImGui::MenuItem(messages_widget_ ? messages_widget_->title().c_str() : "MESSAGES", nullptr, &messages_visible_); + ImGui::MenuItem(video_dock_title_.empty() ? "##video_dock" : video_dock_title_.c_str(), nullptr, &video_visible_); + ImGui::Separator(); + if (ImGui::MenuItem("Reset Window Layout")) { + messages_visible_ = video_visible_ = true; + reset_layout_ = true; + } + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Tools", hasStream())) { + if (ImGui::MenuItem("Find Similar Bits")) findSimilarBits(); + if (ImGui::MenuItem("Find Signal")) findSignal(); + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Help")) { + if (ImGui::MenuItem("Help", "F1")) toggleHelp(); + ImGui::EndMenu(); + } + ImGui::EndMainMenuBar(); +} + +void MainWindow::createDockWidgets() { + widget_connections_.clear(); + messages_widget_ = std::make_unique(); + widget_connections_.push_back(messages_widget_->msgSelectionChanged.connect([this](const MessageId &id) { center_widget_.setMessage(id); })); + + charts_widget_ = std::make_unique(); + center_widget_.setChartsWidget(charts_widget_.get()); + video_widget_ = std::make_unique(); + widget_connections_.push_back(charts_widget_->toggleChartsDocking.connect([this]() { toggleChartsDocking(); })); + widget_connections_.push_back(charts_widget_->showTip.connect([this](double sec) { video_widget_->showThumbnail(sec); })); +} + +void MainWindow::showStatusMessage(const std::string &msg, int timeout_ms) { + status_bar_.message = msg; + status_bar_.message_until = timeout_ms > 0 ? ImGui::GetTime() + timeout_ms / 1000.0 : 0; +} + +void MainWindow::updateWindowTitle() { + std::string title; + for (auto f : dbc()->allDBCFiles()) { + if (!title.empty()) title += " | "; + title += "(" + toString(dbc()->sources(f)) + ") " + f->name(); + } + if (window_modified_) title += "*"; + if (!title.empty()) title += " \xe2\x80\x94 "; // em dash separator + title += "Cabana"; + glfwSetWindowTitle(window_, title.c_str()); +} + +void MainWindow::dbcFileChanged() { + UndoStack::instance()->clear(); + updateWindowTitle(); + nextFrame([this]() { restoreSessionState(); }); +} + +void MainWindow::selectAndOpenStream() { + stream_selector_.open([this](std::unique_ptr stream, const std::string &dbc_file) { + if (stream) { + openStream(std::move(stream), dbc_file); + } else if (!stream_) { + openStream(std::make_unique()); + } + }); +} + +// the route file listing hits the comma API, so the loader runs on a worker behind the window +void MainWindow::loadStartupStream(const std::string &dbc_file) { + wait_dlg_.text = "Loading route..."; + wait_dlg_.value = 0; + wait_dlg_.open = true; + wait_dlg_.show_at = ImGui::GetTime() + 4.0; // minimum duration before the dialog shows + ThreadPool::instance().run([this, dbc_file, loader = std::move(startup_loader_)]() { + AbstractStream *loaded = nullptr; + std::string error; + try { + loaded = loader().release(); + } catch (const std::exception &e) { + // the pool swallows exceptions, so the wait dialog would spin forever + error = e.what(); + } + utils::runOnMainThread([this, dbc_file, loaded, error]() { + wait_dlg_.open = false; + std::unique_ptr stream(loaded); + if (!error.empty()) { + fprintf(stderr, "%s\n", error.c_str()); + MessageBox::warning("Failed to load route", error); + } + stream ? openStream(std::move(stream), dbc_file) : openStream(std::make_unique()); + }); + }); +} + +void MainWindow::closeStream() { + openStream(std::make_unique()); + if (dbc()->nonEmptyDBCCount() > 0) { + dbc()->fileChanged(); + } + showStatusMessage("stream closed"); +} + +void MainWindow::exportToCSV() { + std::string dir = settings.last_dir + "/" + can->routeName() + ".csv"; + FileDialog::getSaveFileName("Export stream to CSV file", dir, ".csv", [](const std::string &fn) { + if (!fn.empty()) { + utils::exportToCSV(fn); + } + }); +} + +void MainWindow::newFile(SourceSet s) { + closeFile(s, [s]() { dbc()->open(s, std::string(""), std::string("")); }); +} + +void MainWindow::openFile(SourceSet s) { + remindSaveChanges([this, s]() { + FileDialog::getOpenFileName("Open File", settings.last_dir, ".dbc", [this, s](const std::string &fn) { + if (!fn.empty()) { + loadFile(fn, s); + } + }); + }); +} + +void MainWindow::loadFile(const std::string &fn, SourceSet s, std::function then) { + if (!fn.empty()) { + closeFile(s, [this, fn, s, then]() { + std::string error; + if (dbc()->open(s, fn, &error)) { + updateRecentFiles(fn); + showStatusMessage("DBC File " + fn + " loaded", 2000); + if (then) then(); + } else { + MessageBox::warning("Failed to load DBC file", "Failed to parse DBC file " + fn, error, then); + } + }); + } else if (then) { + then(); + } +} + +void MainWindow::loadDBCFromOpendbc(const std::string &name) { + loadFile(std::string(OPENDBC_FILE_PATH) + "/" + name); +} + +void MainWindow::loadFromClipboard(SourceSet s, bool close_all) { + std::string text; + if (!utils::getClipboardText(&text)) { + MessageBox::warning("Load From Clipboard", "No clipboard tool found. Install xclip (X11) or wl-clipboard (Wayland)."); + return; + } + if (text.empty()) { + MessageBox::warning("Load From Clipboard", "Clipboard is empty."); + return; + } + + closeFile(s, [s, text]() { + std::string error; + bool ret = dbc()->open(s, std::string(""), text, &error); + if (ret && dbc()->nonEmptyDBCCount() > 0) { + MessageBox::information("Load From Clipboard", "DBC Successfully Loaded!"); + } else { + MessageBox::warning("Failed to load DBC from clipboard", "Make sure that you paste the text with correct format.", error); + } + }); +} + +MainWindow::~MainWindow() { + installDownloadProgressHandler(nullptr); + installMessageHandler(nullptr); + releaseStream(); + can = nullptr; +} + +// the tool dialogs are connected into the messages widget, and the video widget's RouteInfoDlg keeps a raw +// pointer to the replay, so the dialogs go first and the widgets before the stream; the stream's destructor +// joins the threads that read the global `can` +void MainWindow::releaseStream() { + tool_dialogs_.clear(); + wait_dlg_.connection.disconnect(); + wait_dlg_.open = false; + widget_connections_.clear(); + charts_widget_.reset(); + video_widget_.reset(); + center_widget_.clear(); + messages_widget_.reset(); + stream_connections_.clear(); + stream_.reset(); + can = &dummy_; +} + +void MainWindow::openStream(std::unique_ptr stream, const std::string &dbc_file) { + releaseStream(); + startStream(std::move(stream), dbc_file); +} + +void MainWindow::startStream(std::unique_ptr stream, const std::string &dbc_file) { + stream_ = std::move(stream); + can = stream_.get(); + stream_connections_.push_back(can->error.connect([](const std::string &msg) { + MessageBox::warning("Error", msg); + })); + can->start(); + + loadFile(dbc_file, SOURCE_ALL, [this]() { + showStatusMessage("Stream [" + can->routeName() + "] started", 2000); + createDockWidgets(); + + video_dock_title_ = can->routeName(); + // Don't overwrite already loaded DBC + if (!dbc()->nonEmptyDBCCount()) { + newFile(); + } + + stream_connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &) { eventsMerged(); })); + + if (hasStream()) { + wait_dlg_.text = can->liveStreaming() ? "Waiting for the live stream to start..." : "Loading segment data..."; + wait_dlg_.value = 0; + wait_dlg_.open = true; + wait_dlg_.show_at = ImGui::GetTime() + 4.0; // minimum duration before the dialog shows + wait_dlg_.connection = can->eventsMerged.connect([this](const MessageEventsMap &) { + wait_dlg_.open = false; + wait_dlg_.connection.disconnect(); + }); + } + }); +} + +void MainWindow::eventsMerged() { + const std::string fingerprint = can->carFingerprint(); + if (!can->liveStreaming() && std::exchange(car_fingerprint_, fingerprint) != fingerprint) { + video_dock_title_ = "ROUTE: " + can->routeName() + " FINGERPRINT: " + (car_fingerprint_.empty() ? "Unknown Car" : car_fingerprint_); + // Don't overwrite already loaded DBC + auto it = fingerprint_to_dbc_.find(car_fingerprint_); + if (!dbc()->nonEmptyDBCCount() && it != fingerprint_to_dbc_.end()) { + nextFrame([this, dbc_name = it->second]() { loadDBCFromOpendbc(dbc_name + ".dbc"); }); + } + } +} + +void MainWindow::saveFiles(bool as, std::function then) { + const std::vector files = dbc()->nonEmptyDBCFiles(); + auto next = std::make_shared>(); + *next = [this, as, files, next, then](size_t i) { + if (i >= files.size()) { + if (then) then(); + return; + } + auto cb = [next, i]() { (*next)(i + 1); }; + as ? saveFileAs(files[i], cb) : saveFile(files[i], cb); + }; + (*next)(0); +} + +void MainWindow::save(std::function then) { + saveFiles(false, std::move(then)); +} + +void MainWindow::saveAs(std::function then) { + saveFiles(true, std::move(then)); +} + +void MainWindow::closeFile(SourceSet s, std::function then) { + remindSaveChanges([s, then]() { + if (s == SOURCE_ALL) { + dbc()->closeAll(); + } else { + dbc()->close(s); + } + if (then) then(); + }); +} + +void MainWindow::closeFile(DBCFile *dbc_file) { + assert(dbc_file != nullptr); + remindSaveChanges([this, dbc_file]() { + dbc()->close(dbc_file); + // Ensure we always have at least one file open + if (dbc()->dbcCount() == 0) { + newFile(); + } + }); +} + +void MainWindow::saveFile(DBCFile *dbc_file, std::function then) { + assert(dbc_file != nullptr); + if (!dbc_file->filename.empty()) { + dbc_file->save(); + UndoStack::instance()->setClean(); + showStatusMessage("File saved", 2000); + if (then) then(); + } else if (!dbc_file->isEmpty()) { + saveFileAs(dbc_file, then); + } else if (then) { + then(); + } +} + +void MainWindow::saveFileAs(DBCFile *dbc_file, std::function then) { + std::string title = "Save File (bus: " + toString(dbc()->sources(dbc_file)) + ")"; + std::string default_path = (std::filesystem::path(settings.last_dir) / "untitled.dbc").string(); + FileDialog::getSaveFileName(title, default_path, ".dbc", [this, dbc_file, then](const std::string &fn) { + if (!fn.empty()) { + dbc_file->saveAs(fn); + UndoStack::instance()->setClean(); + showStatusMessage("File saved as " + fn, 2000); + updateRecentFiles(fn); + } + if (then) then(); + }); +} + +void MainWindow::saveToClipboard() { + // Should not be called with more than 1 file open + for (auto dbc_file : dbc()->nonEmptyDBCFiles()) { + saveFileToClipboard(dbc_file); + } +} + +void MainWindow::saveFileToClipboard(DBCFile *dbc_file) { + assert(dbc_file != nullptr); + copyToClipboard(dbc_file->generateDBC()); +} + +void MainWindow::copyToClipboard(const std::string &text) { + if (utils::setClipboardText(text)) { + MessageBox::information("Copy To Clipboard", "DBC Successfully copied!"); + } else { + MessageBox::warning("Copy To Clipboard", "Failed to copy DBC to clipboard. Install xclip (X11) or wl-clipboard (Wayland)."); + } +} + +void MainWindow::drawManageDBCsMenu() { + for (int source : can->sources) { + if (source >= 64) continue; // Sent and blocked buses are handled implicitly + + SourceSet ss = {source, uint8_t(source + 128), uint8_t(source + 192)}; + + auto dbc_file = dbc()->findDBCFile(source); + const std::string title = "Bus " + std::to_string(source) + " (" + (dbc_file ? dbc_file->name() : "No DBCs loaded") + ")"; + ImGui::PushID(source); + if (ImGui::BeginMenu(title.c_str())) { + if (ImGui::MenuItem("New DBC File...")) newFile(ss); + if (ImGui::MenuItem("Open DBC File...")) openFile(ss); + if (ImGui::MenuItem("Load DBC From Clipboard...")) loadFromClipboard(ss, false); + + // Show sub-menu for each dbc for this source. + if (dbc_file) { + ImGui::Separator(); + ImGui::MenuItem((dbc_file->name() + " (" + toString(dbc()->sources(dbc_file)) + ")").c_str(), nullptr, false, false); + if (ImGui::MenuItem("Save...")) saveFile(dbc_file); + if (ImGui::MenuItem("Save As...")) saveFileAs(dbc_file); + if (ImGui::MenuItem("Copy to Clipboard...")) saveFileToClipboard(dbc_file); + if (ImGui::MenuItem("Remove from this bus...")) closeFile(ss, {}); + if (ImGui::MenuItem("Remove from all buses...")) closeFile(dbc_file); + } + ImGui::EndMenu(); + } + ImGui::PopID(); + } +} + +void MainWindow::updateRecentFiles(const std::string &fn) { + settings.recent_files.erase(std::remove(settings.recent_files.begin(), settings.recent_files.end(), fn), settings.recent_files.end()); + settings.recent_files.insert(settings.recent_files.begin(), fn); + while (settings.recent_files.size() > MAX_RECENT_FILES) { + settings.recent_files.pop_back(); + } + settings.last_dir = std::filesystem::absolute(fn).parent_path().string(); +} + +void MainWindow::drawRecentFilesMenu() { + int num_recent_files = std::min(settings.recent_files.size(), MAX_RECENT_FILES); + if (!num_recent_files) { + ImGui::MenuItem("No Recent Files", nullptr, false, false); + return; + } + + for (int i = 0; i < num_recent_files; ++i) { + std::string text = std::to_string(i + 1) + " " + std::filesystem::path(settings.recent_files[i]).filename().string(); + ImGui::PushID(i); + if (ImGui::MenuItem(text.c_str())) loadFile(settings.recent_files[i]); + ImGui::PopID(); + } +} + +void MainWindow::remindSaveChanges(std::function then) { + if (UndoStack::instance()->isClean()) { + UndoStack::instance()->clear(); + if (then) then(); + return; + } + std::string text = "You have unsaved changes. Press ok to save them, cancel to discard."; + MessageBox::question("Unsaved Changes", text, [this, then](bool ok) { + if (ok) { + save([this, then]() { remindSaveChanges(then); }); + } else { + UndoStack::instance()->clear(); + if (then) then(); + } + }); +} + +void MainWindow::updateDownloadProgress(uint64_t cur, uint64_t total, bool success) { + const double fraction = total > 0 ? cur / (double)total : 0.0; + if (wait_dlg_.open) wait_dlg_.value = (int)(fraction * 100); + if (success && cur < total) { + status_bar_.progress_value = fraction; + status_bar_.progress_text = "Downloading " + std::to_string((int)(fraction * 100)) + "% (" + formattedDataSize(total) + ")"; + status_bar_.progress_visible = true; + } else { + status_bar_.progress_visible = false; + } +} + +void MainWindow::toggleChartsDocking() { + charts_floating_ = !charts_floating_; + charts_widget_->setIsDocked(!charts_floating_); +} + +void MainWindow::close() { + if (closing_) return; + closing_ = true; + remindSaveChanges([this]() { finishClose(); }); +} + +void MainWindow::finishClose() { + // save states + auto &state = inistate::main_window; + state.maximized = glfwGetWindowAttrib(window_, GLFW_MAXIMIZED); + if (full_screen_) { +#ifndef __APPLE__ + // macOS full screen is the native Cocoa toggle, keep the loaded geometry there + state.pos[0] = windowed_rect_[0]; state.pos[1] = windowed_rect_[1]; + state.size[0] = windowed_rect_[2]; state.size[1] = windowed_rect_[3]; +#endif + } else if (!state.maximized) { + glfwGetWindowPos(window_, &state.pos[0], &state.pos[1]); + glfwGetWindowSize(window_, &state.size[0], &state.size[1]); + } + state.has_geometry = state.size[0] > 0 && state.size[1] > 0; + state.video_splitter_ratio = video_splitter_ratio_; + state.messages_visible = messages_visible_; + state.video_visible = video_visible_; + settings.ui_state = inistate::save(); + + saveSessionState(); + settings.save(); + exited_ = true; +} + +void MainWindow::openSettings() { + settings_dialog_.open(); +} + +void MainWindow::findSimilarBits() { + auto dlg = std::make_unique(); + dlg->connections_.push_back(dlg->openMessage.connect([this](const MessageId &id) { messages_widget_->selectMessage(id); })); + tool_dialogs_.push_back(std::move(dlg)); +} + +void MainWindow::findSignal() { + auto dlg = std::make_unique(); + dlg->connections_.push_back(dlg->openMessage.connect([this](const MessageId &id) { messages_widget_->selectMessage(id); })); + tool_dialogs_.push_back(std::move(dlg)); +} + +void MainWindow::toggleHelp() { + help_overlay_.toggle(); +} + +void MainWindow::toggleFullScreen() { +#ifdef __APPLE__ + toggleNativeFullScreen(window_); +#else + full_screen_ = !full_screen_; + if (full_screen_) { + glfwGetWindowPos(window_, &windowed_rect_[0], &windowed_rect_[1]); + glfwGetWindowSize(window_, &windowed_rect_[2], &windowed_rect_[3]); + GLFWmonitor *monitor = glfwGetPrimaryMonitor(); + const GLFWvidmode *mode = glfwGetVideoMode(monitor); + glfwSetWindowMonitor(window_, monitor, 0, 0, mode->width, mode->height, mode->refreshRate); + } else { + glfwSetWindowMonitor(window_, nullptr, windowed_rect_[0], windowed_rect_[1], windowed_rect_[2], windowed_rect_[3], 0); + glfwMaximizeWindow(window_); + } +#endif +} + +void MainWindow::saveSessionState() { + settings.recent_dbc_file = ""; + settings.active_msg_id = ""; + settings.selected_msg_ids.clear(); + settings.active_charts.clear(); + + const auto files = dbc()->nonEmptyDBCFiles(); + if (!files.empty()) settings.recent_dbc_file = files.front()->filename; + + if (auto *detail = center_widget_.getDetailWidget()) { + auto [active_id, ids] = detail->serializeMessageIds(); + settings.active_msg_id = active_id; + settings.selected_msg_ids = ids; + } + if (charts_widget_) { + settings.active_charts = charts_widget_->serializeChartIds(); + } +} + +void MainWindow::restoreSessionState() { + if (settings.recent_dbc_file.empty() || dbc()->nonEmptyDBCCount() == 0) return; + + if (dbc()->nonEmptyDBCFiles().front()->filename != settings.recent_dbc_file) return; + + if (!settings.selected_msg_ids.empty()) { + center_widget_.ensureDetailWidget()->restoreTabs(settings.active_msg_id, settings.selected_msg_ids); + } + + if (charts_widget_ != nullptr && !settings.active_charts.empty()) { + charts_widget_->restoreChartsFromIds(settings.active_charts); + } +} + +void MainWindow::handleShortcuts() { + const ImGuiIO &io = ImGui::GetIO(); + for (const KeyEvent &e : takeKeyEvents()) { + const bool ctrl = e.mods & (GLFW_MOD_CONTROL | GLFW_MOD_SUPER); + const bool shift = e.mods & GLFW_MOD_SHIFT; + // a focused text input consumes Space but not the Ctrl/F-key sequences + if (e.key == GLFW_KEY_SPACE && !ctrl && can && !io.WantTextInput) can->pause(!can->isPaused()); + if (e.key == GLFW_KEY_F1) toggleHelp(); + if (e.key == GLFW_KEY_F11 && ctrl) toggleFullScreen(); + // an open popup or a focused text input takes Esc first + if (e.key == GLFW_KEY_ESCAPE && full_screen_ && !io.WantTextInput && + !ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel)) { + toggleFullScreen(); + } + if (!ctrl) continue; + if (e.key == GLFW_KEY_N) newFile(); + if (e.key == GLFW_KEY_O) openFile(); + if (e.key == GLFW_KEY_S) { + if (shift) { + if (dbc()->nonEmptyDBCCount() == 1) saveAs(); + } else if (dbc()->nonEmptyDBCCount() > 0) { + save(); + } + } + // a focused text input swallows Ctrl+Z / Ctrl+Shift+Z + if (e.key == GLFW_KEY_Z && !io.WantTextInput) shift ? UndoStack::instance()->redo() : UndoStack::instance()->undo(); + if (e.key == GLFW_KEY_Q) close(); + } +} + +void MainWindow::drawStatusBar() { + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::GetStyle().Colors[ImGuiCol_MenuBarBg]); + ImGui::BeginChild("status_bar", ImVec2(0, ImGui::GetFrameHeight()), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar); + // a borderless child gets no WindowPadding, so both ends sit flush against the edge and clip. Inset by + // WindowPadding.x, which lines the text up with the content of the docked panels above (the messages table). + const float width = ImGui::GetContentRegionAvail().x; + const float pad = ImGui::GetStyle().WindowPadding.x; + ImGui::SetCursorPosX(pad); + ImGui::AlignTextToFramePadding(); + // a temporary message hides the normal widgets, permanent widgets stay on the right + auto &bar = status_bar_; + if (!bar.message.empty() && (bar.message_until == 0 || ImGui::GetTime() < bar.message_until)) { + ImGui::TextUnformatted(bar.message.c_str()); + } else { + bar.message.clear(); + ImGui::TextUnformatted("For Help, Press F1"); + } + if (bar.progress_visible) { + ImGui::SameLine(width - pad - 300.0f); + ImGui::ProgressBar(bar.progress_value, ImVec2(300.0f, 16.0f), bar.progress_text.c_str()); + } + ImGui::EndChild(); + ImGui::PopStyleColor(); +} + +void MainWindow::drawWaitDialog() { + const char *id = "###WaitDialog"; + if (wait_dlg_.open && !ImGui::IsPopupOpen(id) && ImGui::GetTime() >= wait_dlg_.show_at) ImGui::OpenPopup(id); + if (!ImGui::IsPopupOpen(id)) return; // keep submitting until CloseCurrentPopup ran, a stale modal blocks all input + ImGui::SetNextWindowSize(ImVec2(400.0f, 0.0f), ImGuiCond_Always); + setNextDialogWindow(ImVec2(0.0f, 0.0f)); + if (ImGui::BeginPopupModal(id, nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::TextUnformatted(wait_dlg_.text.c_str()); + // no text until the progress is set + ImGui::ProgressBar(wait_dlg_.value / 100.0f, ImVec2(-1.0f, 0.0f), wait_dlg_.value == 0 ? "" : (const char *)nullptr); + bool abort = false, rejected = false; + dialogButtons("Abort", &abort, &rejected, true, nullptr); + if (abort || rejected) { + wait_dlg_.open = false; + close(); + } + if (!wait_dlg_.open) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } +} + +void MainWindow::drawDockspace() { + const ImGuiViewport *viewport = ImGui::GetMainViewport(); + ImGui::SetNextWindowPos(viewport->WorkPos); + ImGui::SetNextWindowSize(viewport->WorkSize); + ImGui::SetNextWindowViewport(viewport->ID); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + const ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus | + ImGuiWindowFlags_NoDocking | ImGuiWindowFlags_NoBackground | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse; + ImGui::Begin("##host", nullptr, flags); + ImGui::PopStyleVar(3); + + // the status bar sits below the dockspace: reserve its height plus the item spacing between the two, + // otherwise the host window is a few pixels taller than the viewport and scrolls + const float status_height = full_screen_ ? 0.0f : ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.y; + const ImVec2 dock_size(ImGui::GetContentRegionAvail().x, ImGui::GetContentRegionAvail().y - status_height); + const ImGuiID dock_id = ImGui::GetID("cabana_dockspace"); + if (reset_layout_ || ImGui::DockBuilderGetNode(dock_id) == nullptr) { + // messages left, video (with charts) right, center widget in the middle + ImGui::DockBuilderRemoveNode(dock_id); + ImGui::DockBuilderAddNode(dock_id, ImGuiDockNodeFlags_DockSpace); + ImGui::DockBuilderSetNodeSize(dock_id, dock_size); + ImGuiID center = dock_id, left = 0, right = 0; + ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, 0.28f, &left, ¢er); + ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.4f, &right, ¢er); + ImGui::DockBuilderDockWindow(MESSAGES_PANEL_ID, left); + ImGui::DockBuilderDockWindow(VIDEO_PANEL, right); + ImGui::DockBuilderDockWindow(CENTER_PANEL, center); + ImGui::DockBuilderGetNode(center)->LocalFlags |= ImGuiDockNodeFlags_NoTabBar; + ImGui::DockBuilderFinish(dock_id); + reset_layout_ = false; + } + // a panel never shrinks past the width where the signal view's tool bar squishes + const float min_panel_width = SignalView::minimumWidth() + (ImGui::GetStyle().WindowPadding.x + ImGui::GetStyle().WindowBorderSize) * 2; + ImGui::PushStyleVar(ImGuiStyleVar_WindowMinSize, ImVec2(min_panel_width, ImGui::GetStyle().WindowMinSize.y)); + ImGui::DockSpace(dock_id, dock_size); + ImGui::PopStyleVar(); + if (!full_screen_) drawStatusBar(); + ImGui::End(); +} + +namespace { +// closing a panel that floated out into its own os window brings it back into the default layout, only +// the close button of a docked panel hides it +bool floatingOut() { return ImGui::GetWindowViewport() != ImGui::GetMainViewport(); } + +// the side panels float out like the dialogs, and their dock nodes have no window menu button: its +// only entry hides the tab bar, and with it the title and the close button +void setNextPanelClass() { + ImGuiWindowClass window_class; + window_class.ViewportFlagsOverrideSet = ImGuiViewportFlags_NoAutoMerge; + window_class.DockNodeFlagsOverrideSet = ImGuiDockNodeFlags_NoWindowMenuButton; + ImGui::SetNextWindowClass(&window_class); +} +} // namespace + +void MainWindow::drawMessagesPanel() { + const std::string name = messages_widget_->title() + MESSAGES_PANEL_ID; + setNextPanelClass(); + if (ImGui::Begin(name.c_str(), &messages_visible_)) { + help_overlay_.add(messages_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); + messages_widget_->draw(); + } + const bool floating = floatingOut(); + ImGui::End(); + if (!messages_visible_ && floating) messages_visible_ = reset_layout_ = true; +} + +void MainWindow::drawVideoPanel() { + const std::string name = video_dock_title_ + VIDEO_PANEL; + setNextPanelClass(); + const bool video_open = ImGui::Begin(name.c_str(), &video_visible_); + const bool floating = floatingOut(); + if (!video_open) { + video_widget_->setVisible(false); // the dock is collapsed or tabbed behind another one, like hideEvent + } else { + const ImVec2 avail = ImGui::GetContentRegionAvail(); + const bool live = can->liveStreaming(); + // the bordered child pads its content, so the heights the widget asks for grow by the padding + const float video_padding = ImGui::GetStyle().WindowPadding.y * 2.0f; + const float default_h = video_widget_->defaultHeight(avail.x) + video_padding; + const float video_hint = video_splitter_ratio_ >= 0.0f ? avail.y * video_splitter_ratio_ : default_h; + float video_h = charts_floating_ ? avail.y : std::clamp(video_hint, 0.0f, avail.y - 1.0f); + if (live) video_h = default_h; // display video at minimum size. + // dragging below half of the minimum size collapses the video, it never shrinks below it otherwise + if (!charts_floating_ && !live) { + const float min_h = std::min(video_widget_->sizeHintHeight() + video_padding, avail.y - 1.0f); + video_h = video_h < min_h / 2 ? 0.0f : std::max(video_h, min_h); + } + if (video_h > 0.0f) { + ImGui::BeginChild("video", ImVec2(0, video_h), ImGuiChildFlags_Borders); + help_overlay_.add(video_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); + video_widget_->draw(); + ImGui::EndChild(); + } else { + video_widget_->setVisible(false); // the splitter collapsed the video: stop the vipc thread + } + if (!charts_floating_) { + // the gap between the video and the charts is the same as the padding at the sides + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); + ImGui::InvisibleButton("##splitter", ImVec2(-1.0f, ImGui::GetStyle().WindowPadding.x)); + if (ImGui::IsItemActive() && !live) { + // the size of the video is the position of the handle inside the splitter + const float top = ImGui::GetWindowPos().y + ImGui::GetCursorStartPos().y; + video_splitter_ratio_ = std::clamp((ImGui::GetMousePos().y - top) / avail.y, 0.0f, 1.0f); + } + if (ImGui::IsItemHovered() && !live) ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); + // the chart list scrolls in its own child, the container itself never scrolls + ImGui::BeginChild("charts", ImVec2(0, 0), ImGuiChildFlags_Borders, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + ImGui::PopStyleVar(); + help_overlay_.add(charts_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); + charts_widget_->draw(); + ImGui::EndChild(); + } + } + ImGui::End(); + if (!video_visible_ && floating) video_visible_ = reset_layout_ = true; +} + +void MainWindow::draw() { +#ifdef __APPLE__ + full_screen_ = isNativeFullScreen(window_); +#endif + auto pending = std::move(next_frame_); + next_frame_.clear(); + for (auto &fn : pending) fn(); + + if (ImGui::GetTopMostPopupModal() == nullptr) { + handleShortcuts(); + } else { + takeKeyEvents(); // modal dialogs swallow the shortcuts + } + if (!full_screen_) drawMenuBar(); + drawDockspace(); + + // the central widget has no scrollbars of its own (the views inside scroll) + if (ImGui::Begin(CENTER_PANEL, nullptr, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { + center_widget_.draw(); + if (auto *detail = center_widget_.getDetailWidget(); detail && help_overlay_.visible()) { + for (const auto &[text, rect] : detail->helpRects()) help_overlay_.add(text, rect); + } + } + ImGui::End(); + if (messages_widget_ && messages_visible_) drawMessagesPanel(); + if (video_widget_ && !video_visible_) video_widget_->setVisible(false); + if (video_widget_ && video_visible_) drawVideoPanel(); + if (charts_widget_ && charts_floating_) { + bool open = true; + ImGui::SetNextWindowSize(ImGui::GetMainViewport()->WorkSize, ImGuiCond_Appearing); + setNextWindowFloatsOut(); + if (ImGui::Begin(CHARTS_WINDOW, &open, ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) charts_widget_->draw(); + ImGui::End(); + if (!open) toggleChartsDocking(); + } + for (auto it = tool_dialogs_.begin(); it != tool_dialogs_.end();) { + it = (*it)->draw() ? it + 1 : tool_dialogs_.erase(it); + } + + stream_selector_.draw(); + settings_dialog_.draw(); + drawWaitDialog(); + FileDialog::draw(); + MessageBox::draw(); + help_overlay_.draw(); + + // Escape closes the top-most non-modal popup (a menu or a combo list) on its own; the modal dialogs + // handled Escape themselves above when they were on top + if (ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { + ImGuiWindow *top = topPopupWindow(); + if (top != nullptr && !(top->Flags & ImGuiWindowFlags_Modal)) ImGui::ClosePopupToLevel(GImGui->OpenPopupStack.Size - 1, true); + } +} diff --git a/openpilot/tools/cabana/ui/mainwin.h b/openpilot/tools/cabana/ui/mainwin.h new file mode 100644 index 0000000000..72c24a3464 --- /dev/null +++ b/openpilot/tools/cabana/ui/mainwin.h @@ -0,0 +1,138 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/ui/app.h" +#include "tools/cabana/ui/dialogs/settingsdialog.h" +#include "tools/cabana/ui/dialogs/streamselector.h" +#include "tools/cabana/ui/helpoverlay.h" +#include "tools/cabana/ui/tools/tooldialog.h" +#include "tools/cabana/ui/chart/chartswidget.h" +#include "tools/cabana/ui/widgets/detailwidget.h" +#include "tools/cabana/ui/widgets/messageswidget.h" +#include "tools/cabana/ui/widgets/videowidget.h" + +struct GLFWwindow; + +class MainWindow { +public: + MainWindow(GLFWwindow *window, std::unique_ptr stream, StreamLoader stream_loader, const std::string &dbc_file); + ~MainWindow(); + void draw(); + void toggleChartsDocking(); + void close(); // remind unsaved changes, save state, exit + bool exited() const { return exited_; } + void showStatusMessage(const std::string &msg, int timeout_ms = 0); + void loadFile(const std::string &fn, SourceSet s = SOURCE_ALL, std::function then = {}); + + void selectAndOpenStream(); + void openStream(std::unique_ptr stream, const std::string &dbc_file = {}); + void closeStream(); + void exportToCSV(); + + void newFile(SourceSet s = SOURCE_ALL); + void openFile(SourceSet s = SOURCE_ALL); + void loadDBCFromOpendbc(const std::string &name); + void save(std::function then = {}); + void saveAs(std::function then = {}); + void saveToClipboard(); + +private: + bool hasStream() const { return dynamic_cast(can) == nullptr; } + void releaseStream(); + void startStream(std::unique_ptr stream, const std::string &dbc_file); + void loadStartupStream(const std::string &dbc_file); + void remindSaveChanges(std::function then); + void closeFile(SourceSet s, std::function then); + void closeFile(DBCFile *dbc_file); + void saveFiles(bool as, std::function then); + void saveFile(DBCFile *dbc_file, std::function then = {}); + void saveFileAs(DBCFile *dbc_file, std::function then = {}); + void saveFileToClipboard(DBCFile *dbc_file); + void copyToClipboard(const std::string &text); + void loadFingerprints(); + void loadFromClipboard(SourceSet s = SOURCE_ALL, bool close_all = true); + void updateRecentFiles(const std::string &fn); + void dbcFileChanged(); + void updateDownloadProgress(uint64_t cur, uint64_t total, bool success); + void openSettings(); + void findSimilarBits(); + void findSignal(); + void toggleHelp(); + void toggleFullScreen(); + void updateWindowTitle(); + void eventsMerged(); + void saveSessionState(); + void restoreSessionState(); + void finishClose(); + void nextFrame(std::function fn) { next_frame_.push_back(std::move(fn)); } + void createDockWidgets(); + + void handleShortcuts(); + void drawMenuBar(); + void drawFileMenu(); + void drawManageDBCsMenu(); + void drawRecentFilesMenu(); + void drawDockspace(); + void drawMessagesPanel(); + void drawVideoPanel(); + void drawStatusBar(); + void drawWaitDialog(); + + GLFWwindow *window_; + std::unique_ptr startup_stream_; // opened on the first frame + StreamLoader startup_loader_; // run on a worker after the first frame + std::unique_ptr stream_; // `can` points here, or at dummy_ when no stream is open + DummyStream dummy_; + std::unique_ptr messages_widget_; + CenterWidget center_widget_; + std::unique_ptr video_widget_; + std::unique_ptr charts_widget_; + StreamSelector stream_selector_; + SettingsDialog settings_dialog_; + HelpOverlay help_overlay_; + std::unordered_map fingerprint_to_dbc_; + std::vector opendbc_names_; + enum { MAX_RECENT_FILES = 15 }; + std::string car_fingerprint_; + std::string video_dock_title_; + bool messages_visible_ = true; + bool video_visible_ = true; + bool reset_layout_ = false; + bool full_screen_ = false; +#ifndef __APPLE__ + int windowed_rect_[4] = {0, 0, 1600, 900}; +#endif + bool charts_floating_ = false; + float video_splitter_ratio_ = -1.0f; // < 0: the video widget is at its size hint + std::vector> tool_dialogs_; + bool closing_ = false; + bool exited_ = false; + bool window_modified_ = false; + struct StatusBar { + std::string message; + double message_until = 0; + bool progress_visible = false; + float progress_value = 0; + std::string progress_text; + } status_bar_; + // "Loading segment data..." dialog + struct WaitDialog { + bool open = false; + double show_at = 0; + std::string text; + int value = 0; + Connection connection; + } wait_dlg_; + std::vector> next_frame_; + Connections connections_; + Connections stream_connections_; + Connections widget_connections_; +}; diff --git a/openpilot/tools/cabana/ui/qtstate.cc b/openpilot/tools/cabana/ui/qtstate.cc new file mode 100644 index 0000000000..f4283420cd --- /dev/null +++ b/openpilot/tools/cabana/ui/qtstate.cc @@ -0,0 +1,171 @@ +#include "tools/cabana/ui/qtstate.h" + +#include +#include + +namespace qtstate { + +namespace { + +// big-endian QDataStream reader; any read past the end latches the failed state +class Cursor { +public: + explicit Cursor(const std::vector &data) : data_(data) {} + + bool ok() const { return ok_; } + size_t remaining() const { return ok_ ? data_.size() - pos_ : 0; } + + uint8_t u8() { return read(1) ? data_[pos_ - 1] : 0; } + uint16_t u16() { + if (!read(2)) return 0; + return (uint16_t(data_[pos_ - 2]) << 8) | data_[pos_ - 1]; + } + uint32_t u32() { + if (!read(4)) return 0; + return (uint32_t(data_[pos_ - 4]) << 24) | (uint32_t(data_[pos_ - 3]) << 16) | + (uint32_t(data_[pos_ - 2]) << 8) | data_[pos_ - 1]; + } + int32_t i32() { return (int32_t)u32(); } + void skip(size_t n) { read(n); } + +private: + bool read(size_t n) { + if (!ok_ || data_.size() - pos_ < n) { + ok_ = false; + return false; + } + pos_ += n; + return true; + } + + const std::vector &data_; + size_t pos_ = 0; + bool ok_ = true; +}; + +} // namespace + +std::optional parseQtGeometry(const std::vector &data) { + Cursor c(data); + if (c.u32() != 0x01D9D0CB) return std::nullopt; + const uint16_t major = c.u16(); + c.u16(); // minor + if (!c.ok() || major > 3) return std::nullopt; + + c.skip(16); // frameGeometry + int x1 = c.i32(), y1 = c.i32(), x2 = c.i32(), y2 = c.i32(); // normalGeometry + c.i32(); // screenNumber + const bool maximized = c.u8() != 0; + c.u8(); // fullScreen + if (major >= 2) c.i32(); // screenWidth + if (major >= 3) { + // the client-area rect actually restored + x1 = c.i32(); y1 = c.i32(); x2 = c.i32(); y2 = c.i32(); + } + if (!c.ok()) return std::nullopt; + return QtGeometry{x1, y1, x2 - x1 + 1, y2 - y1 + 1, maximized}; +} + +std::optional parseQtSplitter(const std::vector &data) { + Cursor c(data); + if (c.i32() != 0xff) return std::nullopt; + const int32_t version = c.i32(); + if (!c.ok() || version > 1) return std::nullopt; + + const uint32_t count = c.u32(); + if (!c.ok() || count != 2) return std::nullopt; + const int32_t first = c.i32(); + const int32_t second = c.i32(); + if (!c.ok() || first + second <= 0) return std::nullopt; + return QtSplitter{first / float(first + second)}; +} + +std::optional parseQtHeaderState(const std::vector &data) { + constexpr int N = kMessageColumnCount; + Cursor c(data); + if (c.i32() != 0xff) return std::nullopt; + if (c.i32() != 0) return std::nullopt; // version + + c.i32(); // orientation + const int32_t sort_order = c.i32(); + const int32_t sort_section = c.i32(); + const bool sort_shown = c.u8() != 0; + if (!c.ok()) return std::nullopt; + + QtHeaderState state{}; + state.sort_section = sort_section; + state.sort_order = sort_order; + state.sort_shown = sort_shown; + for (int i = 0; i < N; ++i) state.visual[i] = i; + + // visualIndices: logical -> visual, empty means identity + const uint32_t visual_count = c.u32(); + if (!c.ok() || visual_count > c.remaining() / 4) return std::nullopt; + if (visual_count > 0) { + std::vector visual(visual_count); + for (uint32_t i = 0; i < visual_count; ++i) visual[i] = c.i32(); + if (!c.ok()) return std::nullopt; + bool valid = visual_count == (uint32_t)N; + if (valid) { + bool seen[N] = {}; + for (int v : visual) { + if (v < 0 || v >= N || seen[v]) { valid = false; break; } + seen[v] = true; + } + } + if (valid) { + for (int i = 0; i < N; ++i) state.visual[i] = visual[i]; + } + } + + // logicalIndices: visual -> logical, unused but must be consumed + const uint32_t logical_count = c.u32(); + if (!c.ok() || logical_count > c.remaining() / 4) return std::nullopt; + c.skip(logical_count * 4); + + // sectionHidden: QBitArray indexed by visual position + const uint32_t hidden_bits = c.u32(); + if (!c.ok() || hidden_bits / 8 > c.remaining()) return std::nullopt; + std::vector hidden_bytes((hidden_bits + 7) / 8); + for (auto &b : hidden_bytes) b = c.u8(); + if (!c.ok()) return std::nullopt; + + // hiddenSectionSize: logical index -> size before hiding + const uint32_t hidden_size_count = c.u32(); + if (!c.ok() || hidden_size_count > c.remaining() / 8) return std::nullopt; + std::map hidden_sizes; + for (uint32_t i = 0; i < hidden_size_count; ++i) { + const int key = c.i32(); + hidden_sizes[key] = c.i32(); + } + if (!c.ok()) return std::nullopt; + + c.i32(); // length + c.i32(); // sectionCount + c.skip(5); // movable, clickable, highlight, stretchLastSection, cascading + c.skip(24); // stretchSections, contentsSections, defaultSectionSize, minimumSectionSize, defaultAlignment, globalResizeMode + if (!c.ok()) return std::nullopt; + + // SectionItems in visual order; a Qt4 item with count != 1 stands for count sections + const uint32_t item_count = c.u32(); + if (!c.ok() || item_count > c.remaining() / 12) return std::nullopt; + std::vector sizes; + for (uint32_t i = 0; i < item_count; ++i) { + const int size = c.i32(); + const int count = c.i32(); + c.i32(); // resizeMode + if (!c.ok() || count <= 0 || (int)sizes.size() + count > N) return std::nullopt; + for (int j = 0; j < count; ++j) sizes.push_back(size / count); + } + if (!c.ok() || (int)sizes.size() != N) return std::nullopt; + + for (int i = 0; i < N; ++i) { + const int v = state.visual[i]; + state.hidden[i] = (uint32_t)v < hidden_bits && (hidden_bytes[v / 8] & (1 << (v % 8))) != 0; + auto it = hidden_sizes.find(i); + state.width[i] = (state.hidden[i] && it != hidden_sizes.end()) ? it->second : sizes[v]; + } + return state; +} + +} // namespace qtstate diff --git a/openpilot/tools/cabana/ui/qtstate.h b/openpilot/tools/cabana/ui/qtstate.h new file mode 100644 index 0000000000..76dbd7400f --- /dev/null +++ b/openpilot/tools/cabana/ui/qtstate.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include + +// Parsers for the Qt frontend's persisted QByteArray blobs (QWidget::saveGeometry, +// QSplitter::saveState, QHeaderView::saveState). Used once to migrate a Qt cabana.json +// to the imgui frontend's ini state. No Qt and no imgui. +// TODO: Delete this migration (qtstate.{h,cc}, migrateQtState in inistate.cc and the Qt +// byte-array fields in Settings) after users have had time to migrate to ui_state. +namespace qtstate { + +constexpr int kMessageColumnCount = 7; + +struct QtGeometry { + int x, y, w, h; + bool maximized; +}; + +struct QtSplitter { + float ratio; +}; + +struct QtHeaderState { + int sort_section; + int sort_order; // 0 = Qt::AscendingOrder, 1 = Qt::DescendingOrder + bool sort_shown; + int visual[kMessageColumnCount]; + int width[kMessageColumnCount]; + bool hidden[kMessageColumnCount]; +}; + +std::optional parseQtGeometry(const std::vector &data); +std::optional parseQtSplitter(const std::vector &data); +std::optional parseQtHeaderState(const std::vector &data); + +} // namespace qtstate diff --git a/openpilot/tools/cabana/ui/style.cc b/openpilot/tools/cabana/ui/style.cc new file mode 100644 index 0000000000..b1b5c6cee6 --- /dev/null +++ b/openpilot/tools/cabana/ui/style.cc @@ -0,0 +1,280 @@ +#include "tools/cabana/ui/app.h" + +#include +#include +#include + +#include "implot.h" +#include "tools/cabana/core/settings.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/util.h" + +namespace fs = std::filesystem; + +namespace { +bool g_dark = false; +ImFont *g_ui_font = nullptr; +ImFont *g_bold_font = nullptr; +ImFont *g_mono_font = nullptr; +ImFont *g_large_font = nullptr; + +void addIconFont(float size, ImFont *base) { + ImFontConfig cfg; + cfg.MergeMode = base != nullptr; + cfg.GlyphMinAdvanceX = size; + if (base != nullptr) { + ImFontBaked *baked = base->GetFontBaked(size); + const float center = baked != nullptr ? (baked->Ascent + baked->Descent) * 0.5f : size * 0.5f; + cfg.GlyphOffset.y = std::round(size * 0.5f - center); + } + static const ImWchar ranges[] = {0xF000, 0xF8FF, 0}; + ImGui::GetIO().Fonts->AddFontFromFileTTF(BOOTSTRAP_ICONS_TTF, size, &cfg, ranges); +} + +ImFont *addFont(const fs::path &path, float size) { + ImFontConfig cfg; + cfg.OversampleH = 2; + cfg.OversampleV = 2; + ImFont *font = ImGui::GetIO().Fonts->AddFontFromFileTTF(path.c_str(), size, &cfg); + if (font != nullptr) addIconFont(size, font); + return font; +} +} // namespace + +void loadFonts() { + ImGuiIO &io = ImGui::GetIO(); + const fs::path fonts = fs::path(CABANA_FONTS_DIR); + g_ui_font = addFont(fonts / "Inter-Regular.ttf", 16.0f); + g_bold_font = addFont(fonts / "Inter-SemiBold.ttf", 16.0f); + g_mono_font = addFont(fonts / "JetBrainsMono-Medium.ttf", 15.0f); + g_large_font = addFont(fonts / "Inter-Bold.ttf", 50.0f); + if (g_ui_font != nullptr) io.FontDefault = g_ui_font; + if (g_bold_font == nullptr) g_bold_font = g_ui_font; + if (g_mono_font == nullptr) g_mono_font = g_ui_font; + if (g_large_font == nullptr) g_large_font = g_bold_font; +} + +void applyTheme(int theme) { + const bool dark = theme == DARK_THEME; + g_dark = dark; + if (dark) { + ImGui::StyleColorsDark(); + ImPlot::StyleColorsDark(); + } else { + ImGui::StyleColorsLight(); + ImPlot::StyleColorsLight(); + } + + ImGuiStyle &style = ImGui::GetStyle(); + style.WindowRounding = 0.0f; + style.ChildRounding = 0.0f; + style.PopupRounding = 0.0f; + style.FrameRounding = 2.0f; + style.GrabRounding = 2.0f; + style.ScrollbarRounding = 2.0f; + style.TabRounding = 2.0f; + style.WindowBorderSize = 1.0f; + style.FrameBorderSize = 1.0f; + style.TabBorderSize = 1.0f; + style.WindowPadding = ImVec2(8.0f, 7.0f); + style.FramePadding = ImVec2(6.0f, 3.0f); + style.ItemSpacing = ImVec2(8.0f, 5.0f); + style.ScrollbarSize = 14.0f; + style.GrabMinSize = 13.0f; + + auto c = [](const CabanaColor &col, float a = 1.0f) { return colorRgb(col.r, col.g, col.b, a); }; + ImVec4 *colors = style.Colors; + if (dark) { + // the low contrast Darcula grays are opened up: text and outlines sit further from the window and + // base grays + const ImVec4 highlight = c(DarkTheme::highlight); + const ImVec4 outline = colorRgb(0x5a, 0x5d, 0x60); + colors[ImGuiCol_WindowBg] = c(DarkTheme::window); + colors[ImGuiCol_ChildBg] = c(DarkTheme::base); + colors[ImGuiCol_PopupBg] = c(DarkTheme::base); + colors[ImGuiCol_MenuBarBg] = c(DarkTheme::window); + colors[ImGuiCol_DockingEmptyBg] = c(DarkTheme::window); + colors[ImGuiCol_Text] = colorRgb(0xdc, 0xdc, 0xdc); + colors[ImGuiCol_TextDisabled] = colorRgb(0x8c, 0x8c, 0x8c); + colors[ImGuiCol_Border] = outline; + colors[ImGuiCol_BorderShadow] = colorRgb(0, 0, 0, 0.0f); + colors[ImGuiCol_FrameBg] = colorRgb(0x2e, 0x30, 0x32); // darker than the base so fields read as sunken + colors[ImGuiCol_FrameBgHovered] = colorRgb(0x3a, 0x3d, 0x40); + colors[ImGuiCol_FrameBgActive] = colorRgb(0x45, 0x48, 0x4b); + colors[ImGuiCol_Button] = c(DarkTheme::button); + colors[ImGuiCol_ButtonHovered] = colorRgb(0x52, 0x56, 0x59); + colors[ImGuiCol_ButtonActive] = colorRgb(0x2b, 0x2d, 0x30); + colors[ImGuiCol_Header] = highlight; + colors[ImGuiCol_HeaderHovered] = c(DarkTheme::highlight, 0.8f); + colors[ImGuiCol_HeaderActive] = highlight; + colors[ImGuiCol_CheckMark] = c(DarkTheme::bright_text); + colors[ImGuiCol_SliderGrab] = colorRgb(0x8f, 0x92, 0x95); + colors[ImGuiCol_SliderGrabActive] = colorRgb(0xa8, 0xab, 0xae); + colors[ImGuiCol_ScrollbarBg] = c(DarkTheme::window); + colors[ImGuiCol_ScrollbarGrab] = colorRgb(0x70, 0x73, 0x76); + colors[ImGuiCol_ScrollbarGrabHovered] = colorRgb(0x85, 0x88, 0x8b); + colors[ImGuiCol_ScrollbarGrabActive] = c(DarkTheme::light); + colors[ImGuiCol_Separator] = outline; + colors[ImGuiCol_SeparatorHovered] = c(DarkTheme::highlight, 0.6f); + colors[ImGuiCol_SeparatorActive] = highlight; + colors[ImGuiCol_ResizeGrip] = colorRgb(0, 0, 0, 0.0f); + colors[ImGuiCol_ResizeGripHovered] = c(DarkTheme::highlight, 0.6f); + colors[ImGuiCol_ResizeGripActive] = highlight; + colors[ImGuiCol_Tab] = c(DarkTheme::window); + colors[ImGuiCol_TabHovered] = colorRgb(0x4b, 0x4e, 0x52); + colors[ImGuiCol_TabSelected] = c(DarkTheme::base); + colors[ImGuiCol_TabSelectedOverline] = highlight; + colors[ImGuiCol_TabDimmed] = c(DarkTheme::window); + colors[ImGuiCol_TabDimmedSelected] = c(DarkTheme::base); + colors[ImGuiCol_TabDimmedSelectedOverline] = colorRgb(0, 0, 0, 0.0f); + colors[ImGuiCol_TitleBg] = c(DarkTheme::window); + colors[ImGuiCol_TitleBgActive] = c(DarkTheme::window); + colors[ImGuiCol_TitleBgCollapsed] = c(DarkTheme::window); + colors[ImGuiCol_TableHeaderBg] = c(DarkTheme::window); + colors[ImGuiCol_TableBorderStrong] = outline; + colors[ImGuiCol_TableBorderLight] = colorRgb(0x23, 0x26, 0x28); // darker than the cells, like the qt grid + colors[ImGuiCol_TableRowBg] = colorRgb(0, 0, 0, 0.0f); + colors[ImGuiCol_TableRowBgAlt] = colorRgb(0xff, 0xff, 0xff, 0.06f); + colors[ImGuiCol_TextSelectedBg] = c(DarkTheme::highlight, 0.6f); + colors[ImGuiCol_DockingPreview] = c(DarkTheme::highlight, 0.5f); + colors[ImGuiCol_NavCursor] = highlight; + colors[ImGuiCol_PlotLines] = c(DarkTheme::text); + colors[ImGuiCol_PlotHistogram] = highlight; + colors[ImGuiCol_DragDropTarget] = highlight; + } else { + const ImVec4 window = colorRgb(0xef, 0xef, 0xef); + const ImVec4 base = colorRgb(0xff, 0xff, 0xff); + const ImVec4 outline = colorRgb(0xb9, 0xb9, 0xb9); + const ImVec4 highlight = colorRgb(0x30, 0x8c, 0xc6); + colors[ImGuiCol_WindowBg] = window; + colors[ImGuiCol_ChildBg] = base; + colors[ImGuiCol_PopupBg] = colorRgb(0xfb, 0xfb, 0xfb); + colors[ImGuiCol_MenuBarBg] = window; + colors[ImGuiCol_DockingEmptyBg] = window; + colors[ImGuiCol_Text] = colorRgb(0x00, 0x00, 0x00); + colors[ImGuiCol_TextDisabled] = colorRgb(0xbe, 0xbe, 0xbe); + colors[ImGuiCol_Border] = outline; + colors[ImGuiCol_BorderShadow] = colorRgb(0, 0, 0, 0.0f); + colors[ImGuiCol_FrameBg] = base; + colors[ImGuiCol_FrameBgHovered] = colorRgb(0xf7, 0xf7, 0xf7); + colors[ImGuiCol_FrameBgActive] = colorRgb(0xef, 0xef, 0xef); + colors[ImGuiCol_Button] = colorRgb(0xf3, 0xf3, 0xf3); + colors[ImGuiCol_ButtonHovered] = colorRgb(0xf9, 0xf9, 0xf9); + colors[ImGuiCol_ButtonActive] = colorRgb(0xdc, 0xdc, 0xdc); + colors[ImGuiCol_Header] = highlight; + colors[ImGuiCol_HeaderHovered] = colorRgb(0x30, 0x8c, 0xc6, 0.8f); + colors[ImGuiCol_HeaderActive] = highlight; + colors[ImGuiCol_CheckMark] = colorRgb(0x3b, 0x3b, 0x3b); + colors[ImGuiCol_SliderGrab] = colorRgb(0xd8, 0xd8, 0xd8); + colors[ImGuiCol_SliderGrabActive] = colorRgb(0xc4, 0xc4, 0xc4); + colors[ImGuiCol_ScrollbarBg] = window; + colors[ImGuiCol_ScrollbarGrab] = colorRgb(0xc8, 0xc8, 0xc8); + colors[ImGuiCol_ScrollbarGrabHovered] = colorRgb(0xb4, 0xb4, 0xb4); + colors[ImGuiCol_ScrollbarGrabActive] = colorRgb(0xa0, 0xa0, 0xa0); + colors[ImGuiCol_Separator] = outline; + colors[ImGuiCol_SeparatorHovered] = colorRgb(0x30, 0x8c, 0xc6, 0.6f); + colors[ImGuiCol_SeparatorActive] = highlight; + colors[ImGuiCol_ResizeGrip] = colorRgb(0, 0, 0, 0.0f); + colors[ImGuiCol_ResizeGripHovered] = colorRgb(0x30, 0x8c, 0xc6, 0.6f); + colors[ImGuiCol_ResizeGripActive] = highlight; + colors[ImGuiCol_Tab] = colorRgb(0xe2, 0xe2, 0xe2); + colors[ImGuiCol_TabHovered] = colorRgb(0xf5, 0xf5, 0xf5); + colors[ImGuiCol_TabSelected] = base; + colors[ImGuiCol_TabSelectedOverline] = highlight; + colors[ImGuiCol_TabDimmed] = colorRgb(0xe2, 0xe2, 0xe2); + colors[ImGuiCol_TabDimmedSelected] = base; + colors[ImGuiCol_TabDimmedSelectedOverline] = colorRgb(0, 0, 0, 0.0f); + colors[ImGuiCol_TitleBg] = window; + colors[ImGuiCol_TitleBgActive] = window; + colors[ImGuiCol_TitleBgCollapsed] = window; + colors[ImGuiCol_TableHeaderBg] = colorRgb(0xf2, 0xf2, 0xf2); + colors[ImGuiCol_TableBorderStrong] = outline; + colors[ImGuiCol_TableBorderLight] = colorRgb(0xd8, 0xd8, 0xd8); + colors[ImGuiCol_TableRowBg] = colorRgb(0, 0, 0, 0.0f); + colors[ImGuiCol_TableRowBgAlt] = colorRgb(0, 0, 0, 0.03f); + colors[ImGuiCol_TextSelectedBg] = colorRgb(0x30, 0x8c, 0xc6, 0.35f); + colors[ImGuiCol_DockingPreview] = colorRgb(0x30, 0x8c, 0xc6, 0.5f); + colors[ImGuiCol_NavCursor] = highlight; + colors[ImGuiCol_PlotLines] = colorRgb(0x3b, 0x3b, 0x3b); + colors[ImGuiCol_PlotHistogram] = highlight; + colors[ImGuiCol_DragDropTarget] = highlight; + } + // imgui fades the modal dim in over several frames, which reads as the dialog lagging + colors[ImGuiCol_ModalWindowDimBg] = ImVec4(0, 0, 0, 0); + colors[ImGuiCol_NavWindowingDimBg] = ImVec4(0, 0, 0, 0); +} + +bool isDarkTheme() { return g_dark; } + +ImU32 highlightedTextColor() { + return g_dark ? IM_COL32(DarkTheme::window_text.r, DarkTheme::window_text.g, DarkTheme::window_text.b, 255) + : IM_COL32(255, 255, 255, 255); +} + +ImU32 paletteBrightText() { + return g_dark ? IM_COL32(DarkTheme::bright_text.r, DarkTheme::bright_text.g, DarkTheme::bright_text.b, 255) + : IM_COL32(255, 255, 255, 255); +} + +void drawSliderHandle(ImDrawList *p, const ImRect &r) { + const bool dark = isDarkTheme(); + const ImU32 top = dark ? IM_COL32(0x3e, 0x41, 0x43, 255) : IM_COL32(255, 255, 255, 255); + const ImU32 bottom = dark ? IM_COL32(0x39, 0x3c, 0x3e, 255) : IM_COL32(0xf0, 0xf0, 0xf0, 255); + // the top/left edge is one step lighter than the bottom/right edge + const ImU32 outline_top = dark ? IM_COL32(0xa3, 0xa3, 0xa3, 255) : IM_COL32(0xab, 0xab, 0xab, 255); + const ImU32 outline_bottom = dark ? IM_COL32(0x9c, 0x9c, 0x9c, 255) : IM_COL32(0xa4, 0xa4, 0xa4, 255); + p->AddRectFilled(r.Min, r.Max, top, 2.0f); + p->AddRectFilled(ImVec2(r.Min.x, r.GetCenter().y), r.Max, bottom, 2.0f, ImDrawFlags_RoundCornersBottom); + p->AddRect(r.Min, r.Max, outline_bottom, 2.0f, 0, 1.0f); + // the straight edges are drawn as crisp 1 px rects: an antialiased outline washes out to a much lighter grey + const float c = 2.0f; // corner radius + p->AddRectFilled(ImVec2(r.Min.x + c, r.Min.y), ImVec2(r.Max.x - c, r.Min.y + 1.0f), outline_top); + p->AddRectFilled(ImVec2(r.Min.x, r.Min.y + c), ImVec2(r.Min.x + 1.0f, r.Max.y - c), outline_top); + p->AddRectFilled(ImVec2(r.Min.x + c, r.Max.y - 1.0f), ImVec2(r.Max.x - c, r.Max.y), outline_bottom); + p->AddRectFilled(ImVec2(r.Max.x - 1.0f, r.Min.y + c), ImVec2(r.Max.x, r.Max.y - c), outline_bottom); +} + +bool fusionSliderInt(const char *label, int *v, int min, int max, float width) { + // a grey groove over the full width with the part left of the handle filled, and a 13x13 handle on top + const ImU32 groove_col = isDarkTheme() ? IM_COL32(0x2a, 0x2c, 0x2e, 255) : IM_COL32(0xc4, 0xc4, 0xc4, 255); + const ImU32 fill_col = ImGui::GetColorU32(ImGuiCol_Header); + ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_FrameBgActive, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_SliderGrab, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_SliderGrabActive, IM_COL32_BLACK_TRANS); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); // the slider has no frame + ImGui::SetNextItemWidth(width); + bool changed = ImGui::SliderInt(label, v, min, max, "", ImGuiSliderFlags_NoInput); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(5); + + const ImVec2 bb_min = ImGui::GetItemRectMin(), bb_max = ImGui::GetItemRectMax(); + const float cy = (bb_min.y + bb_max.y) * 0.5f; + const float groove_h = SLIDER_THICKNESS * 0.5f; + const float handle_h = std::min(SLIDER_THICKNESS, bb_max.y - bb_min.y); + const float x0 = bb_min.x + SLIDER_LENGTH * 0.5f, x1 = bb_max.x - SLIDER_LENGTH * 0.5f; + const float t = max > min ? (float)(*v - min) / (float)(max - min) : 0.0f; + const float hx = x0 + (x1 - x0) * t; + ImDrawList *dl = ImGui::GetWindowDrawList(); + const float groove_y0 = cy - groove_h * 0.5f, groove_y1 = cy + groove_h * 0.5f; + dl->AddRectFilled(ImVec2(bb_min.x, groove_y0), ImVec2(bb_max.x, groove_y1), groove_col, groove_h * 0.5f); + dl->AddRectFilled(ImVec2(bb_min.x, groove_y0), ImVec2(hx, groove_y1), fill_col, groove_h * 0.5f); + drawSliderHandle(dl, ImRect(ImVec2(hx - SLIDER_LENGTH * 0.5f, cy - handle_h * 0.5f), + ImVec2(hx + SLIDER_LENGTH * 0.5f, cy + handle_h * 0.5f))); + return changed; +} + +ImFont *boldFont() { return g_bold_font; } +ImFont *monoFont() { return g_mono_font; } + +void pushMonoFont(float size) { + if (!g_mono_font) return; + size > 0.0f ? ImGui::PushFont(g_mono_font, size) : ImGui::PushFont(g_mono_font); +} +void popMonoFont() { if (g_mono_font) ImGui::PopFont(); } +void pushBoldFont() { if (g_bold_font) ImGui::PushFont(g_bold_font); } +void popBoldFont() { if (g_bold_font) ImGui::PopFont(); } +void pushLargeFont() { if (g_large_font) ImGui::PushFont(g_large_font); } +void popLargeFont() { if (g_large_font) ImGui::PopFont(); } diff --git a/openpilot/tools/cabana/ui/threadpool.h b/openpilot/tools/cabana/ui/threadpool.h new file mode 100644 index 0000000000..5a45ec80f2 --- /dev/null +++ b/openpilot/tools/cabana/ui/threadpool.h @@ -0,0 +1,81 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Reusing the same threads matters: a std::async thread per update lands each allocation in a different +// glibc malloc arena and the process RSS grows without bound. +class ThreadPool { +public: + static ThreadPool &instance() { + static ThreadPool pool(std::clamp(std::thread::hardware_concurrency(), 2u, 4u)); + return pool; + } + + std::future run(std::function fn) { + auto task = std::make_shared>(std::move(fn)); + std::future future = task->get_future(); + { + std::lock_guard lk(mutex_); + tasks_.push([task]() { (*task)(); }); + } + cv_.notify_one(); + return future; + } + + ~ThreadPool() { + { + std::lock_guard lk(mutex_); + stop_ = true; + } + cv_.notify_all(); + for (auto &t : threads_) t.join(); + } + +private: + explicit ThreadPool(unsigned n) { + for (unsigned i = 0; i < n; ++i) { + threads_.emplace_back([this]() { + for (;;) { + std::function task; + { + std::unique_lock lk(mutex_); + cv_.wait(lk, [this]() { return stop_ || !tasks_.empty(); }); + if (stop_ && tasks_.empty()) return; + task = std::move(tasks_.front()); + tasks_.pop(); + } + task(); + } + }); + } + } + + std::vector threads_; + std::queue> tasks_; + std::mutex mutex_; + std::condition_variable cv_; + bool stop_ = false; +}; + +// fn(begin, end) over [0, n) split into one chunk per pool thread plus one for the caller, which also +// waits for the others. Not for use from a pool thread. +inline void parallelFor(size_t n, const std::function &fn) { + const size_t chunks = std::clamp(std::thread::hardware_concurrency(), 2, 4) + 1; + const size_t chunk = (n + chunks - 1) / chunks; + if (chunk == 0) return; + std::vector> futures; + size_t begin = chunk; + for (; begin < n; begin += chunk) { + futures.push_back(ThreadPool::instance().run([&fn, begin, end = std::min(begin + chunk, n)]() { fn(begin, end); })); + } + fn(0, std::min(chunk, n)); + for (auto &f : futures) f.get(); +} diff --git a/openpilot/tools/cabana/ui/tools/findsignal.cc b/openpilot/tools/cabana/ui/tools/findsignal.cc new file mode 100644 index 0000000000..1642ac2cc5 --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/findsignal.cc @@ -0,0 +1,325 @@ +#include "tools/cabana/ui/tools/findsignal.h" + +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "tools/cabana/ui/threadpool.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" + +namespace { +constexpr int MAX_ROWS = 300; +} // namespace + +void SignalSearch::search(const std::function &cmp) { + const auto prev_sigs = !histories.empty() ? histories.back() : initial_signals; + filtered_signals.clear(); + filtered_signals.reserve(prev_sigs.size()); + + std::mutex lock; + parallelFor(prev_sigs.size(), [&](size_t begin, size_t end) { + for (size_t i = begin; i < end; ++i) { + const auto &s = prev_sigs[i]; + const auto &events = can->events(s.id); + auto first = std::upper_bound(events.cbegin(), events.cend(), s.mono_time, CompareCanEvent()); + auto last = events.cend(); + if (last_time < std::numeric_limits::max()) { + last = std::upper_bound(events.cbegin(), events.cend(), last_time, CompareCanEvent()); + } + + auto it = std::find_if(first, last, [&](const CanEvent *e) { return cmp(get_raw_value(e->dat, e->size, s.sig)); }); + if (it != last) { + auto values = s.values; + char buf[64]; + snprintf(buf, sizeof(buf), "(%.3f, %g)", can->toSeconds((*it)->mono_time), get_raw_value((*it)->dat, (*it)->size, s.sig)); + values.push_back(buf); + std::lock_guard lk(lock); + filtered_signals.push_back({.id = s.id, .mono_time = (*it)->mono_time, .sig = s.sig, .values = values}); + } + } + }); + + histories.push_back(filtered_signals); +} + +void SignalSearch::undo() { + if (!histories.empty()) { + histories.pop_back(); + filtered_signals.clear(); + if (!histories.empty()) filtered_signals = histories.back(); + } +} + +void SignalSearch::reset() { + histories.clear(); + filtered_signals.clear(); + initial_signals.clear(); +} + +FindSignalDlg::FindSignalDlg() { + setTitle("Find Signal"); +} + +FindSignalDlg::~FindSignalDlg() { + if (search_future_.valid()) search_future_.wait(); +} + +bool FindSignalDlg::draw() { + if (search_future_.valid() && search_future_.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { + search_future_.get(); + searched_ = true; + } + searching_ = search_future_.valid(); + if (begin(ImVec2(900, 650))) { + float group_w = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) / 2; + ImGui::BeginChild("Messages", ImVec2(group_w, 0), ImGuiChildFlags_Borders | ImGuiChildFlags_AutoResizeY); + drawMessageGroup(); + ImGui::EndChild(); + ImGui::SameLine(); + ImGui::BeginChild("Signal", ImVec2(group_w, 0), ImGuiChildFlags_Borders | ImGuiChildFlags_AutoResizeY); + drawPropertiesGroup(); + ImGui::EndChild(); + float footer = searched_ ? ImGui::GetTextLineHeightWithSpacing() : 0; + ImGui::BeginChild("Find signal", ImVec2(0, -footer), ImGuiChildFlags_Borders); + drawFindGroup(); + ImGui::EndChild(); + if (searched_) { + ImGui::Text("%zu matches. right click on an item to create signal. double click to open message", + search_.filtered_signals.size()); + } + } + return end(); +} + +void FindSignalDlg::drawMessageGroup() { + ImGui::BeginDisabled(searching_ || !search_.histories.empty()); + ImGui::TextUnformatted("Messages"); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Bus"); + ImGui::SameLine(80); + ImGui::SetNextItemWidth(-1); + inputText("##bus", &bus_, "comma-separated values. Leave blank for all"); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Address"); + ImGui::SameLine(80); + ImGui::SetNextItemWidth(-1); + inputText("##address", &address_, "comma-separated hex values. Leave blank for all"); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Time"); + ImGui::SameLine(80); + ImGui::SetNextItemWidth(70); + validatedText("##first_time", &first_time_, validateDouble); + ImGui::SameLine(); + ImGui::TextUnformatted("-"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(70); + validatedText("##last_time", &last_time_, validateDouble); + ImGui::SameLine(); + ImGui::TextUnformatted("seconds"); + ImGui::EndDisabled(); +} + +void FindSignalDlg::drawPropertiesGroup() { + ImGui::BeginDisabled(searching_ || !search_.histories.empty()); + ImGui::TextUnformatted("Signal"); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Size"); + ImGui::SameLine(80); + ImGui::SetNextItemWidth(70); + if (ImGui::InputInt("##min_size", &min_size_, 1, 10)) min_size_ = std::clamp(min_size_, 1, 64); + ImGui::SameLine(); + ImGui::TextUnformatted("-"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(70); + if (ImGui::InputInt("##max_size", &max_size_, 1, 10)) max_size_ = std::clamp(max_size_, 1, 64); + ImGui::SameLine(); + checkBox("Little endian", &little_endian_); + ImGui::SameLine(); + checkBox("Signed", &is_signed_); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Factor"); + ImGui::SameLine(80); + ImGui::SetNextItemWidth(100); + validatedText("##factor", &factor_, validateDouble); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Offset"); + ImGui::SameLine(80); + ImGui::SetNextItemWidth(100); + validatedText("##offset", &offset_, validateDouble); + ImGui::EndDisabled(); +} + +void FindSignalDlg::drawFindGroup() { + static const char *compare_items[] = {"=", ">", ">=", "!=", "<", "<=", "between"}; + const int compare_count = IM_ARRAYSIZE(compare_items); + ImGui::TextUnformatted("Find signal"); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Value"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(90); + ImGui::Combo("##compare", &compare_, compare_items, compare_count); + ImGui::SameLine(); + ImGui::SetNextItemWidth(80); + if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere(); + validatedText("##value1", &value1_, validateDouble); + if (compare_ == compare_count - 1) { + ImGui::SameLine(); + ImGui::TextUnformatted("-"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(80); + validatedText("##value2", &value2_, validateDouble); + } + ImGui::SameLine(); + const bool first = !searching_ && search_.histories.empty(); + ImGui::BeginDisabled(searching_ || search_.histories.size() <= 1); + if (ImGui::Button("Undo prev find")) { + search_.undo(); + searched_ = true; + } + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::BeginDisabled(searching_ || (search_.filtered_signals.empty() && !first)); + if (ImGui::Button(searching_ ? "Finding ...." : (first ? "Find" : "Find Next"))) search(); + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::BeginDisabled(searching_ || first); + if (ImGui::Button("Reset")) { + search_.reset(); + searched_ = true; + } + ImGui::EndDisabled(); + + if (searching_) { + ImGui::BeginChild("view", ImVec2(0, 0), ImGuiChildFlags_Borders); + ImGui::EndChild(); + } else { + drawTable(); + } +} + +void FindSignalDlg::drawTable() { + static const char *titles[] = {"Id", "Start Bit, size", "(time, value)"}; + const int columns = IM_ARRAYSIZE(titles); + const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable | ImGuiTableFlags_NoSavedSettings; + if (!ImGui::BeginTable("view", columns + 1, flags, ImVec2(0, 0))) return; + ImGui::TableSetupScrollFreeze(0, 1); + const int rows = std::min(search_.filtered_signals.size(), MAX_ROWS); + // vertical header: row number, no width while there are no results + ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed | (rows ? 0 : ImGuiTableColumnFlags_Disabled), 40.0f); + for (int c = 0; c < columns; ++c) { + auto column_flags = c == columns - 1 ? ImGuiTableColumnFlags_WidthStretch : ImGuiTableColumnFlags_WidthFixed; + ImGui::TableSetupColumn(titles[c], column_flags, c == 0 ? 80.0f : 120.0f); + } + tableHeadersRow(); + for (int row = 0; row < rows; ++row) { + const auto &s = search_.filtered_signals[row]; + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::PushID(row); + if (ImGui::Selectable(std::to_string(row + 1).c_str(), false, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) { + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) openMessage(s.id); + } + drawContextMenu(row); + ImGui::PopID(); + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(s.id.toString().c_str()); + ImGui::TableSetColumnIndex(2); + ImGui::Text("%d, %d", s.sig.start_bit, s.sig.size); + ImGui::TableSetColumnIndex(3); + std::string values; + for (size_t i = 0; i < s.values.size(); ++i) { + if (i) values += " "; + values += s.values[i]; + } + ImGui::TextUnformatted(values.c_str()); + } + ImGui::EndTable(); +} + +void FindSignalDlg::search() { + if (search_.histories.empty()) { + setInitialSignals(); + } + auto v1 = utils::toDouble(value1_); + auto v2 = utils::toDouble(value2_); + std::function cmp = nullptr; + switch (compare_) { + case 0: cmp = [v1](double v) { return v == v1;}; break; + case 1: cmp = [v1](double v) { return v > v1;}; break; + case 2: cmp = [v1](double v) { return v >= v1;}; break; + case 3: cmp = [v1](double v) { return v != v1;}; break; + case 4: cmp = [v1](double v) { return v < v1;}; break; + case 5: cmp = [v1](double v) { return v <= v1;}; break; + case 6: cmp = [v1, v2](double v) { return v >= v1 && v <= v2;}; break; + } + searched_ = false; + // a thread of its own: the search fans out over the pool, which a pool thread must not wait on + search_future_ = std::async(std::launch::async, [this, cmp = std::move(cmp)]() { search_.search(cmp); }); + searching_ = true; +} + +void FindSignalDlg::setInitialSignals() { + std::set buses; + for (auto bus : utils::split(utils::trimmed(bus_), ',')) { + bus = utils::trimmed(bus); + if (!bus.empty()) buses.insert((unsigned short)utils::toULong(bus)); + } + + std::set addresses; + for (auto addr : utils::split(utils::trimmed(address_), ',')) { + addr = utils::trimmed(addr); + if (!addr.empty()) addresses.insert(utils::toULong(addr, 16)); + } + + cabana::Signal sig{}; + sig.is_little_endian = little_endian_; + sig.is_signed = is_signed_; + sig.factor = utils::toDouble(factor_); + sig.offset = utils::toDouble(offset_); + + double first_time_val = utils::toDouble(first_time_); + double last_time_val = utils::toDouble(last_time_); + auto [first_sec, last_sec] = std::minmax(first_time_val, last_time_val); + uint64_t first_time = can->toMonoTime(first_sec); + search_.last_time = std::numeric_limits::max(); + if (last_sec > 0) { + search_.last_time = can->toMonoTime(last_sec); + } + search_.initial_signals.clear(); + + for (const auto &[id, m] : can->lastMessages()) { + if ((buses.empty() || buses.count(id.source)) && (addresses.empty() || addresses.count(id.address))) { + const auto &events = can->events(id); + auto e = std::lower_bound(events.cbegin(), events.cend(), first_time, CompareCanEvent()); + if (e != events.cend()) { + const int total_size = m.dat.size() * 8; + for (int size = min_size_; size <= max_size_; ++size) { + for (int start = 0; start <= total_size - size; ++start) { + SignalSearch::SearchSignal s{.id = id, .mono_time = first_time, .sig = sig}; + s.sig.start_bit = start; + s.sig.size = size; + updateMsbLsb(s.sig); + s.value = get_raw_value((*e)->dat, (*e)->size, s.sig); + search_.initial_signals.push_back(s); + } + } + } + } + } +} + +void FindSignalDlg::drawContextMenu(int row) { + if (ImGui::BeginPopupContextItem("menu")) { + if (ImGui::MenuItem("Create Signal")) { + auto &s = search_.filtered_signals[row]; + UndoStack::instance()->push(new AddSigCommand(s.id, s.sig)); + openMessage(s.id); + } + ImGui::EndPopup(); + } +} diff --git a/openpilot/tools/cabana/ui/tools/findsignal.h b/openpilot/tools/cabana/ui/tools/findsignal.h new file mode 100644 index 0000000000..1db9bc47ee --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/findsignal.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/tools/tooldialog.h" + +struct SignalSearch { + struct SearchSignal { + MessageId id = {}; + uint64_t mono_time = 0; + cabana::Signal sig = {}; + double value = 0.; + std::vector values; + }; + + void search(const std::function &cmp); + void reset(); + void undo(); + + std::vector filtered_signals; + std::vector initial_signals; + std::vector> histories; + uint64_t last_time = std::numeric_limits::max(); +}; + +class FindSignalDlg : public ToolDialog { +public: + FindSignalDlg(); + ~FindSignalDlg() override; + bool draw() override; + + Observable openMessage; + +private: + void search(); + void setInitialSignals(); + void drawContextMenu(int row); + void drawMessageGroup(); + void drawPropertiesGroup(); + void drawFindGroup(); + void drawTable(); + + std::string value1_, value2_, factor_ = "1.0", offset_ = "0.0"; + std::string bus_, address_, first_time_ = "0", last_time_ = "MAX"; + int compare_ = 0; + int min_size_ = 8, max_size_ = 8; + bool little_endian_ = true, is_signed_ = false; + bool searched_ = false; // a search/undo/reset ran, so the stats line is shown + SignalSearch search_; + std::future search_future_; // search_ is off limits while it is valid + bool searching_ = false; +}; diff --git a/openpilot/tools/cabana/ui/tools/findsimilarbits.cc b/openpilot/tools/cabana/ui/tools/findsimilarbits.cc new file mode 100644 index 0000000000..81ae3660a9 --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/findsimilarbits.cc @@ -0,0 +1,178 @@ +#include "tools/cabana/ui/tools/findsimilarbits.h" + +#include +#include + +#include "imgui.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/ui/util.h" + +FindSimilarBitsDlg::FindSimilarBitsDlg() { + setTitle("Find similar bits"); + + for (int bus : can->sources) { + bus_items_.push_back(bus); + } + updateMessages(); +} + +void FindSimilarBitsDlg::updateMessages() { + msg_items_.clear(); + msg_names_.clear(); + for (auto &[address, msg] : dbc()->getMessages(busAt(src_bus_))) { + msg_items_.push_back({msg.name, address}); + } + std::sort(msg_items_.begin(), msg_items_.end(), [](auto &l, auto &r) { return l.first < r.first; }); + for (auto &[name, _] : msg_items_) msg_names_.push_back(name); + msg_index_ = 0; +} + +bool FindSimilarBitsDlg::draw() { + if (begin(ImVec2(700, 500))) { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Find From:"); + ImGui::SameLine(90); + ImGui::TextUnformatted("Bus"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(60); + if (comboBox("##src_bus", &src_bus_, bus_items_.data(), (int)bus_items_.size())) updateMessages(); + ImGui::SameLine(); + ImGui::SetNextItemWidth(200); + comboBox("##msg", &msg_index_, msg_names_); + ImGui::SameLine(); + ImGui::TextUnformatted("Byte Index"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(80); + if (ImGui::InputInt("##byte_idx", &byte_idx_, 1, 10)) byte_idx_ = std::clamp(byte_idx_, 0, 63); + ImGui::SameLine(); + ImGui::TextUnformatted("Bit Index"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(80); + if (ImGui::InputInt("##bit_idx", &bit_idx_, 1, 10)) bit_idx_ = std::clamp(bit_idx_, 0, 7); + + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Find In:"); + ImGui::SameLine(90); + ImGui::TextUnformatted("Bus"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(60); + comboBox("##find_bus", &find_bus_, bus_items_.data(), (int)bus_items_.size()); + ImGui::SameLine(); + ImGui::TextUnformatted("Equal"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(60); + ImGui::Combo("##equal", &equal_, "Yes\0No\0"); + ImGui::SameLine(); + ImGui::TextUnformatted("Min msg count"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(80); + if (ImGui::InputInt("##min_msgs", &min_msgs_, 1, 10)) min_msgs_ = std::max(min_msgs_, 0); + ImGui::SameLine(); + if (ImGui::Button("Find")) find(); + + drawTable(); + } + return end(); +} + +void FindSimilarBitsDlg::drawTable() { + // columns are set by find(); until then the table is an empty frame + if (!table_has_columns_) { + ImGui::BeginChild("table", ImVec2(0, 0), ImGuiChildFlags_Borders); + ImGui::EndChild(); + return; + } + const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable | ImGuiTableFlags_NoSavedSettings; + if (!ImGui::BeginTable("table", 7, flags, ImVec2(0, 0))) return; + ImGui::TableSetupScrollFreeze(0, 1); + static const char *headers[] = {"address", "byte idx", "bit idx", "mismatches", "total msgs", "% mismatched"}; + // the fixed widths are section sizes: imgui adds the cell padding on top of the column width + const float padding = ImGui::GetStyle().CellPadding.x * 2; + ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 40.0f - padding); // vertical header: row number + for (int c = 0; c < 6; ++c) { + ImGui::TableSetupColumn(headers[c], c == 5 ? ImGuiTableColumnFlags_WidthStretch : ImGuiTableColumnFlags_WidthFixed, + 100.0f - padding); + } + tableHeadersRow(); + ImGuiListClipper clipper; + clipper.Begin((int)table_.size()); + while (clipper.Step()) { + for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; ++i) { + auto &m = table_[i]; + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::PushID(i); + if (ImGui::Selectable(std::to_string(i + 1).c_str(), false, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) { + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + openMessage(MessageId{.source = busAt(find_bus_), .address = m.address}); + } + } + ImGui::PopID(); + ImGui::TableSetColumnIndex(1); + ImGui::Text("%x", m.address); + ImGui::TableSetColumnIndex(2); + ImGui::Text("%u", m.byte_idx); + ImGui::TableSetColumnIndex(3); + ImGui::Text("%u", m.bit_idx); + ImGui::TableSetColumnIndex(4); + ImGui::Text("%u", m.mismatches); + ImGui::TableSetColumnIndex(5); + ImGui::Text("%u", m.total); + ImGui::TableSetColumnIndex(6); + ImGui::Text("%.2f", m.perc); + } + } + ImGui::EndTable(); +} + +void FindSimilarBitsDlg::find() { + const uint32_t selected_address = msg_index_ < (int)msg_items_.size() ? msg_items_[msg_index_].second : 0; + table_ = calcBits(busAt(src_bus_), selected_address, byte_idx_, bit_idx_, busAt(find_bus_), equal_ == 0, min_msgs_); + table_has_columns_ = true; +} + +std::vector FindSimilarBitsDlg::calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, + int bit_idx, uint8_t find_bus, bool equal, int min_msgs_cnt) { + std::unordered_map> mismatches; + std::unordered_map msg_count; + const auto &events = can->allEvents(); + int bit_to_find = -1; + for (const CanEvent *e : events) { + if (e->src == bus) { + if (e->address == selected_address && e->size > byte_idx) { + bit_to_find = ((e->dat[byte_idx] >> (7 - bit_idx)) & 1) != 0; + } + } + if (e->src == find_bus) { + ++msg_count[e->address]; + if (bit_to_find == -1) continue; + + auto &mismatched = mismatches[e->address]; + if (mismatched.size() < e->size * 8) { + mismatched.resize(e->size * 8); + } + for (int i = 0; i < e->size; ++i) { + for (int j = 0; j < 8; ++j) { + int bit = ((e->dat[i] >> (7 - j)) & 1) != 0; + mismatched[i * 8 + j] += equal ? (bit != bit_to_find) : (bit == bit_to_find); + } + } + } + } + + std::vector result; + result.reserve(mismatches.size()); + for (auto it = mismatches.begin(); it != mismatches.end(); ++it) { + if (auto cnt = msg_count[it->first]; cnt > (uint32_t)min_msgs_cnt) { + auto &mismatched = it->second; + for (int i = 0; i < (int)mismatched.size(); ++i) { + if (float perc = (mismatched[i] / (double)cnt) * 100; perc < 50) { + result.push_back({it->first, (uint32_t)i / 8, (uint32_t)i % 8, mismatched[i], cnt, perc}); + } + } + } + } + std::sort(result.begin(), result.end(), [](auto &l, auto &r) { return l.perc < r.perc; }); + return result; +} diff --git a/openpilot/tools/cabana/ui/tools/findsimilarbits.h b/openpilot/tools/cabana/ui/tools/findsimilarbits.h new file mode 100644 index 0000000000..c60bf1562b --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/findsimilarbits.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include + +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/ui/tools/tooldialog.h" + +class FindSimilarBitsDlg : public ToolDialog { +public: + FindSimilarBitsDlg(); + bool draw() override; + + Observable openMessage; + +private: + struct Mismatch { + uint32_t address, byte_idx, bit_idx, mismatches, total; + float perc; + }; + std::vector calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, int bit_idx, uint8_t find_bus, + bool equal, int min_msgs_cnt); + uint8_t busAt(int index) const { return index < (int)bus_items_.size() ? bus_items_[index] : 0; } + void updateMessages(); + void find(); + void drawTable(); + + std::vector table_; // rows, replaced by find() + bool table_has_columns_ = false; + std::vector bus_items_; // the source and the find bus combos + int src_bus_ = 0, find_bus_ = 0; + std::vector> msg_items_; // (name, address) of the source bus + std::vector msg_names_; + int msg_index_ = 0; + int equal_ = 0; + int byte_idx_ = 0, bit_idx_ = 0; + int min_msgs_ = 100; +}; diff --git a/openpilot/tools/cabana/ui/tools/routeinfo.cc b/openpilot/tools/cabana/ui/tools/routeinfo.cc new file mode 100644 index 0000000000..1761da2a1f --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/routeinfo.cc @@ -0,0 +1,50 @@ +#include "tools/cabana/ui/tools/routeinfo.h" + +#include +#include + +#include "imgui.h" +#include "tools/cabana/streams/replaystream.h" +#include "tools/cabana/ui/util.h" + +RouteInfoDlg::RouteInfoDlg() { + replay_ = dynamic_cast(can)->getReplay(); + setTitle("Route: " + replay_->route().name()); +} + +bool RouteInfoDlg::draw() { + static const char *headers[] = {"", "rlog", "narrow road", "wide road", "driver", "qlog", "qcam"}; + auto yn = [](const std::string &s) { return s.empty() ? "--" : "Yes"; }; + const auto &segments = replay_->route().segments(); + // minimum size: header + min(rowCount, 13) rows + float row_h = ImGui::GetTextLineHeightWithSpacing(); + float min_h = row_h * (std::min((int)segments.size(), 13) + 1) + ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().WindowPadding.y * 2; + if (begin(ImVec2(520, min_h))) { + const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingFixedFit; + if (ImGui::BeginTable("table", 7, flags, ImVec2(0, 0))) { + ImGui::TableSetupScrollFreeze(0, 1); + for (int c = 0; c < 7; ++c) ImGui::TableSetupColumn(headers[c]); + tableHeadersRow(); + int row = 0; + for (const auto &[seg_num, seg] : segments) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::PushID(row); + if (ImGui::Selectable(std::to_string(seg_num).c_str(), false, ImGuiSelectableFlags_SpanAllColumns)) { + can->seekTo(row * 60.0); + } + ImGui::SetItemTooltip("Click on a row to seek to the corresponding segment."); + ImGui::PopID(); + const char *cells[] = {yn(seg.rlog), yn(seg.narrow_road_cam), yn(seg.wide_road_cam), + yn(seg.cabin_cam), yn(seg.qlog), yn(seg.qcamera)}; + for (int c = 1; c < 7; ++c) { + ImGui::TableSetColumnIndex(c); + ImGui::TextUnformatted(cells[c - 1]); + } + ++row; + } + ImGui::EndTable(); + } + } + return end(); +} diff --git a/openpilot/tools/cabana/ui/tools/routeinfo.h b/openpilot/tools/cabana/ui/tools/routeinfo.h new file mode 100644 index 0000000000..e3d4e11b3d --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/routeinfo.h @@ -0,0 +1,14 @@ +#pragma once + +#include "tools/cabana/ui/tools/tooldialog.h" + +class Replay; + +class RouteInfoDlg : public ToolDialog { +public: + RouteInfoDlg(); + bool draw() override; + +private: + Replay *replay_ = nullptr; // destroyed with the stream, which destroys this dialog first +}; diff --git a/openpilot/tools/cabana/ui/tools/tooldialog.h b/openpilot/tools/cabana/ui/tools/tooldialog.h new file mode 100644 index 0000000000..06807bcd33 --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/tooldialog.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include + +#include "imgui.h" +#include "tools/cabana/core/observable.h" +#include "tools/cabana/ui/util.h" + +// non-modal dialogs drawn by MainWindow every frame until closed +class ToolDialog { +public: + virtual ~ToolDialog() = default; + virtual bool draw() = 0; // false once the dialog was closed + + Connections connections_; // dies with the dialog + +protected: + void setTitle(const std::string &name) { + char buf[32]; + snprintf(buf, sizeof(buf), "###tooldialog%p", (void *)this); + title_ = name + buf; + } + + // draw() body: `if (begin(size)) { content } return end();` + bool begin(const ImVec2 &size) { + if (!open_) return false; + ImGui::SetNextWindowSize(size, ImGuiCond_Appearing); + setNextWindowFloatsOut(); + began_ = true; + return visible_ = ImGui::Begin(title_.c_str(), &open_, ImGuiWindowFlags_NoSavedSettings); + } + + bool end() { + if (!began_) return false; + // Escape closes the dialog like QDialog, but not while a popup is open above it + if (visible_ && ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) && + ImGui::IsKeyPressed(ImGuiKey_Escape, false) && + !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel)) { + open_ = false; + } + ImGui::End(); + began_ = false; + return open_; + } + + std::string title_; + bool open_ = true; + +private: + bool began_ = false, visible_ = false; +}; diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc new file mode 100644 index 0000000000..bd1b2f8039 --- /dev/null +++ b/openpilot/tools/cabana/ui/util.cc @@ -0,0 +1,466 @@ +#include "tools/cabana/ui/util.h" + +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#ifdef __APPLE__ +#include +#include +extern "C" { +struct objc_object; +struct objc_selector; +objc_object *glfwGetCocoaWindow(GLFWwindow *window); +objc_selector *sel_registerName(const char *name); +void objc_msgSend(void); +} +#endif + +#include "tools/cabana/ui/icons.h" + +int inputCallback(ImGuiInputTextCallbackData *data) { + auto *ctx = static_cast(data->UserData); + if (data->EventFlag == ImGuiInputTextFlags_CallbackCharFilter) { + return ctx->validator ? ctx->validator(data) : 0; + } + if (data->EventFlag == ImGuiInputTextFlags_CallbackEdit) { + if (ctx->validate(std::string(data->Buf, data->BufTextLen)) == ValidState::Invalid) { + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, ctx->last_valid->c_str()); + } + return 0; + } + if (data->EventFlag == ImGuiInputTextFlags_CallbackResize) { + ctx->str->resize(data->BufTextLen); + data->Buf = ctx->str->data(); + } + return 0; +} + +bool validatedInput(const char *label, std::string *s, ImGuiInputTextCallback validator, const char *hint, + ImGuiInputTextFlags flags) { + InputContext ctx{s, validator}; + flags |= ImGuiInputTextFlags_CallbackResize; + if (validator) flags |= ImGuiInputTextFlags_CallbackCharFilter; + return ImGui::InputTextWithHint(label, hint, s->data(), s->capacity() + 1, flags, inputCallback, &ctx); +} + +bool inputTextMultiline(const char *label, std::string *s, const ImVec2 &size, ImGuiInputTextFlags flags) { + InputContext ctx{s, nullptr}; + return ImGui::InputTextMultiline(label, s->data(), s->capacity() + 1, size, flags | ImGuiInputTextFlags_CallbackResize, + inputCallback, &ctx); +} + +bool clearableInput(const char *label, std::string *s, const char *hint, ImGuiInputTextCallback validator) { + bool changed = validatedInput(label, s, validator, hint); + if (!s->empty()) { + ImGui::SameLine(0.0f, 0.0f); + ImGui::PushID(label); + if (toolButton("clear", icon::X)) { + s->clear(); + changed = true; + } + ImGui::PopID(); + } + return changed; +} + +bool comboBox(const char *label, int *index, const std::vector &items) { + bool changed = false; + const int count = (int)items.size(); + if (ImGui::BeginCombo(label, *index >= 0 && *index < count ? items[*index].c_str() : "")) { + for (int i = 0; i < count; ++i) { + ImGui::PushID(i); + if (ImGui::Selectable(items[i].c_str(), i == *index) && *index != i) { + *index = i; + changed = true; + } + if (i == *index) ImGui::SetItemDefaultFocus(); + ImGui::PopID(); + } + ImGui::EndCombo(); + } + return changed; +} + +bool validatedText(const char *label, std::string *s, ValidState (*validate)(const std::string &), + const char *hint, ImGuiInputTextCallback filter) { + const std::string last_valid = *s; // a refused edit never reaches *s + InputContext ctx{s, filter, validate, &last_valid}; + ImGuiInputTextFlags flags = ImGuiInputTextFlags_CallbackResize | ImGuiInputTextFlags_CallbackEdit; + if (filter) flags |= ImGuiInputTextFlags_CallbackCharFilter; + ImGui::InputTextWithHint(label, hint, s->data(), s->capacity() + 1, flags, inputCallback, &ctx); + return *s != last_valid; +} + +int nameValidator(ImGuiInputTextCallbackData *data) { + // [A-Za-z0-9_], spaces rewritten to '_' + if (data->EventChar == ' ') { + data->EventChar = '_'; + return 0; + } + return (data->EventChar < 128 && (std::isalnum((int)data->EventChar) || data->EventChar == '_')) ? 0 : 1; +} + +int nodeValidator(ImGuiInputTextCallbackData *data) { + // \w+(,\w+)* + return (data->EventChar < 128 && (std::isalnum((int)data->EventChar) || data->EventChar == '_' || data->EventChar == ',')) ? 0 : 1; +} + +int doubleValidator(ImGuiInputTextCallbackData *data) { + // C-locale floating-point + const ImWchar c = data->EventChar; + return (c < 128 && (std::isdigit((int)c) || c == '+' || c == '-' || c == '.' || c == 'e' || c == 'E')) ? 0 : 1; +} + +int ipValidator(ImGuiInputTextCallbackData *data) { + // [0-9.] + const ImWchar c = data->EventChar; + return ((c >= '0' && c <= '9') || c == '.') ? 0 : 1; +} + +int nonWhitespaceValidator(ImGuiInputTextCallbackData *data) { + // \S+ + return (data->EventChar < 128 && std::isspace((int)data->EventChar)) ? 1 : 0; +} + +bool toolButton(const char *id, const char *icon, const char *tooltip, const char *text) { + std::string label = text && *text ? std::string(icon) + " " + text + "###" + id : std::string(icon) + "###" + id; + // no frame, transparent until hovered + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + bool clicked = ImGui::Button(label.c_str()); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + if (tooltip && *tooltip) ImGui::SetItemTooltip("%s", tooltip); + return clicked; +} + +void disabledItemTooltip(const char *text) { + if (ImGui::IsItemHovered(ImGuiHoveredFlags_ForTooltip | ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", text); +} + +bool radioMenuItem(const char *label, bool checked, float width) { + const float indent = ImGui::GetFontSize(); + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const bool clicked = ImGui::Selectable((std::string("##") + label).c_str(), false, ImGuiSelectableFlags_None, + ImVec2(ImMax(width, ImGui::GetContentRegionAvail().x), 0.0f)); + const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text); + ImDrawList *painter = ImGui::GetWindowDrawList(); + if (checked) ImGui::RenderBullet(painter, ImVec2(pos.x + indent / 2, pos.y + ImGui::GetTextLineHeight() / 2), color); + painter->AddText(ImVec2(pos.x + indent, pos.y), color, label); + return clicked; +} + +bool PopupOwner::begin(const char *id) { + ImGuiWindow *window = ImGui::GetCurrentWindowRead(); // GetCurrentWindow() would mark the fallback window as used + if (popup_id == 0) { + // a pending popup may only be opened from the call nested in the top-most modal, or from any call + // when there is no modal at all + ImGuiWindow *modal = ImGui::GetTopMostPopupModal(); + if (modal != nullptr && modal != window) return false; + ImGui::OpenPopup(id); + popup_id = window->GetID(id); + owner_id = window->ID; + } else if (owner_id != window->ID) { + return false; + } else if (!ImGui::IsPopupOpen(popup_id, ImGuiPopupFlags_AnyPopupLevel)) { + // reopen if imgui closed the popup underneath us (host window change) + ImGui::OpenPopup(id); + } + return true; +} + +ImGuiWindow *topPopupWindow() { + ImGuiContext &g = *GImGui; + return g.OpenPopupStack.Size > 0 ? g.OpenPopupStack.back().Window : nullptr; +} + +bool dialogEscapePressed() { + return ImGui::IsKeyPressed(ImGuiKey_Escape, false) && topPopupWindow() == ImGui::GetCurrentWindow(); +} + +bool dialogButtons(const char *accept_label, bool *accepted, bool *rejected, bool accept_enabled, + const char *reject_label) { + const float button_width = 80.0f; + const int count = reject_label ? 2 : 1; + const float total = button_width * count + ImGui::GetStyle().ItemSpacing.x * (count - 1); + const float avail = ImGui::GetContentRegionAvail().x; + if (avail > total) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + avail - total); + bool pressed = false; + if (reject_label) { + if (ImGui::Button(reject_label, ImVec2(button_width, 0.0f))) { + if (rejected) *rejected = true; + pressed = true; + } + ImGui::SameLine(); + } + ImGui::BeginDisabled(!accept_enabled); + if (ImGui::Button(accept_label, ImVec2(button_width, 0.0f))) { + if (accepted) *accepted = true; + pressed = true; + } + ImGui::EndDisabled(); + if (rejected && dialogEscapePressed()) { + *rejected = true; + pressed = true; + } + return pressed; +} + +int tableHeadersRow() { + int clicked = -1; + ImGui::TableNextRow(ImGuiTableRowFlags_Headers); + for (int c = 0, count = ImGui::TableGetColumnCount(); c < count; ++c) { + if (!ImGui::TableSetColumnIndex(c)) continue; + const char *name = ImGui::TableGetColumnName(c); + if (!name) name = ""; + const float offset = (ImGui::GetContentRegionAvail().x - ImGui::CalcTextSize(name).x) * 0.5f; + if (offset > 0) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + offset); + ImGui::PushID(c); + ImGui::TableHeader(name); + // same timing as TableHeader's own TableOpenContextMenu, so a menu opened by the caller is opened last + // and replaces the (disabled) table context menu in the popup stack + if (ImGui::IsItemHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Right)) clicked = c; + ImGui::PopID(); + } + return clicked; +} + +bool viewSelectable(const char *label, bool selected, ImGuiSelectableFlags flags, const ImVec2 &size) { + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, selected ? ImGui::GetColorU32(ImGuiCol_Header) : IM_COL32(0, 0, 0, 0)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImGui::GetColorU32(ImGuiCol_Header)); + const bool clicked = ImGui::Selectable(label, selected, flags, size); + ImGui::PopStyleColor(2); + return clicked; +} + +bool checkBox(const char *label, bool *v) { + const float box = 16.0f; + ImGuiWindow *window = ImGui::GetCurrentWindow(); + if (window->SkipItems) return false; + const ImGuiStyle &style = ImGui::GetStyle(); + const ImGuiID id = window->GetID(label); + const ImVec2 label_size = ImGui::CalcTextSize(label, nullptr, true); + const float frame_h = ImGui::GetFrameHeight(); + const ImVec2 pos = window->DC.CursorPos; + const ImRect total_bb(pos, ImVec2(pos.x + box + (label_size.x > 0.0f ? style.ItemInnerSpacing.x + label_size.x : 0.0f), pos.y + frame_h)); + ImGui::ItemSize(total_bb, style.FramePadding.y); + if (!ImGui::ItemAdd(total_bb, id)) return false; + bool hovered, held; + const bool pressed = ImGui::ButtonBehavior(total_bb, id, &hovered, &held); + if (pressed) { + *v = !*v; + ImGui::MarkItemEdited(id); + } + const float y = pos.y + IM_TRUNC((frame_h - box) * 0.5f); + const ImRect check_bb(ImVec2(pos.x, y), ImVec2(pos.x + box, y + box)); + ImGui::RenderNavCursor(total_bb, id); + const ImU32 bg = ImGui::GetColorU32((held && hovered) ? ImGuiCol_FrameBgActive : hovered ? ImGuiCol_FrameBgHovered : ImGuiCol_FrameBg); + ImGui::RenderFrame(check_bb.Min, check_bb.Max, bg, true, style.FrameRounding); + if (*v) { + const float pad = ImMax(1.0f, IM_TRUNC(box / 6.0f)); + ImGui::RenderCheckMark(window->DrawList, ImVec2(check_bb.Min.x + pad, check_bb.Min.y + pad), ImGui::GetColorU32(ImGuiCol_CheckMark), box - pad * 2.0f); + } + if (label_size.x > 0.0f) ImGui::RenderText(ImVec2(check_bb.Max.x + style.ItemInnerSpacing.x, pos.y + style.FramePadding.y), label); + return pressed; +} + +void alignRight(float width) { + ImGui::SameLine(); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, ImGui::GetContentRegionAvail().x - width)); +} + +void drawText(ImDrawList *dl, const ImRect &rect, const char *text, ImU32 col, ImFont *font, float font_size, const ImVec2 &align) { + if (font == nullptr) font = ImGui::GetFont(); + if (font_size <= 0.0f) font_size = ImGui::GetFontSize(); + const ImVec2 size = font->CalcTextSizeA(font_size, FLT_MAX, 0.0f, text); + const ImVec2 pos(rect.Min.x + (rect.GetWidth() - size.x) * align.x, rect.Min.y + (rect.GetHeight() - size.y) * align.y); + dl->AddText(font, font_size, pos, col, text); +} + +void drawElidedText(ImDrawList *dl, const ImRect &rect, const std::string &text, ImU32 col, bool align_right) { + const ImVec2 size = ImGui::CalcTextSize(text.c_str()); + const float y = rect.Min.y + std::max(0.0f, (rect.GetHeight() - size.y) * 0.5f); + if (size.x <= rect.GetWidth()) { + dl->AddText(ImVec2(align_right ? rect.Max.x - size.x : rect.Min.x, y), col, text.c_str()); + } else { + ImGui::PushStyleColor(ImGuiCol_Text, col); + ImGui::RenderTextEllipsis(dl, ImVec2(rect.Min.x, y), ImVec2(rect.Max.x, y + size.y), rect.Max.x, text.c_str(), nullptr, &size); + ImGui::PopStyleColor(); + } +} + +float markerSize() { return ImGui::GetTextLineHeight() - 4; } + +void drawColorMarker(ImDrawList *dl, const ImVec2 &pos, ImU32 col) { + const float size = markerSize(); + dl->AddRectFilled(ImVec2(pos.x, pos.y + 2), ImVec2(pos.x + size, pos.y + 2 + size), col); +} + +#ifdef __APPLE__ +void setMacAppName(const char *name) { + auto info = (CFMutableDictionaryRef)CFBundleGetInfoDictionary(CFBundleGetMainBundle()); + if (info == nullptr) return; + CFStringRef value = CFStringCreateWithCString(kCFAllocatorDefault, name, kCFStringEncodingUTF8); + CFDictionarySetValue(info, CFSTR("CFBundleName"), value); + CFRelease(value); +} + +bool isNativeFullScreen(GLFWwindow *window) { + constexpr unsigned long NS_WINDOW_STYLE_MASK_FULL_SCREEN = 1ul << 14; + objc_object *ns_window = glfwGetCocoaWindow(window); + if (ns_window == nullptr) return false; + auto styleMask = (unsigned long (*)(objc_object *, objc_selector *))objc_msgSend; + return (styleMask(ns_window, sel_registerName("styleMask")) & NS_WINDOW_STYLE_MASK_FULL_SCREEN) != 0; +} + +void toggleNativeFullScreen(GLFWwindow *window) { + auto toggle = (void (*)(objc_object *, objc_selector *, objc_object *))objc_msgSend; + toggle(glfwGetCocoaWindow(window), sel_registerName("toggleFullScreen:"), nullptr); +} +#endif + +void setNextWindowFloatsOut() { + ImGuiWindowClass window_class; + window_class.ViewportFlagsOverrideSet = ImGuiViewportFlags_NoAutoMerge; + ImGui::SetNextWindowClass(&window_class); +} + +void setNextDialogWindow(const ImVec2 &size) { + if (size.x > 0.0f || size.y > 0.0f) ImGui::SetNextWindowSize(size, ImGuiCond_Appearing); + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + setNextWindowFloatsOut(); +} + +bool beginDialog(const char *id, PopupOwner *owner, const ImVec2 &size, ImGuiWindowFlags flags) { + if (!owner->begin(id)) return false; + setNextDialogWindow(size); + return ImGui::BeginPopupModal(id, nullptr, flags | ImGuiWindowFlags_NoSavedSettings); +} + +// tool bar + +void beginToolbar() { + // the items sit next to each other, the buttons only carry the auto raise margin + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(TOOLBAR_ITEM_SPACING, ImGui::GetStyle().ItemSpacing.y)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(TOOLBAR_BUTTON_PADDING, ImGui::GetStyle().FramePadding.y)); +} + +void endToolbar() { ImGui::PopStyleVar(2); } + +float toolbarButtonWidth(const std::string &label) { + return ImGui::CalcTextSize(label.c_str(), nullptr, true).x + ImGui::GetStyle().FramePadding.x * 2; +} + +static float toolbarGroupWidth(const std::vector &items, size_t begin, size_t end) { + float w = 0; + for (size_t i = begin; i < end; ++i) w += items[i].width + (i > begin ? ImGui::GetStyle().ItemSpacing.x : 0); + return w; +} + +float toolbarWidth(const std::vector &items, size_t spacer_index) { + spacer_index = std::min(spacer_index, items.size()); + float w = toolbarGroupWidth(items, 0, spacer_index) + toolbarGroupWidth(items, spacer_index, items.size()); + if (spacer_index > 0 && spacer_index < items.size()) w += ImGui::GetStyle().ItemSpacing.x; + return w; +} + +void drawToolbar(const std::vector &items, size_t spacer_index) { + const ImGuiStyle &style = ImGui::GetStyle(); + spacer_index = std::min(spacer_index, items.size()); + const float right_width = toolbarGroupWidth(items, spacer_index, items.size()); + const float start_x = ImGui::GetCursorPosX(); + const float avail = ImGui::GetContentRegionAvail().x; + const float right_edge = start_x + avail; + const float extension_width = toolbarButtonWidth(icon::RAQUO); + + // when everything fits the spacer takes the slack, otherwise the extension button is reserved at the + // right edge and the items are packed from the left until the next one does not fit + const bool fits = toolbarWidth(items, spacer_index) <= avail; + size_t visible = items.size(); + if (!fits) { + const float usable = avail - (extension_width + style.ItemSpacing.x); + float used = 0; + for (visible = 0; visible < items.size(); ++visible) { + const float w = items[visible].width + (visible ? style.ItemSpacing.x : 0); + if (used + w > usable) break; + used += w; + } + } + + for (size_t i = 0; i < visible; ++i) { + if (i == 0) ImGui::SetCursorPosX(start_x); + else if (fits && i == spacer_index) ImGui::SameLine(right_edge - right_width); + else ImGui::SameLine(); + items[i].draw(); + } + + if (visible < items.size()) { + // the extension button sits fully inside the toolbar: its right edge is the content region right edge + const float extension_x = std::max(start_x, right_edge - extension_width); + visible == 0 ? ImGui::SetCursorPosX(extension_x) : ImGui::SameLine(extension_x); + if (ImGui::Button((std::string(icon::RAQUO) + "###toolbar_extension").c_str(), ImVec2(extension_width, 0))) + ImGui::OpenPopup("toolbar_extension_menu"); + ImGui::SetItemTooltip("More"); + // the popup opens inward: its right edge is aligned with the button so it stays inside the window + ImGui::SetNextWindowPos(ImVec2(ImGui::GetItemRectMax().x, ImGui::GetItemRectMax().y), ImGuiCond_Always, ImVec2(1, 0)); + if (ImGui::BeginPopup("toolbar_extension_menu")) { + for (size_t i = visible; i < items.size(); ++i) { + if (!items[i].in_menu) continue; + if (items[i].menu_label.empty()) { + items[i].draw(); + } else if (ImGui::MenuItem(items[i].menu_label.c_str(), nullptr, false, items[i].enabled)) { + items[i].trigger(); + } + } + ImGui::EndPopup(); + } + } +} + +const float MENU_ARROW_SIZE = 6.0f; // dropdown arrow on a menu button +const float MENU_ARROW_SPACING = 5.0f; // gap between the label and the dropdown arrow + +float menuButtonWidth(const std::string &text, bool bold) { + if (bold) pushBoldFont(); + const float w = ImGui::CalcTextSize(text.c_str(), nullptr, true).x + MENU_ARROW_SPACING + MENU_ARROW_SIZE + + ImGui::GetStyle().FramePadding.x * 2; + if (bold) popBoldFont(); + return w; +} + +bool menuButton(const char *id, const std::string &text, const char *popup_id, bool bold, float width) { + const ImGuiStyle &style = ImGui::GetStyle(); + const bool popup_open = ImGui::IsPopupOpen(popup_id); + if (width <= 0.0f) width = menuButtonWidth(text, bold); + // no frame, transparent until hovered; the button is drawn pressed while the menu is open. The menu opens + // on press; a press while it is open toggles it closed (imgui closes the popup at the end of the frame of + // a click outside it, so only open when it is not already open) + if (bold) pushBoldFont(); + ImGui::PushStyleColor(ImGuiCol_Button, popup_open ? style.Colors[ImGuiCol_ButtonActive] : ImVec4(0, 0, 0, 0)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.0f, 0.5f)); + const bool clicked = ImGui::ButtonEx((text + "###" + id).c_str(), ImVec2(width, 0.0f), ImGuiButtonFlags_PressedOnClick); + ImGui::PopStyleVar(2); + ImGui::PopStyleColor(); + const float text_width = ImGui::CalcTextSize(text.c_str(), nullptr, true).x; + const float ascent = ImGui::GetFontBaked()->Ascent; + if (bold) popBoldFont(); + // a 6 px arrow right after the text, sitting on the text baseline + const ImVec2 min = ImGui::GetItemRectMin(); + const float x = min.x + style.FramePadding.x + text_width + MENU_ARROW_SPACING; + const float baseline = min.y + style.FramePadding.y + ascent; + ImGui::GetWindowDrawList()->AddTriangleFilled(ImVec2(x, baseline - MENU_ARROW_SIZE * 0.5f), + ImVec2(x + MENU_ARROW_SIZE, baseline - MENU_ARROW_SIZE * 0.5f), + ImVec2(x + MENU_ARROW_SIZE * 0.5f, baseline), + ImGui::GetColorU32(ImGuiCol_TextDisabled)); + if (clicked && !popup_open) ImGui::OpenPopup(popup_id); + // the menu drops down from below the button, not at the mouse cursor + ImGui::SetNextWindowPos(ImVec2(min.x, ImGui::GetItemRectMax().y), ImGuiCond_Always); + return clicked; +} diff --git a/openpilot/tools/cabana/ui/util.h b/openpilot/tools/cabana/ui/util.h new file mode 100644 index 0000000000..99de047913 --- /dev/null +++ b/openpilot/tools/cabana/ui/util.h @@ -0,0 +1,201 @@ +#pragma once + +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" + +#include "tools/cabana/core/color.h" +#include "tools/cabana/utils/util.h" + +struct GLFWwindow; + +inline ImVec4 colorRgb(int r, int g, int b, float alpha = 1.0f) { + return ImVec4(r / 255.0f, g / 255.0f, b / 255.0f, alpha); +} + +inline ImU32 toImU32(const CabanaColor &c) { return IM_COL32(c.r, c.g, c.b, c.a); } +inline ImVec4 toImVec4(const CabanaColor &c) { return ImVec4(c.r / 255.0f, c.g / 255.0f, c.b / 255.0f, c.a / 255.0f); } +inline ImU32 withAlpha(ImU32 c, int alpha) { return (c & ~IM_COL32_A_MASK) | ((ImU32)alpha << IM_COL32_A_SHIFT); } + +// the dock window identity of the messages panel (the visible title changes, the part after ### is the id) +constexpr const char *MESSAGES_PANEL_ID = "###MessagesPanel"; + +struct InputContext { + std::string *str; + ImGuiInputTextCallback validator; + ValidState (*validate)(const std::string &) = nullptr; + const std::string *last_valid = nullptr; +}; + +int inputCallback(ImGuiInputTextCallbackData *data); + +// text input with an optional validator; `s` grows through the resize callback +bool validatedInput(const char *label, std::string *s, ImGuiInputTextCallback validator, const char *hint = "", + ImGuiInputTextFlags flags = 0); + +inline bool inputText(const char *label, std::string *s, const char *hint = "", ImGuiInputTextFlags flags = 0) { + return validatedInput(label, s, nullptr, hint, flags); +} + +bool inputTextMultiline(const char *label, std::string *s, const ImVec2 &size, ImGuiInputTextFlags flags = 0); + +// an input with a trailing clear button once it holds text; true when the text changed +bool clearableInput(const char *label, std::string *s, const char *hint = "", ImGuiInputTextCallback validator = nullptr); + +bool comboBox(const char *label, int *index, const std::vector &items); + +// numeric items (bus ids, bus speeds) are formatted as they are drawn +template +inline bool comboBox(const char *label, int *index, const T *values, int count) { + bool changed = false; + const std::string preview = *index >= 0 && *index < count ? std::to_string(values[*index]) : ""; + if (ImGui::BeginCombo(label, preview.c_str())) { + for (int i = 0; i < count; ++i) { + ImGui::PushID(i); + if (ImGui::Selectable(std::to_string(values[i]).c_str(), i == *index) && *index != i) { + *index = i; + changed = true; + } + if (i == *index) ImGui::SetItemDefaultFocus(); + ImGui::PopID(); + } + ImGui::EndCombo(); + } + return changed; +} + +// an edit that makes the text Invalid is refused inside the imgui buffer, like a validated line edit +bool validatedText(const char *label, std::string *s, ValidState (*validate)(const std::string &), + const char *hint = "", ImGuiInputTextCallback filter = nullptr); + +// InputText char filters; the std::string validators in utils/util.h are run again when the edit is committed +int nameValidator(ImGuiInputTextCallbackData *data); +int nodeValidator(ImGuiInputTextCallbackData *data); +int doubleValidator(ImGuiInputTextCallbackData *data); +int ipValidator(ImGuiInputTextCallbackData *data); +int nonWhitespaceValidator(ImGuiInputTextCallbackData *data); + +// auto-raise icon button with a tooltip +bool toolButton(const char *id, const char *icon, const char *tooltip = nullptr, const char *text = nullptr); + +// tooltip for the last item that also shows while the item is disabled +void disabledItemTooltip(const char *text); + +// exclusive menu action: the bullet sits in the check column and the whole row highlights. `width` is the +// minimum row width, so a narrow popup stays wide enough for every row while the highlight spans the popup. +bool radioMenuItem(const char *label, bool checked, float width = 0.0f); + +// A queued modal popup submitted from whichever call site is nested in the top-most modal. draw() is called +// both nested in a modal dialog and at the root level; only the level that opened the popup may submit it +// (opening at level 0 would make imgui close the parent modal). +struct PopupOwner { + ImGuiID popup_id = 0, owner_id = 0; + + // false: this call site must skip the popup this frame + bool begin(const char *id); + + void reset() { popup_id = owner_id = 0; } +}; + +// Escape closes a dialog only when nothing is open above it: a combo drops its list first. +bool dialogEscapePressed(); + +// the window of the top-most open popup, nullptr when none is open +ImGuiWindow *topPopupWindow(); + +// [Cancel] [Accept], right aligned. reject_label = nullptr for an accept-only box. Escape rejects. +bool dialogButtons(const char *accept_label, bool *accepted, bool *rejected, bool accept_enabled = true, + const char *reject_label = "Cancel"); + +// horizontal header labels are centered. Returns the column a right click was released on, or -1. +int tableHeadersRow(); + +// no hover highlight, only the selection background. Selectable() prefers HeaderHovered over Header +// whenever the row is hovered, even when it is selected, so a selected row has to keep the selection color +// as its hover color or it looks unselected. +bool viewSelectable(const char *label, bool selected, ImGuiSelectableFlags flags, const ImVec2 &size); + +// a 16px box vertically centered in the frame height so rows keep their layout; ImGui::Checkbox draws a +// frame height (22 px) square. +bool checkBox(const char *label, bool *v); + +// the next items on the line are right aligned as a block `width` wide +void alignRight(float width); + +// text drawn inside a rect; align 0 = left/top, 0.5 = center, 1 = right/bottom. font_size 0: the current size +void drawText(ImDrawList *dl, const ImRect &rect, const char *text, ImU32 col, ImFont *font = nullptr, + float font_size = 0.0f, const ImVec2 &align = ImVec2(0.5f, 0.5f)); +// elided to the rect width, vertically centered +void drawElidedText(ImDrawList *dl, const ImRect &rect, const std::string &text, ImU32 col, bool align_right = false); +// the colored square in front of a signal name: a text line minus 4 px, drawn 2 px below `pos` +float markerSize(); +void drawColorMarker(ImDrawList *dl, const ImVec2 &pos, ImU32 col); + +void loadFonts(); +void applyTheme(int theme); // safe to call at runtime +bool isDarkTheme(); // the theme applyTheme() resolved + +ImU32 highlightedTextColor(); +ImU32 paletteBrightText(); + +// the next window is a real OS window instead of being drawn inside the main one +void setNextWindowFloatsOut(); +#ifdef __APPLE__ +// the app menu takes its name from the main bundle, and a bare binary gets an info dictionary with its +// file name in it. That dictionary is mutable, so the name is set before glfw brings up cocoa +void setMacAppName(const char *name); +// the native Cocoa full screen toggle (glfw's monitor switch is not full screen on macOS) +bool isNativeFullScreen(GLFWwindow *window); +void toggleNativeFullScreen(GLFWwindow *window); +#endif + +// centered, floating out, sized on first appearance +void setNextDialogWindow(const ImVec2 &size); +// centered modal dialog. false when the popup is not submitted this frame. +bool beginDialog(const char *id, PopupOwner *owner, const ImVec2 &size, ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize); + +const float TOOLBAR_ITEM_SPACING = 1.0f; +const float TOOLBAR_BUTTON_PADDING = 4.0f; // auto raise button horizontal margin +const float SLIDER_LENGTH = 13.0f; +const float SLIDER_THICKNESS = 13.0f; + +// a tool bar item: `draw` submits it. Items that do not fit go into a ">>" menu, where an item with a +// menu_label becomes a MenuItem that runs `trigger`, and an item without one draws itself. +struct ToolbarItem { + float width; + std::function draw; + std::string menu_label; + std::function trigger; + bool enabled = true; + bool in_menu = true; // false: left out of the ">>" menu (a separator) +}; +void beginToolbar(); // item spacing and button padding of a tool bar, until endToolbar() +void endToolbar(); +float toolbarButtonWidth(const std::string &label); +// the width of every item plus the spacing between neighbors and the two groups +float toolbarWidth(const std::vector &items, size_t spacer_index); +// items before spacer_index sit at the left, the rest are right aligned; the overflow goes into the ">>" menu +void drawToolbar(const std::vector &items, size_t spacer_index); + +// an auto-raise button that opens `popup_id` below itself, with a dropdown arrow after the text. width 0: +// sized to the text, otherwise the arrow sits at the right edge +float menuButtonWidth(const std::string &text, bool bold = false); +bool menuButton(const char *id, const std::string &text, const char *popup_id, bool bold = false, float width = 0.0f); + +// a 13x13 handle filled with a subtle vertical gradient and a mid grey outline +void drawSliderHandle(ImDrawList *p, const ImRect &r); + +// full width groove, filled left of the handle, 13x13 handle (style.cc) +bool fusionSliderInt(const char *label, int *v, int min, int max, float width); + +ImFont *boldFont(); +ImFont *monoFont(); +void pushMonoFont(float size = 0.0f); // 0: the size the font was loaded at +void popMonoFont(); +void pushBoldFont(); +void popBoldFont(); +void pushLargeFont(); +void popLargeFont(); diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.cc b/openpilot/tools/cabana/ui/widgets/binaryview.cc new file mode 100644 index 0000000000..87e7213ea8 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/binaryview.cc @@ -0,0 +1,548 @@ +#include "tools/cabana/ui/widgets/binaryview.h" + +#include +#include +#include +#include +#include +#include + +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" + +namespace { + +const int CELL_HEIGHT = 36; +const float SMALL_FONT_SIZE = 10.0f; // Inter needs 10 px for a 7 px cap height +const int VERTICAL_HEADER_WIDTH = 30; +inline int get_bit_pos(const BinaryIndex &index) { return flipBitPos(index.row * 8 + index.column); } + +inline ImU32 paletteHighlight() { return ImGui::GetColorU32(ImGuiCol_Header); } +inline ImU32 paletteBase() { return ImGui::GetColorU32(ImGuiCol_ChildBg); } +inline ImU32 paletteText(bool active) { return ImGui::GetColorU32(active ? ImGuiCol_Text : ImGuiCol_TextDisabled); } +const ImU32 DARK_GRAY = IM_COL32(128, 128, 128, 255); + +// JetBrains Mono ships no bold variant, so emulate one by drawing the glyphs again a fraction of a +// pixel to the right. Keeps the monospace advance, unlike switching to the proportional bold face. +void drawBoldText(ImDrawList *p, const ImRect &r, const char *text, ImU32 col, ImFont *font, float font_size) { + drawText(p, r, text, col, font, font_size); + drawText(p, ImRect(ImVec2(r.Min.x + 0.6f, r.Min.y), ImVec2(r.Max.x + 0.6f, r.Max.y)), text, col, font, font_size); +} + +// sparse dots +void fillDense7Pattern(ImDrawList *p, const ImRect &r, ImU32 col) { + p->PushClipRect(r.Min, r.Max, true); + for (float y = r.Min.y; y < r.Max.y; y += 4.0f) { + for (float x = r.Min.x + (static_cast((y - r.Min.y) / 4.0f) % 2) * 2.0f; x < r.Max.x; x += 4.0f) { + p->AddRectFilled(ImVec2(x, y), ImVec2(x + 1.0f, y + 1.0f), col); + } + } + p->PopClipRect(); +} + +// backward diagonal lines +void fillBDiagPattern(ImDrawList *p, const ImRect &r, ImU32 col) { + p->PushClipRect(r.Min, r.Max, true); + const float h = r.GetHeight(); + for (float x = r.Min.x - h; x < r.Max.x; x += 8.0f) { + p->AddLine(ImVec2(x, r.Max.y), ImVec2(x + h, r.Min.y), col, 1.0f); + } + p->PopClipRect(); +} + +} // namespace + +BinaryView::BinaryView() { + connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { refresh(); })); +} + +std::string BinaryView::whatsThis() const { + return R"( + Binary View
+ Shortcuts
+ Delete Signal: +  x , +  Backspace , +  Delete 
+ Change endianness:  e 
+ Change signedness:  s 
+ Open chart: +  c , +  p , +  g  + )"; +} + +void BinaryView::addShortcuts() { + const ImGuiIO &io = ImGui::GetIO(); + if (io.WantTextInput || io.KeyCtrl || io.KeySuper) return; + if (ImGui::GetTopMostPopupModal() != nullptr) return; // a modal dialog blocks the shortcuts + + if (ImGui::IsKeyPressed(ImGuiKey_X, false) || ImGui::IsKeyPressed(ImGuiKey_Backspace, false) || ImGui::IsKeyPressed(ImGuiKey_Delete, false)) { + if (hovered_sig_ != nullptr) { + UndoStack::instance()->push(new RemoveSigCommand(msg_id_, hovered_sig_)); + hovered_sig_ = nullptr; + } + } + + if (ImGui::IsKeyPressed(ImGuiKey_E, false)) { + if (hovered_sig_ != nullptr) { + cabana::Signal s = *hovered_sig_; + s.is_little_endian = !s.is_little_endian; + editSignal(hovered_sig_, s); + } + } + + if (ImGui::IsKeyPressed(ImGuiKey_S, false)) { + if (hovered_sig_ != nullptr) { + cabana::Signal s = *hovered_sig_; + s.is_signed = !s.is_signed; + editSignal(hovered_sig_, s); + } + } + + if (ImGui::IsKeyPressed(ImGuiKey_P, false) || ImGui::IsKeyPressed(ImGuiKey_G, false) || ImGui::IsKeyPressed(ImGuiKey_C, false)) { + if (hovered_sig_ != nullptr) { + showChart(msg_id_, hovered_sig_, true, false); + } + } +} + +ImVec2 BinaryView::minimumSizeHint() const { + // widest fixed-font glyph plus the header margins + pushMonoFont(); + const float min_section_size = ImGui::CalcTextSize("W").x + 8.0f; + popMonoFont(); + return {(min_section_size + 1) * 9 + VERTICAL_HEADER_WIDTH + 2, + static_cast(CELL_HEIGHT * std::min(row_count_, 10) + 2)}; +} + +void BinaryView::highlight(const cabana::Signal *sig) { + if (sig != hovered_sig_) { + hovered_sig_ = sig; + signalHovered(hovered_sig_); + } +} + +void BinaryView::setSelection() { + auto index = indexAt(last_mouse_pos_); + if (!anchor_index_.isValid() || !index.isValid()) + return; + + std::set selection; + auto [start, size, is_lb] = getSelection(index); + for (int i = 0; i < size; ++i) { + int pos = is_lb ? flipBitPos(start + i) : flipBitPos(start) + i; + selection.insert({pos / 8, pos % 8}); + } + selection_ = std::move(selection); +} + +void BinaryView::handleMousePress(const ImVec2 &pos) { + resize_sig_ = nullptr; + if (auto index = indexAt(last_mouse_pos_ = pos); index.isValid() && index.column != HEX_COLUMN) { + anchor_index_ = index; + auto item = &cellAt(anchor_index_); + int bit_pos = get_bit_pos(anchor_index_); + for (auto s : item->sigs) { + if (bit_pos == s->lsb || bit_pos == s->msb) { + int idx = flipBitPos(bit_pos == s->lsb ? s->msb : s->lsb); + anchor_index_ = {idx / 8, idx % 8}; + resize_sig_ = s; + break; + } + } + } +} + +void BinaryView::highlightPosition(const ImVec2 &pos) { + if (auto index = indexAt(pos); index.isValid()) { + auto item = &cellAt(index); + const cabana::Signal *sig = item->sigs.empty() ? nullptr : item->sigs.back(); + highlight(sig); + } +} + +void BinaryView::handleMouseMove(const ImVec2 &pos) { + highlightPosition(last_mouse_pos_ = pos); + // drag selecting while the left button is down; the hex column is not selectable + if (ImGui::IsMouseDown(ImGuiMouseButton_Left) && indexAt(pos).column != HEX_COLUMN) setSelection(); +} + +void BinaryView::handleMouseRelease(const ImVec2 &pos) { + auto release_index = indexAt(pos); + if (release_index.isValid() && anchor_index_.isValid()) { + if (hasSelection()) { + auto sig = resize_sig_ ? *resize_sig_ : cabana::Signal{}; + std::tie(sig.start_bit, sig.size, sig.is_little_endian) = getSelection(release_index); + resize_sig_ ? editSignal(resize_sig_, sig) + : UndoStack::instance()->push(new AddSigCommand(msg_id_, sig)); + } else { + auto item = &cellAt(anchor_index_); + if (item->sigs.size() > 0) + signalClicked(item->sigs.back()); + } + } + selection_.clear(); + anchor_index_ = BinaryIndex(); + resize_sig_ = nullptr; +} + +void BinaryView::setMessage(const MessageId &message_id) { + msg_id_ = message_id; + scroll_to_top_ = true; + refresh(); +} + +void BinaryView::refresh() { + selection_.clear(); + anchor_index_ = BinaryIndex(); + resize_sig_ = nullptr; + hovered_sig_ = nullptr; + bit_flip_tracker_ = {}; + cells_.clear(); + if (auto dbc_msg = dbc()->msg(msg_id_)) { + row_count_ = dbc_msg->size; + cells_.resize(row_count_ * COLUMN_COUNT); + for (auto sig : dbc_msg->getSignals()) { + for (int j = 0; j < sig->size; ++j) { + int pos = sig->is_little_endian ? flipBitPos(sig->start_bit + j) : flipBitPos(sig->start_bit) + j; + int idx = COLUMN_COUNT * (pos / 8) + pos % 8; + if (idx >= cells_.size()) { + fprintf(stderr, "signal %s out of bounds.start_bit: %d size: %d\n", sig->name.c_str(), sig->start_bit, sig->size); + break; + } + if (j == 0) sig->is_little_endian ? cells_[idx].is_lsb = true : cells_[idx].is_msb = true; + if (j == sig->size - 1) sig->is_little_endian ? cells_[idx].is_msb = true : cells_[idx].is_lsb = true; + + auto &sigs = cells_[idx].sigs; + sigs.push_back(sig); + if (sigs.size() > 1) { + std::sort(sigs.begin(), sigs.end(), [](auto l, auto r) { return l->size > r->size; }); + } + } + } + } else { + row_count_ = can->lastMessage(msg_id_).dat.size(); + cells_.resize(row_count_ * COLUMN_COUNT); + } + updateState(); + if (under_mouse_) highlightPosition(last_mouse_pos_); +} + + +std::set BinaryView::getOverlappingSignals() const { + std::set overlapping; + for (const auto &item : cells_) { + if (item.sigs.size() > 1) { + for (auto s : item.sigs) { + if (s->type == cabana::Signal::Type::Normal) overlapping.insert(s); + } + } + } + return overlapping; +} + +std::tuple BinaryView::getSelection(BinaryIndex index) { + if (index.column == HEX_COLUMN) { + index = {index.row, 7}; + } + bool is_lb = true; + if (resize_sig_) { + is_lb = resize_sig_->is_little_endian; + } else if (settings.drag_direction == Settings::DragDirection::MsbFirst) { + is_lb = index < anchor_index_; + } else if (settings.drag_direction == Settings::DragDirection::LsbFirst) { + is_lb = !(index < anchor_index_); + } else if (settings.drag_direction == Settings::DragDirection::AlwaysLE) { + is_lb = true; + } else if (settings.drag_direction == Settings::DragDirection::AlwaysBE) { + is_lb = false; + } + + int cur_bit_pos = get_bit_pos(index); + int anchor_bit_pos = get_bit_pos(anchor_index_); + int start_bit = is_lb ? std::min(cur_bit_pos, anchor_bit_pos) : get_bit_pos(std::min(index, anchor_index_)); + int size = is_lb ? std::abs(cur_bit_pos - anchor_bit_pos) + 1 : std::abs(flipBitPos(cur_bit_pos) - flipBitPos(anchor_bit_pos)) + 1; + return {start_bit, size, is_lb}; +} + +BinaryIndex BinaryView::indexAt(const ImVec2 &pos) const { + if (column_width_ <= 0 || pos.x < grid_pos_.x + VERTICAL_HEADER_WIDTH || pos.y < grid_pos_.y) return {}; + int column = static_cast((pos.x - grid_pos_.x - VERTICAL_HEADER_WIDTH) / column_width_); + int row = static_cast((pos.y - grid_pos_.y) / CELL_HEIGHT); + if (column >= COLUMN_COUNT || row >= row_count_) return {}; + return {row, column}; +} + +ImRect BinaryView::visualRect(const BinaryIndex &index) const { + // sections are integral: round the edges so neighboring cells share them exactly and the cells are + // painted edge to edge with no grid line between them + const float x0 = grid_pos_.x + VERTICAL_HEADER_WIDTH + IM_ROUND(index.column * column_width_); + const float x1 = grid_pos_.x + VERTICAL_HEADER_WIDTH + IM_ROUND((index.column + 1) * column_width_); + const float y = grid_pos_.y + index.row * CELL_HEIGHT; + return ImRect(x0, y, x1, y + CELL_HEIGHT); +} + +void BinaryView::draw() { + is_message_active_ = can->isMessageActive(msg_id_); + if (scroll_to_top_) { + ImGui::SetScrollY(0.0f); + scroll_to_top_ = false; + } + + const int rows = row_count_; + const float width = ImGui::GetContentRegionAvail().x; + column_width_ = std::max(1.0f, (width - VERTICAL_HEADER_WIDTH) / COLUMN_COUNT); + grid_pos_ = ImGui::GetCursorScreenPos(); + ImGui::InvisibleButton("##binary_view", ImVec2(std::max(width, 1.0f), std::max(static_cast(rows * CELL_HEIGHT), 1.0f))); + ImDrawList *painter = ImGui::GetWindowDrawList(); + + for (int row = 0; row < rows; ++row) { + const ImRect r(grid_pos_.x, grid_pos_.y + row * CELL_HEIGHT, grid_pos_.x + VERTICAL_HEADER_WIDTH, grid_pos_.y + (row + 1) * CELL_HEIGHT); + painter->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_WindowBg)); // plain header background + drawText(painter, r, std::to_string(row).c_str(), ImGui::GetColorU32(ImGuiCol_Text)); + } + for (int row = 0; row < rows; ++row) { + for (int column = 0; column < COLUMN_COUNT; ++column) { + const BinaryIndex index = {row, column}; + paintCell(painter, visualRect(index), index); + } + } + + const ImVec2 mouse = ImGui::GetMousePos(); + const bool hovered = ImGui::IsItemHovered(); + const bool active = ImGui::IsItemActive(); + const bool under_mouse = (hovered || active) && ImGui::IsMouseHoveringRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax(), false); + if (hovered || active) { + if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) handleMousePress(mouse); + const ImVec2 delta = ImGui::GetIO().MouseDelta; + if (delta.x != 0.0f || delta.y != 0.0f) { + handleMouseMove(mouse); + } else { + // imgui only reports a delta on the frames the mouse actually moves, so recompute the hovered + // signal every frame the mouse is inside the widget, or the shortcuts stay inert after a click + highlightPosition(last_mouse_pos_ = mouse); + } + } + // the mouse left the widget rect, also while dragging + if (std::exchange(under_mouse_, under_mouse) && !under_mouse) highlight(nullptr); + if (ImGui::IsItemDeactivated()) handleMouseRelease(mouse); + + if (ImGui::IsItemHovered(ImGuiHoveredFlags_ForTooltip)) { + if (auto index = indexAt(mouse); index.isValid() && !cellAt(index).sigs.empty()) { + ImGui::SetTooltip("%s", utils::stripHtml(utils::signalToolTip(cellAt(index).sigs.back())).c_str()); + } + } + + addShortcuts(); +} + +void BinaryView::setCell(int row, int col, uint8_t val, const CabanaColor &color) { + auto &item = cells_[row * COLUMN_COUNT + col]; + item.valid = true; + item.val = val; + item.bg_color = color; +} + +void BinaryView::updateState() { + const auto &last_msg = can->lastMessage(msg_id_); + const auto &binary = last_msg.dat; + if (binary.size() > row_count_) { + row_count_ = binary.size(); + cells_.resize(row_count_ * COLUMN_COUNT); + } + + auto &bit_flips = heatmap_live_mode_ ? last_msg.bit_flip_counts : bitFlipChanges(binary.size()); + uint32_t max_bit_flip_count = 1; // 1 to avoid division by zero + for (const auto &row : bit_flips) { + for (uint32_t count : row) { + max_bit_flip_count = std::max(max_bit_flip_count, count); + } + } + + const bool dark = isDarkTheme(); + const double max_alpha = 255.0; + const double min_alpha_with_signal = dark ? 70.0 : 25.0; // Base alpha for small flip counts + const double min_alpha_no_signal = dark ? 28.0 : 10.0; // Base alpha for small flip counts for no signal bits + const double alpha_gamma = dark ? 0.6 : 1.0; + const double log_factor = 1.0 + 0.2; + const double log_scaler = max_alpha / log2(log_factor * max_bit_flip_count); + + for (size_t i = 0; i < binary.size(); ++i) { + for (int j = 0; j < 8; ++j) { + auto &item = cells_[i * COLUMN_COUNT + j]; + int bit_val = (binary[i] >> (7 - j)) & 1; + + double alpha = item.sigs.empty() ? 0 : min_alpha_with_signal; + uint32_t flip_count = bit_flips[i][j]; + if (flip_count > 0) { + double normalized_alpha = log2(1.0 + flip_count * log_factor) * log_scaler; + normalized_alpha = max_alpha * std::pow(std::clamp(normalized_alpha / max_alpha, 0.0, 1.0), alpha_gamma); + double min_alpha = item.sigs.empty() ? min_alpha_no_signal : min_alpha_with_signal; + alpha = std::clamp(normalized_alpha, min_alpha, max_alpha); + } + + auto color = item.bg_color; + color.a = static_cast(alpha); + setCell(i, j, bit_val, color); + } + setCell(i, HEX_COLUMN, binary[i], last_msg.colors[i]); + } +} + +const std::vector> &BinaryView::bitFlipChanges(size_t msg_size) { + auto time_range = can->timeRange(); + if (bit_flip_tracker_.time_range == time_range && !bit_flip_tracker_.flip_counts.empty()) + return bit_flip_tracker_.flip_counts; + + bit_flip_tracker_.time_range = time_range; + bit_flip_tracker_.flip_counts.assign(msg_size, std::array{}); + + auto [first, last] = can->eventsInRange(msg_id_, time_range); + if (std::distance(first, last) <= 1) return bit_flip_tracker_.flip_counts; + + std::vector prev_values((*first)->dat, (*first)->dat + (*first)->size); + for (auto it = std::next(first); it != last; ++it) { + const CanEvent *event = *it; + int size = std::min(msg_size, event->size); + for (int i = 0; i < size; ++i) { + const uint8_t diff = event->dat[i] ^ prev_values[i]; + if (!diff) continue; + + auto &bit_flips = bit_flip_tracker_.flip_counts[i]; + for (int bit = 0; bit < 8; ++bit) { + if (diff & (1u << bit)) ++bit_flips[7 - bit]; + } + prev_values[i] = event->dat[i]; + } + } + + return bit_flip_tracker_.flip_counts; +} + +bool BinaryView::hasSignal(const BinaryIndex &index, int dx, int dy, const cabana::Signal *sig) const { + if (!index.isValid()) return false; + int idx = (index.row + dy) * COLUMN_COUNT + index.column + dx; + if (idx < 0 || idx >= (int)cells_.size()) return false; + auto &s = cells_[idx].sigs; + return std::find(s.begin(), s.end(), sig) != s.end(); +} + +void BinaryView::paintCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index) const { + auto item = &cellAt(index); + ImFont *font = ImGui::GetFont(); + float font_size = ImGui::GetFontSize(); + ImU32 pen = paletteText(is_message_active_); + + if (index.column == HEX_COLUMN) { + if (item->valid) { + pushMonoFont(); + font = ImGui::GetFont(); + font_size = ImGui::GetFontSize(); + popMonoFont(); + painter->AddRectFilled(rect.Min, rect.Max, toImU32(item->bg_color)); + } + } else if (isSelected(index)) { + auto color = resize_sig_ ? toImU32(resize_sig_->color) : paletteHighlight(); + painter->AddRectFilled(rect.Min, rect.Max, color); + pen = paletteBrightText(); + } else if (!hasSelection() || std::find(item->sigs.begin(), item->sigs.end(), resize_sig_) == item->sigs.end()) { // not resizing + if (item->sigs.size() > 0) { + for (auto &s : item->sigs) { + if (s == hovered_sig_) { + painter->AddRectFilled(rect.Min, rect.Max, toImU32(s->color.darker(125))); // 4/5x brightness + } else { + drawSignalCell(painter, rect, index, s); + } + } + } else if (item->valid && item->bg_color.alpha() > 0) { + painter->AddRectFilled(rect.Min, rect.Max, toImU32(item->bg_color)); + } + bool bright = std::find(item->sigs.begin(), item->sigs.end(), hovered_sig_) != item->sigs.end(); + pen = bright ? paletteBrightText() : paletteText(is_message_active_); + } + + if (item->sigs.size() > 1) { + fillDense7Pattern(painter, rect, DARK_GRAY); + } else if (!item->valid) { + fillBDiagPattern(painter, rect, DARK_GRAY); + } + if (item->valid) { + if (index.column == HEX_COLUMN) { + drawBoldText(painter, rect, utils::hexByte(item->val), pen, font, font_size); + } else { + drawText(painter, rect, item->val ? "1" : "0", pen, font, font_size); + } + } + if (item->is_msb || item->is_lsb) { + const ImRect marker_rect(rect.Min, ImVec2(rect.Max.x - 8, rect.Max.y - 3)); + drawText(painter, marker_rect, item->is_msb ? "M" : "L", pen, nullptr, SMALL_FONT_SIZE, ImVec2(1.0f, 1.0f)); + } +} + +// Draw border on edge of signal +void BinaryView::drawSignalCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index, const cabana::Signal *sig) const { + bool draw_left = !hasSignal(index, -1, 0, sig); + bool draw_top = !hasSignal(index, 0, -1, sig); + bool draw_right = !hasSignal(index, 1, 0, sig); + bool draw_bottom = !hasSignal(index, 0, 1, sig); + + const int spacing = 2; + ImRect rc(rect.Min.x + draw_left * 3, rect.Min.y + draw_top * spacing, rect.Max.x - draw_right * 3, rect.Max.y - draw_bottom * spacing); + std::vector subtract; + if (!draw_top) { + if (!draw_left && !hasSignal(index, -1, -1, sig)) { + subtract.emplace_back(rc.Min.x, rc.Min.y, rc.Min.x + 3, rc.Min.y + spacing); + } else if (!draw_right && !hasSignal(index, 1, -1, sig)) { + subtract.emplace_back(rc.Max.x - 3, rc.Min.y, rc.Max.x, rc.Min.y + spacing); + } + } + if (!draw_bottom) { + if (!draw_left && !hasSignal(index, -1, 1, sig)) { + subtract.emplace_back(rc.Min.x, rc.Max.y - spacing, rc.Min.x + 3, rc.Max.y); + } else if (!draw_right && !hasSignal(index, 1, 1, sig)) { + subtract.emplace_back(rc.Max.x - 3, rc.Max.y - spacing, rc.Max.x, rc.Max.y); + } + } + // rc split into horizontal bands with the notch corners removed: at most one notch in the top band and + // one in the bottom band + const ImRect *top_notch = !subtract.empty() && subtract.front().Min.y == rc.Min.y ? &subtract.front() : nullptr; + const ImRect *bottom_notch = !subtract.empty() && subtract.back().Min.y != rc.Min.y ? &subtract.back() : nullptr; + std::vector region; + auto band = [&](const ImRect *notch, float y0, float y1) { + const float x0 = notch && notch->Min.x == rc.Min.x ? notch->Max.x : rc.Min.x; + const float x1 = notch && notch->Min.x != rc.Min.x ? notch->Min.x : rc.Max.x; + if (x1 > x0 && y1 > y0) region.emplace_back(x0, y0, x1, y1); + }; + if (top_notch) band(top_notch, rc.Min.y, rc.Min.y + spacing); + band(nullptr, rc.Min.y + (top_notch ? spacing : 0), rc.Max.y - (bottom_notch ? spacing : 0)); + if (bottom_notch) band(bottom_notch, rc.Max.y - spacing, rc.Max.y); + + auto item = &cellAt(index); + CabanaColor color = sig->color; + color.a = item->bg_color.alpha(); + const ImU32 edge = toImU32(sig->color.darker(125)); + + for (const ImRect &clip : region) { + painter->PushClipRect(clip.Min, clip.Max, true); + // mix the signal color with the background to fade it + painter->AddRectFilled(rc.Min, rc.Max, paletteBase()); + painter->AddRectFilled(rc.Min, rc.Max, toImU32(color)); + + if (draw_left) painter->AddLine(ImVec2(rc.Min.x + 0.5f, rc.Min.y), ImVec2(rc.Min.x + 0.5f, rc.Max.y), edge, 1.0f); + if (draw_right) painter->AddLine(ImVec2(rc.Max.x - 0.5f, rc.Min.y), ImVec2(rc.Max.x - 0.5f, rc.Max.y), edge, 1.0f); + if (draw_bottom) painter->AddLine(ImVec2(rc.Min.x, rc.Max.y - 0.5f), ImVec2(rc.Max.x, rc.Max.y - 0.5f), edge, 1.0f); + if (draw_top) painter->AddLine(ImVec2(rc.Min.x, rc.Min.y + 0.5f), ImVec2(rc.Max.x, rc.Min.y + 0.5f), edge, 1.0f); + + // fill gaps inside corners: the 2px stroke is clipped to the region, only the half outside the notch is painted + for (auto &r : subtract) { + painter->AddRect(r.Min, r.Max, edge, 0.0f, 0, 2.0f); + } + painter->PopClipRect(); + } +} diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.h b/openpilot/tools/cabana/ui/widgets/binaryview.h new file mode 100644 index 0000000000..1ca3c17bab --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/binaryview.h @@ -0,0 +1,98 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/core/observable.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" + +// a (row, column) of the bit grid: 8 bit columns and the hex column +struct BinaryIndex { + int row = -1; + int column = -1; + bool isValid() const { return row >= 0 && column >= 0; } + bool operator==(const BinaryIndex &o) const { return row == o.row && column == o.column; } + bool operator<(const BinaryIndex &o) const { return std::tie(row, column) < std::tie(o.row, o.column); } +}; + +class BinaryView { +public: + static constexpr int COLUMN_COUNT = 9; + static constexpr int HEX_COLUMN = 8; + + BinaryView(); + void setMessage(const MessageId &message_id); + void highlight(const cabana::Signal *sig); + std::set getOverlappingSignals() const; + void updateState(); + // draws inline into the current (scrollable) window and handles the mouse/keyboard + void draw(); + ImVec2 minimumSizeHint() const; + void setHeatmapLiveMode(bool live) { heatmap_live_mode_ = live; updateState(); } + std::string whatsThis() const; + + Observable signalClicked; + Observable signalHovered; + Observable editSignal; + Observable showChart; + +private: + struct Cell { + CabanaColor bg_color = CabanaColor(102, 86, 169, 255); + bool is_msb = false; + bool is_lsb = false; + uint8_t val; + std::vector sigs; + bool valid = false; + }; + + void refresh(); // rebuilds the grid from the DBC message + void setCell(int row, int col, uint8_t val, const CabanaColor &color); + const std::vector> &bitFlipChanges(size_t msg_size); + Cell &cellAt(const BinaryIndex &index) { return cells_[index.row * COLUMN_COUNT + index.column]; } + const Cell &cellAt(const BinaryIndex &index) const { return cells_[index.row * COLUMN_COUNT + index.column]; } + + void addShortcuts(); // polled every frame from draw() + std::tuple getSelection(BinaryIndex index); + void setSelection(); + void handleMousePress(const ImVec2 &pos); + void handleMouseMove(const ImVec2 &pos); + void handleMouseRelease(const ImVec2 &pos); + void highlightPosition(const ImVec2 &pt); + BinaryIndex indexAt(const ImVec2 &pos) const; + ImRect visualRect(const BinaryIndex &index) const; + bool hasSelection() const { return !selection_.empty(); } + bool isSelected(const BinaryIndex &index) const { return selection_.count(index) > 0; } + + void paintCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index) const; + bool hasSignal(const BinaryIndex &index, int dx, int dy, const cabana::Signal *sig) const; + void drawSignalCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index, const cabana::Signal *sig) const; + + MessageId msg_id_; + std::vector cells_; + int row_count_ = 0; + bool heatmap_live_mode_ = true; + struct BitFlipTracker { + std::optional> time_range; + std::vector> flip_counts; + } bit_flip_tracker_; + + BinaryIndex anchor_index_; + ImVec2 last_mouse_pos_{-1, -1}; + bool is_message_active_ = false; + const cabana::Signal *resize_sig_ = nullptr; + const cabana::Signal *hovered_sig_ = nullptr; + std::set selection_; + ImVec2 grid_pos_; // viewport origin of the current frame + float column_width_ = 0; // stretched section size + bool under_mouse_ = false; + bool scroll_to_top_ = false; + Connections connections_; +}; diff --git a/openpilot/tools/cabana/ui/widgets/cameraview.cc b/openpilot/tools/cabana/ui/widgets/cameraview.cc new file mode 100644 index 0000000000..78ca15116b --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/cameraview.cc @@ -0,0 +1,176 @@ +#include "tools/cabana/ui/widgets/cameraview.h" + +#include +#include +#include + +#include +#include "imgui_impl_opengl3_loader.h" + +#include "common/yuv.h" +#include "tools/cabana/utils/util.h" + +namespace { +constexpr GLenum GL_LINEAR_MIPMAP_LINEAR_ = 0x2703; +// glGenerateMipmap is not part of the imgui GL loader +void generateMipmap() { + static auto fn = (void (*)(GLenum))glfwGetProcAddress("glGenerateMipmap"); + if (fn) fn(GL_TEXTURE_2D); +} +} // namespace + +void GlTexture::upload(const RgbImage &image) { + if (id == 0) { + glGenTextures(1, &id); + glBindTexture(GL_TEXTURE_2D, id); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, mipmap ? GL_LINEAR_MIPMAP_LINEAR_ : GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + } else { + glBindTexture(GL_TEXTURE_2D, id); + } + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); + if (width != image.width || height != image.height) { + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, image.width, image.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, image.data.data()); + width = image.width; + height = image.height; + } else { + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, image.data.data()); + } + if (mipmap) generateMipmap(); + glBindTexture(GL_TEXTURE_2D, 0); +} + +void GlTexture::destroy() { + if (id != 0) { + glDeleteTextures(1, &id); + } + id = 0; + width = height = 0; + key = 0; +} + +CameraWidget::CameraWidget(std::string stream_name, VisionStreamType type) + : stream_name_(stream_name), active_stream_type_(type), requested_stream_type_(type) {} + +CameraWidget::~CameraWidget() { + stopVipcThread(); +} + +void CameraWidget::startVipcThread() { + if (!vipc_thread_.joinable()) { + clearFrames(); + vipc_exit_ = false; + vipc_thread_ = std::thread(&CameraWidget::vipcThread, this); + } +} + +void CameraWidget::stopVipcThread() { + vipc_exit_ = true; + if (vipc_thread_.joinable()) { + vipc_thread_.join(); + } +} + +void CameraWidget::setVisible(bool visible) { + if (visible == visible_) return; + visible_ = visible; + visible ? startVipcThread() : stopVipcThread(); +} + +void CameraWidget::draw(const ImVec2 &size) { + setVisible(true); + ImGui::InvisibleButton("##camera", ImVec2(std::max(1.0f, size.x), std::max(1.0f, size.y)), + ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight | ImGuiButtonFlags_MouseButtonMiddle); + rect_ = ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + paint(); + if (ImGui::IsItemDeactivated()) clicked(); +} + +float CameraWidget::frameAspectRatio() const { + if (frame_texture_.width > 0 && frame_texture_.height > 0) { + return (float)frame_texture_.width / frame_texture_.height; + } + return 1928.0f / 1208.0f; // the road camera, until the first frame arrives +} + +void CameraWidget::paint() { + ImDrawList *p = ImGui::GetWindowDrawList(); + p->AddRectFilled(rect_.Min, rect_.Max, bg_); + + std::lock_guard lk(frame_lock_); + if (rgb_frame_.isNull()) return; + if (frame_updated_) { + frame_texture_.upload(rgb_frame_); + frame_updated_ = false; + } + + // Scale for aspect ratio + float widget_ratio = (float)width() / height(); + float frame_ratio = (float)rgb_frame_.width / rgb_frame_.height; + int w = std::lround(width() * std::min(frame_ratio / widget_ratio, 1.0f)); + int h = std::lround(height() * std::min(widget_ratio / frame_ratio, 1.0f)); + ImVec2 video_min(rect_.Min.x + (int)(width() - w) / 2, rect_.Min.y + (int)(height() - h) / 2); + ImVec2 video_max(video_min.x + w, video_min.y + h); + + ImVec2 uv0(0, 0), uv1(1, 1); + if (active_stream_type_ == VISION_STREAM_CABIN) { + // mirror cabin camera horizontally + uv0.x = 1; + uv1.x = 0; + } + p->AddImage(frame_texture_.ref(), video_min, video_max, uv0, uv1); +} + +void CameraWidget::vipcThread() { + VisionStreamType cur_stream = requested_stream_type_; + std::unique_ptr vipc_client; + VisionIpcBufExtra frame_meta = {}; + + while (!vipc_exit_) { + if (!vipc_client || cur_stream != requested_stream_type_) { + clearFrames(); + cur_stream = requested_stream_type_; + vipc_client.reset(new VisionIpcClient(stream_name_, cur_stream, false)); + } + active_stream_type_ = cur_stream; + + if (!vipc_client->connected) { + clearFrames(); + auto streams = VisionIpcClient::getAvailableStreams(stream_name_, false); + if (streams.empty()) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + continue; + } + utils::runOnMainThread(utils::guarded(alive_, [this, streams]() { availableStreamsUpdated(streams); })); + + if (!vipc_client->connect(false)) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + continue; + } + } + + if (VisionBuf *buf = vipc_client->recv(&frame_meta, 100)) { + // NV12 -> RGBA once per frame on the receive thread; paint just draws the image + if (rgb_back_.width != (int)buf->width || rgb_back_.height != (int)buf->height) { + rgb_back_.resize(buf->width, buf->height); + } + yuv::nv12_to_rgba(buf->y, buf->stride, buf->uv, buf->stride, + rgb_back_.data.data(), rgb_back_.bytesPerLine(), buf->width, buf->height); + { + std::lock_guard lk(frame_lock_); + rgb_frame_.swap(rgb_back_); + frame_updated_ = true; + } + } + } +} + +void CameraWidget::clearFrames() { + std::lock_guard lk(frame_lock_); + rgb_frame_.reset(); + rgb_back_.reset(); + frame_updated_ = false; +} diff --git a/openpilot/tools/cabana/ui/widgets/cameraview.h b/openpilot/tools/cabana/ui/widgets/cameraview.h new file mode 100644 index 0000000000..381e13dc50 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/cameraview.h @@ -0,0 +1,88 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "openpilot/cereal/visionstream.h" +#include "tools/cabana/core/observable.h" +#include "msgq/visionipc/visionipc_client.h" + +// tightly packed RGBA pixels +struct RgbImage { + int width = 0; + int height = 0; + std::vector data; + bool isNull() const { return data.empty(); } + void reset() { width = height = 0; data.clear(); } + void resize(int w, int h) { width = w; height = h; data.resize(size_t(w) * h * 4); } + int bytesPerLine() const { return width * 4; } + void swap(RgbImage &other) { std::swap(width, other.width); std::swap(height, other.height); data.swap(other.data); } +}; + +// GL texture holding an RgbImage. Created/uploaded/freed on the GUI thread only (the GL context is current there). +struct GlTexture { + GlTexture() = default; + GlTexture(const GlTexture &) = delete; + GlTexture &operator=(const GlTexture &) = delete; + ~GlTexture() { destroy(); } + void upload(const RgbImage &image); // (re)allocates when the size changes + void destroy(); + ImTextureRef ref() const { return ImTextureRef((ImTextureID)(uintptr_t)id); } + + unsigned int id = 0; + int width = 0; + int height = 0; + uint64_t key = 0; // caller-defined identity of the uploaded image + bool mipmap = false; // set before the first upload: a mip chain for images drawn downscaled +}; + +class CameraWidget { +public: + explicit CameraWidget(std::string stream_name, VisionStreamType stream_type); + ~CameraWidget(); + void setStreamType(VisionStreamType type) { requested_stream_type_ = type; } + void stopVipcThread(); + // draw() implies visible; the owner calls setVisible(false) when the widget is no longer drawn, which + // stops the vipc thread. + void setVisible(bool visible); + // draws an item of `size` into the current window + void draw(const ImVec2 &size); + const ImRect &rect() const { return rect_; } + float frameAspectRatio() const; + float width() const { return rect_.GetWidth(); } + float height() const { return rect_.GetHeight(); } + + Observable<> clicked; + Observable> availableStreamsUpdated; // invoked on the main thread + +private: + void paint(); + void startVipcThread(); + void vipcThread(); + void clearFrames(); + + ImU32 bg_ = IM_COL32(0, 0, 0, 255); + RgbImage rgb_frame_; // written by vipc thread, drawn by GUI thread; guarded by frame_lock_ + RgbImage rgb_back_; // vipc thread only + bool frame_updated_ = false; // rgb_frame_ changed since the last upload; guarded by frame_lock_ + GlTexture frame_texture_; // GUI thread only + ImRect rect_; + bool visible_ = false; + + std::string stream_name_; + std::atomic active_stream_type_; + std::atomic requested_stream_type_; + std::thread vipc_thread_; + std::atomic vipc_exit_ = false; + std::mutex frame_lock_; + std::shared_ptr alive_ = std::make_shared(true); +}; diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc new file mode 100644 index 0000000000..ebb74ffa4a --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -0,0 +1,439 @@ +#include "tools/cabana/ui/widgets/detailwidget.h" + +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/commands.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" + +namespace { + +bool iequals(const std::string &a, const std::string &b) { + return a.size() == b.size() && + std::equal(a.begin(), a.end(), b.begin(), [](char x, char y) { return std::tolower((unsigned char)x) == std::tolower((unsigned char)y); }); +} + +} // namespace + +ElidedLabel::ElidedLabel(const std::string &text) : text_(utils::trimmed(text)) {} + +void ElidedLabel::draw(float width) { + ImGuiWindow *window = ImGui::GetCurrentWindow(); + const ImVec2 pos(window->DC.CursorPos.x, window->DC.CursorPos.y + window->DC.CurrLineTextBaseOffset); + const ImRect bb(pos, ImVec2(pos.x + width, pos.y + ImGui::GetTextLineHeight())); + ImGui::ItemSize(bb.GetSize(), 0.0f); + if (ImGui::ItemAdd(bb, 0)) { + ImGui::RenderTextEllipsis(window->DrawList, bb.Min, bb.Max, bb.Max.x, text_.c_str(), nullptr, nullptr); + } + if (!tooltip_.empty()) ImGui::SetItemTooltip("%s", tooltip_.c_str()); + if (ImGui::IsItemHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + clicked(); + } +} + +DetailWidget::DetailWidget(ChartsWidget *charts) : charts_(charts) { + tabbar_.setUsesScrollButtons(true); + tabbar_.setAutoHide(true); + tabbar_.setTabsClosable(true); + connections_.push_back(tabbar_.currentChanged.connect([this](int index) { + if (index >= 0) setMessage(MessageId::fromString(tabbar_.tabText(index))); + })); + connections_.push_back(tabbar_.tabCloseRequested.connect([this](int index) { tabbar_.removeTab(index); })); + connections_.push_back(tabbar_.tabContextMenu.connect([this](int index) { showTabBarContextMenu(index); })); + binary_view_ = std::make_unique(); + signal_view_ = std::make_unique(charts); + + history_log_ = std::make_unique(); + + connections_.push_back(binary_view_->signalHovered.connect([this](const cabana::Signal *s) { signal_view_->signalHovered(s); })); + connections_.push_back(binary_view_->signalClicked.connect([this](const cabana::Signal *s) { signal_view_->selectSignal(s, true); })); + connections_.push_back(binary_view_->editSignal.connect([this](const cabana::Signal *origin_s, cabana::Signal &s) { signal_view_->saveSignal(origin_s, s); })); + connections_.push_back(binary_view_->showChart.connect([this](const MessageId &id, const cabana::Signal *sig, bool show, bool merge) { charts_->showChart(id, sig, show, merge); })); + connections_.push_back(signal_view_->showChart.connect([this](const MessageId &id, const cabana::Signal *sig, bool show, bool merge) { charts_->showChart(id, sig, show, merge); })); + connections_.push_back(signal_view_->highlight.connect([this](const cabana::Signal *sig) { binary_view_->highlight(sig); })); + connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool) { updateState(msgs); })); + connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { refresh(); })); + connections_.push_back(charts->seriesChanged.connect([this]() { signal_view_->updateChartState(); })); + connections_.push_back(can->timeRangeChanged.connect([this](const std::optional> &range) { + char text[64]; + if (range) snprintf(text, sizeof(text), "%.3f - %.3f", range->first, range->second); + heatmap_all_text_ = range ? text : "All"; + const bool live = !range; + if (std::exchange(heatmap_live_, live) != live) binary_view_->setHeatmapLiveMode(live); + })); +} + +void DetailWidget::drawToolBar() { + const ImGuiStyle &style = ImGui::GetStyle(); + auto radio_width = [&](const char *label) { return ImGui::GetFrameHeight() + style.ItemInnerSpacing.x + ImGui::CalcTextSize(label).x; }; + auto button_width = [&](const char *label) { return ImGui::CalcTextSize(label).x + style.FramePadding.x * 2; }; + const float right_width = ImGui::CalcTextSize("Heatmap:").x + style.ItemSpacing.x + radio_width("Live") + style.ItemSpacing.x + + radio_width(heatmap_all_text_.c_str()) + style.ItemSpacing.x * 3 + 1.0f + + button_width(icon::PENCIL) + style.ItemSpacing.x + button_width(icon::X_LG); + const float avail = ImGui::GetContentRegionAvail().x; + + ImGui::AlignTextToFramePadding(); + pushBoldFont(); + name_label_.draw(std::max(1.0f, avail - right_width - style.ItemSpacing.x)); + popBoldFont(); + + alignRight(right_width); + ImGui::TextUnformatted("Heatmap:"); + ImGui::SameLine(); + if (ImGui::RadioButton("Live##heatmap_live_", heatmap_live_) && !heatmap_live_) { + heatmap_live_ = true; + binary_view_->setHeatmapLiveMode(true); + } + ImGui::SameLine(); + if (ImGui::RadioButton((heatmap_all_text_ + "##heatmap_all").c_str(), !heatmap_live_) && heatmap_live_) { + heatmap_live_ = false; + binary_view_->setHeatmapLiveMode(false); + } + + ImGui::SameLine(); + ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); + ImGui::SameLine(); + if (ImGui::Button(icon::PENCIL)) editMsg(); + ImGui::SetItemTooltip("Edit Message"); + ImGui::SameLine(); + ImGui::BeginDisabled(!action_remove_msg_enabled_); + if (ImGui::Button(icon::X_LG)) UndoStack::instance()->push(new RemoveMsgCommand(msg_id_)); + ImGui::EndDisabled(); + disabledItemTooltip("Remove Message"); +} + +void DetailWidget::showTabBarContextMenu(int index) { + if (ImGui::BeginPopupContextItem()) { + if (ImGui::MenuItem("Close Other Tabs")) { + tabbar_.moveTab(index, 0); + tabbar_.setCurrentIndex(0); + while (tabbar_.count() > 1) tabbar_.removeTab(1); + } + ImGui::EndPopup(); + } +} + +int DetailWidget::findOrAddTab(const MessageId &message_id) { + const std::string text = message_id.toString(); + int index = tabbar_.count() - 1; + for (/**/; index >= 0; --index) { + if (tabbar_.tabText(index) == text) break; + } + if (index == -1) { + index = tabbar_.addTab(text); + tabbar_.setTabToolTip(index, msgName(message_id)); + } + return index; +} + +void DetailWidget::setMessage(const MessageId &message_id) { + if (std::exchange(msg_id_, message_id) == message_id) return; + + tabbar_.setCurrentIndex(findOrAddTab(message_id)); + + signal_view_->setMessage(msg_id_); + binary_view_->setMessage(msg_id_); + history_log_->setMessage(msg_id_); + refresh(); +} + +std::pair> DetailWidget::serializeMessageIds() const { + std::vector msgs; + for (int i = 0; i < tabbar_.count(); ++i) msgs.push_back(tabbar_.tabText(i)); + return std::make_pair(msg_id_.toString(), msgs); +} + +void DetailWidget::restoreTabs(const std::string &active_msg_id, const std::vector& msg_ids) { + for (const auto& str_id : msg_ids) { + MessageId id = MessageId::fromString(str_id); + if (dbc()->msg(id) != nullptr) + findOrAddTab(id); + } + + auto active_id = MessageId::fromString(active_msg_id); + if (dbc()->msg(active_id) != nullptr) + setMessage(active_id); +} + +void DetailWidget::refresh() { + std::vector warnings; + auto msg = dbc()->msg(msg_id_); + if (msg) { + if (msg_id_.source == INVALID_SOURCE) { + warnings.push_back("No messages received."); + } else if (msg->size != can->lastMessage(msg_id_).dat.size()) { + warnings.push_back("Message size (" + std::to_string(msg->size) + ") is incorrect."); + } + for (auto s : binary_view_->getOverlappingSignals()) { + warnings.push_back(s->name + " has overlapping bits."); + } + } + std::string msg_name = msg ? msg->name + " (" + msg->transmitter + ")" : msgName(msg_id_); + name_label_.setText(msg_name); + name_label_.setToolTip(msg_name); + action_remove_msg_enabled_ = msg != nullptr; + + if (!warnings.empty()) { + warning_label_.clear(); + for (size_t i = 0; i < warnings.size(); ++i) { + if (i) warning_label_ += '\n'; + warning_label_ += warnings[i]; + } + warning_icon_ = msg ? icon::EXCLAMATION_TRIANGLE : icon::INFO_CIRCLE; + } + warning_widget_visible_ = !warnings.empty(); +} + +void DetailWidget::updateState(const std::set *msgs) { + if ((msgs && !msgs->count(msg_id_))) + return; + + if (tab_widget_index_ == 0) + binary_view_->updateState(); + else + history_log_->updateState(); +} + +void DetailWidget::editMsg() { + auto msg = dbc()->msg(msg_id_); + int size = msg ? msg->size : can->lastMessage(msg_id_).dat.size(); + edit_dlg_ = std::make_unique(msg_id_, msgName(msg_id_), size, ImGui::GetWindowWidth()); +} + +void DetailWidget::drawTabWidget() { + // the pages first, the tab bar below them + const float tab_height = ImGui::GetFrameHeight(); + const float content_height = ImGui::GetContentRegionAvail().y - tab_height - ImGui::GetStyle().ItemSpacing.y; + ImGui::BeginChild("tab_widget", ImVec2(0, std::max(content_height, 1.0f)), ImGuiChildFlags_None, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + if (tab_widget_index_ == 0) { + // binary_view_ keeps its size hint, signal_view_ takes the rest + const float min_height = binary_view_->minimumSizeHint().y; + const float avail = ImGui::GetContentRegionAvail().y; + const float max_height = std::max(avail - 6.0f - ImGui::GetStyle().ItemSpacing.y * 2 - 1.0f, 1.0f); + const float height = std::clamp(min_height, 1.0f, max_height); + ImGui::BeginChild("binary_view", ImVec2(0, height)); + binary_view_rect_ = ImGui::GetCurrentWindow()->Rect(); + binary_view_->draw(); + ImGui::EndChild(); + ImGui::Dummy(ImVec2(0.0f, 6.0f)); + const float spacing = ImGui::GetStyle().ItemSpacing.y; + const ImRect child_rect = ImGui::GetCurrentWindow()->Rect(); + ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(child_rect.Min.x, ImGui::GetItemRectMin().y - spacing), + ImVec2(child_rect.Max.x, ImGui::GetItemRectMax().y + spacing), + ImGui::GetColorU32(ImGuiCol_WindowBg)); + ImGui::BeginChild("signal_view", ImVec2(0, 0)); + signal_view_rect_ = ImGui::GetCurrentWindow()->Rect(); + signal_view_->draw(); + ImGui::EndChild(); + } else { + history_log_->draw(); + } + ImGui::EndChild(); + + const std::string labels[] = {std::string(icon::FILE_EARMARK_RULED) + " Messages", std::string(icon::STOPWATCH) + " Logs"}; + // the tabs are centered in the bar: the bar itself starts at the first tab, so its separator only spans the + // tabs and the full width one is drawn underneath it + const ImGuiStyle &style = ImGui::GetStyle(); + float tabs_width = 0.0f; + for (int i = 0; i < 2; ++i) { + tabs_width += ImGui::TabItemCalcSize(labels[i].c_str(), false).x + (i ? style.ItemInnerSpacing.x : 0.0f); + } + ImGuiWindow *window = ImGui::GetCurrentWindow(); + const float separator_y = ImGui::GetCursorScreenPos().y + ImGui::GetFrameHeight() - 1.0f; + window->DrawList->AddLine(ImVec2(window->WorkRect.Min.x, separator_y), ImVec2(window->WorkRect.Max.x, separator_y), + ImGui::GetColorU32(ImGuiCol_TabSelected), style.TabBarBorderSize); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (ImGui::GetContentRegionAvail().x - tabs_width) * 0.5f)); + + if (ImGui::BeginTabBar("tab_widget_tabs")) { + for (int i = 0; i < 2; ++i) { + if (ImGui::BeginTabItem(labels[i].c_str())) { + if (tab_widget_index_ != i) { + tab_widget_index_ = i; + if (i == 1) history_log_->onShown(); + updateState(); + } + ImGui::EndTabItem(); + } + } + ImGui::EndTabBar(); + } +} + +void DetailWidget::draw() { + tabbar_.draw(); + drawToolBar(); + + if (warning_widget_visible_) { + ImGui::TextUnformatted(warning_icon_); + ImGui::SameLine(); + ImGui::TextUnformatted(warning_label_.c_str()); + } + + drawTabWidget(); + + if (edit_dlg_ && !edit_dlg_->draw()) { + if (edit_dlg_->accepted()) { + const auto r = edit_dlg_->result(); + UndoStack::instance()->push(new EditMsgCommand(r.msg_id, r.name, r.size, r.node, r.comment)); + } + edit_dlg_.reset(); + } +} + +// HelpOverlay: the whatsThis text and last drawn rect of the binary view and the signal view +std::vector> DetailWidget::helpRects() const { + std::vector> rects; + if (tab_widget_index_ == 0) { + rects.emplace_back(binary_view_->whatsThis(), binary_view_rect_); + rects.emplace_back(signal_view_->whatsThis(), signal_view_rect_); + } + return rects; +} + +EditMessageDialog::EditMessageDialog(const MessageId &msg_id, const std::string &title, int size, float parent_width) + : msg_id_(msg_id), original_name_(title), name_edit_(title), size_spin_(size), width_(parent_width * 0.9f) { + window_title_ = "Edit message: " + msg_id.toString(); + + if (auto msg = dbc()->msg(msg_id)) { + node_ = msg->transmitter; + comment_edit_ = msg->comment; + } + validateName(name_edit_); +} + +EditMessageDialog::Result EditMessageDialog::result() const { + return {msg_id_, utils::trimmed(name_edit_), utils::trimmed(node_), utils::trimmed(comment_edit_), size_spin_}; +} + +bool EditMessageDialog::draw() { + if (closed_) return false; + if (!opened_) { + ImGui::OpenPopup(window_title_.c_str()); + opened_ = true; + } + setNextDialogWindow(ImVec2(0.0f, 0.0f)); + ImGui::SetNextWindowSize(ImVec2(width_, 0.0f), ImGuiCond_Always); // fixed width, the height fits the form + bool open = true; + if (ImGui::BeginPopupModal(window_title_.c_str(), &open)) { + const float label_width = ImGui::CalcTextSize("Comment").x + ImGui::GetStyle().ItemSpacing.x * 2; + auto row = [&](const char *label) { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(label); + ImGui::SameLine(label_width); + ImGui::SetNextItemWidth(-FLT_MIN); + }; + + if (!error_label_.empty()) { + row(""); + ImGui::TextUnformatted(error_label_.c_str()); + } + row("Name"); + if (validatedInput("##name", &name_edit_, nameValidator)) { + validateName(name_edit_); + } + + row("Size"); + if (ImGui::InputInt("##size", &size_spin_)) size_spin_ = std::clamp(size_spin_, 1, CAN_MAX_DATA_BYTES); + + row("Node"); + validatedInput("##node", &node_, nameValidator); + row("Comment"); + inputTextMultiline("##comment", &comment_edit_, ImVec2(-FLT_MIN, 192.0f)); + const bool comment_active = ImGui::IsItemActive(); + + bool accept = false, reject = false; + if (dialogButtons("OK", &accept, &reject, ok_enabled_)) { + accepted_ = accept; + closed_ = true; + } + // Enter triggers the default (OK) button + if (!closed_ && ok_enabled_ && !comment_active && ImGui::IsKeyPressed(ImGuiKey_Enter, false)) { + accepted_ = true; + closed_ = true; + } + if (!open) closed_ = true; + if (closed_) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } else { + closed_ = true; // closed from outside + } + return !closed_; +} + +void EditMessageDialog::validateName(const std::string &text) { + bool valid = !iequals(text, UNTITLED); + error_label_.clear(); + if (!text.empty() && valid && text != original_name_) { + valid = dbc()->msg(msg_id_.source, text) == nullptr; + if (!valid) error_label_ = "Name already exists"; + } + ok_enabled_ = valid; +} + +DetailWidget* CenterWidget::ensureDetailWidget() { + if (!detail_widget) { + detail_widget = std::make_unique(charts_); + } + return detail_widget.get(); +} + +void CenterWidget::clear() { + detail_widget.reset(); + charts_ = nullptr; // MainWindow recreates the ChartsWidget after startStream +} + +void CenterWidget::draw() { + if (detail_widget) { + detail_widget->draw(); + } else { + drawWelcomeWidget(); + } +} + +void CenterWidget::drawWelcomeWidget() { + const ImVec2 win_pos = ImGui::GetWindowPos(), win_size = ImGui::GetWindowSize(); + ImGui::GetWindowDrawList()->AddRectFilled(win_pos, ImVec2(win_pos.x + win_size.x, win_pos.y + win_size.y), ImGui::GetColorU32(ImGuiCol_ChildBg)); + + const ImVec2 avail = ImGui::GetContentRegionAvail(); + const ImVec2 origin = ImGui::GetCursorPos(); + auto centered = [&](const char *text, float y) { + const ImVec2 size = ImGui::CalcTextSize(text); + ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - size.x) * 0.5f, y)); + ImGui::TextUnformatted(text); + }; + ImGui::PushStyleColor(ImGuiCol_Text, colorRgb(169, 169, 169)); + float y = origin.y + avail.y * 0.5f - 90.0f; + pushLargeFont(); + centered("CABANA", y); + y += ImGui::GetTextLineHeightWithSpacing(); + popLargeFont(); + + auto newShortcutRow = [&](const char *title, const char *key) { + const float w = ImGui::CalcTextSize(title).x + ImGui::CalcTextSize(key).x + 40.0f; + ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - w) * 0.5f, y)); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(title); + ImGui::SameLine(); + ImGui::BeginDisabled(); + ImGui::SmallButton(key); + ImGui::EndDisabled(); + y += ImGui::GetFrameHeightWithSpacing(); + }; + + centered("<-Select a message to view details", y); + y += ImGui::GetTextLineHeightWithSpacing(); + newShortcutRow("Pause", "Space"); + newShortcutRow("Help", "F1"); + newShortcutRow("WhatsThis", "Shift+F1"); + ImGui::PopStyleColor(); +} diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.h b/openpilot/tools/cabana/ui/widgets/detailwidget.h new file mode 100644 index 0000000000..08d3d7db41 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.h @@ -0,0 +1,112 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "tools/cabana/ui/widgets/binaryview.h" +#include "tools/cabana/ui/chart/chartswidget.h" +#include "tools/cabana/ui/widgets/historylog.h" +#include "tools/cabana/ui/widgets/signalview.h" +#include "tools/cabana/ui/widgets/tabbar.h" + +// a label that elides its text to the available width +class ElidedLabel { +public: + explicit ElidedLabel(const std::string &text = {}); + void setText(const std::string &text) { text_ = text; } + void setToolTip(const std::string &tip) { tooltip_ = tip; } + void draw(float width); + + Observable<> clicked; + +private: + std::string text_, tooltip_; +}; + +// modal, non-blocking: DetailWidget::draw() polls draw() and applies the result once it returns false +class EditMessageDialog { +public: + struct Result { + MessageId msg_id; + std::string name, node, comment; // trimmed + int size; + }; + + EditMessageDialog(const MessageId &msg_id, const std::string &title, int size, float parent_width); + bool draw(); // false once closed + bool accepted() const { return accepted_; } + Result result() const; + +private: + void validateName(const std::string &text); + + MessageId msg_id_; + std::string original_name_; + std::string name_edit_; + std::string node_; + std::string comment_edit_; + std::string error_label_; // empty when the name is valid + int size_spin_; + bool ok_enabled_ = true; + std::string window_title_; + float width_; + bool opened_ = false; + bool accepted_ = false; + bool closed_ = false; +}; + +class DetailWidget { +public: + DetailWidget(ChartsWidget *charts); + void setMessage(const MessageId &message_id); + void refresh(); + void draw(); // tab bar of message ids, toolbar, warning, Messages/Logs tabs + std::pair> serializeMessageIds() const; + void restoreTabs(const std::string &active_msg_id, const std::vector &msg_ids); + std::vector> helpRects() const; // HelpOverlay: (whatsThis, rect) of the binary and signal views + +private: + void drawToolBar(); + void drawTabWidget(); + int findOrAddTab(const MessageId& message_id); + void showTabBarContextMenu(int index); + void editMsg(); + void updateState(const std::set *msgs = nullptr); + + MessageId msg_id_; + const char *warning_icon_ = nullptr; + std::string warning_label_; + ElidedLabel name_label_; + bool warning_widget_visible_ = false; + TabBar tabbar_; + int tab_widget_index_ = 0; + bool action_remove_msg_enabled_ = false; + bool heatmap_live_ = true; + std::string heatmap_all_text_ = "All"; + ImRect binary_view_rect_, signal_view_rect_; // child window rects of the last drawTabWidget + std::unique_ptr history_log_; + std::unique_ptr binary_view_; + std::unique_ptr signal_view_; + ChartsWidget *charts_; + std::unique_ptr edit_dlg_; + Connections connections_; +}; + +class CenterWidget { +public: + CenterWidget() = default; + void setChartsWidget(ChartsWidget *charts) { charts_ = charts; } + void setMessage(const MessageId &message_id) { ensureDetailWidget()->setMessage(message_id); } + DetailWidget* getDetailWidget() { return detail_widget.get(); } + DetailWidget* ensureDetailWidget(); + void clear(); + void draw(); // the welcome widget until a message is selected, then the DetailWidget + +private: + void drawWelcomeWidget(); + std::unique_ptr detail_widget; + ChartsWidget *charts_ = nullptr; +}; diff --git a/openpilot/tools/cabana/ui/widgets/historylog.cc b/openpilot/tools/cabana/ui/widgets/historylog.cc new file mode 100644 index 0000000000..0a3de51d70 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/historylog.cc @@ -0,0 +1,307 @@ +#include "tools/cabana/ui/widgets/historylog.h" + +#include +#include +#include + +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/dialogs/filedialog.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/ui/widgets/messagebytes.h" +#include "tools/cabana/utils/export.h" +#include "tools/cabana/utils/strings.h" + +namespace { + +constexpr int BATCH_SIZE = 50; +constexpr float DISPLAY_TYPE_WIDTH = 90.0f; +constexpr float SIGNALS_WIDTH = 160.0f; +constexpr float COMPARE_WIDTH = 50.0f; + +std::string formatTime(uint64_t mono_time) { + char buf[32] = {}; + snprintf(buf, sizeof(buf), "%.3f", can->toSeconds(mono_time)); + return buf; +} + +} // namespace + +LogsWidget::LogsWidget() { + connections_.push_back(can->seekedTo.connect([this](double) { reset(); })); + connections_.push_back(dbc()->fileChanged.connect([this]() { reset(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { reset(); })); +} + +void LogsWidget::setMessage(const MessageId &message_id) { + msg_id_ = message_id; + reset(); +} + +void LogsWidget::reset() { + sigs_.clear(); + if (auto dbc_msg = dbc()->msg(msg_id_)) sigs_ = dbc_msg->getSignals(); + messages_.clear(); + hex_colors_ = {}; + signals_cb_ = comp_box_ = 0; + value_edit_.clear(); + value_edit_modified_ = false; + export_btn_enabled_ = false; + selected_row_ = selected_col_ = -1; + setFilter(0, "", nullptr); +} + +void LogsWidget::setFilter(int sig_idx, const std::string &value, std::function cmp) { + filter_sig_idx_ = sig_idx; + filter_value_ = utils::toDouble(value); + filter_cmp_ = value.empty() ? nullptr : cmp; + load(true); +} + +void LogsWidget::load(bool clear) { + if (clear && !messages_.empty()) { + messages_.clear(); + selected_row_ = selected_col_ = -1; + } + const uint64_t current_time = can->toMonoTime(can->lastMessage(msg_id_).ts) + 1; + fetch(messages_.begin(), current_time, messages_.empty() ? 0 : messages_.front().mono_time); +} + +bool LogsWidget::canFetchMore() const { + const auto &events = can->events(msg_id_); + return !events.empty() && !messages_.empty() && messages_.back().mono_time > events.front()->mono_time; +} + +void LogsWidget::fetch(std::deque::iterator insert_pos, uint64_t from_time, uint64_t min_time) { + const auto &events = can->events(msg_id_); + auto first = std::upper_bound(events.rbegin(), events.rend(), from_time, [](uint64_t ts, auto e) { return ts > e->mono_time; }); + + std::vector msgs; + std::vector values(sigs_.size()); + msgs.reserve(BATCH_SIZE); + for (; first != events.rend() && (*first)->mono_time > min_time; ++first) { + const CanEvent *e = *first; + for (int i = 0; i < sigs_.size(); ++i) { + sigs_[i]->getValue(e->dat, e->size, &values[i]); + } + if (!filter_cmp_ || filter_cmp_(values[filter_sig_idx_], filter_value_)) { + msgs.emplace_back(Message{e->mono_time, values, {e->dat, e->dat + e->size}}); + if (msgs.size() >= BATCH_SIZE && min_time == 0) break; + } + } + if (msgs.empty()) return; + + if (hexMode() && (min_time > 0 || messages_.empty())) { + const auto freq = can->lastMessage(msg_id_).freq; + const std::vector no_mask; + for (auto &m : msgs) { + hex_colors_.compute(msg_id_, m.data.data(), m.data.size(), m.mono_time / (double)1e9, can->getSpeed(), no_mask, freq); + m.colors = hex_colors_.colors; + } + } + const int pos = std::distance(messages_.begin(), insert_pos); + messages_.insert(insert_pos, std::move_iterator(msgs.begin()), std::move_iterator(msgs.end())); + export_btn_enabled_ = true; + // the selection follows the message it was made on + if (selected_row_ >= pos) selected_row_ += msgs.size(); +} + +void LogsWidget::filterChanged() { + if (value_edit_.empty() && !value_edit_modified_) return; + + std::function cmp = nullptr; + switch (comp_box_) { + case 0: cmp = std::greater{}; break; + case 1: cmp = std::equal_to{}; break; + case 2: cmp = [](double l, double r) { return l != r; }; break; + case 3: cmp = std::less{}; break; + } + setFilter(signals_cb_, value_edit_, cmp); +} + +void LogsWidget::exportToCSV() { + std::string dir = settings.last_dir + "/" + can->routeName() + "_" + msgName(msg_id_) + ".csv"; + FileDialog::getSaveFileName("Export " + msgName(msg_id_) + " to CSV file", dir, ".csv", [this](const std::string &fn) { + if (!fn.empty()) { + hexMode() ? utils::exportToCSV(fn, msg_id_) : utils::exportSignalsToCSV(fn, msg_id_); + } + }); +} + +void LogsWidget::draw() { + const ImGuiStyle &style = ImGui::GetStyle(); + + // toolbar: the export button is right aligned and never clipped, the value input shrinks first + const float export_w = ImGui::CalcTextSize(icon::FILETYPE_CSV).x + style.FramePadding.x * 2; + if (!sigs_.empty()) { + const float clear_w = value_edit_.empty() ? 0.0f : ImGui::CalcTextSize(icon::X).x + style.FramePadding.x * 2; + const float fixed = DISPLAY_TYPE_WIDTH + SIGNALS_WIDTH + COMPARE_WIDTH + clear_w + style.ItemSpacing.x * 4 + export_w; + const float value_w = std::clamp(ImGui::GetContentRegionAvail().x - fixed, 30.0f, 120.0f); + + ImGui::SetNextItemWidth(DISPLAY_TYPE_WIDTH); + if (ImGui::Combo("##display_type", &display_type_cb_, "Signal\0Hex\0")) { + hex_mode_ = display_type_cb_; + reset(); + } + ImGui::SetItemTooltip("Display signal value or raw hex value"); + ImGui::SameLine(); + std::string sig_items; + for (auto s : sigs_) { + sig_items += s->name; + sig_items += '\0'; + } + sig_items += '\0'; + ImGui::SetNextItemWidth(SIGNALS_WIDTH); + if (ImGui::Combo("##signals", &signals_cb_, sig_items.c_str())) filterChanged(); + ImGui::SameLine(); + ImGui::SetNextItemWidth(COMPARE_WIDTH); + if (ImGui::Combo("##comp", &comp_box_, ">\0=\0!=\0<\0")) filterChanged(); + ImGui::SameLine(); + ImGui::SetNextItemWidth(value_w); + if (clearableInput("##value", &value_edit_, "", doubleValidator)) { + value_edit_modified_ = true; // clearing the field still counts as modified + filterChanged(); + } + } + alignRight(export_w); + ImGui::BeginDisabled(!export_btn_enabled_); + if (ImGui::Button(icon::FILETYPE_CSV)) exportToCSV(); + ImGui::EndDisabled(); + disabledItemTooltip("Export to CSV file..."); + + ImGui::Separator(); + drawTable(); +} + +std::string LogsWidget::headerText(int column) const { + if (column == 0) return "Time"; + if (hexMode()) return "Data"; + std::string text = sigs_[column - 1]->name; + if (!sigs_[column - 1]->unit.empty()) text += " (" + sigs_[column - 1]->unit + ")"; + std::replace(text.begin(), text.end(), '_', ' '); + return text; +} + +ImVec2 LogsWidget::headerSize(int column, float viewport_width) const { + const ImVec2 time_text_size = ImGui::CalcTextSize("000000.000"); + const ImVec2 time_col_size(time_text_size.x + 10, time_text_size.y + 6); + if (column == 0) return time_col_size; + const int default_size = std::max(100, (int)((viewport_width - time_col_size.x) / (columnCount() - 1))); + const ImVec2 rect = ImGui::CalcTextSize(headerText(column).c_str(), nullptr, false, default_size); + return ImVec2{std::max(rect.x + 10, (float)default_size), rect.y + 6}; +} + +void LogsWidget::drawHeaderCell(ImDrawList *dl, const ImRect &rect, int column) const { + if (column > 0 && !hexMode()) { + CabanaColor bg = sigs_[column - 1]->color; + bg.a = 128; + dl->AddRectFilled(rect.Min, rect.Max, toImU32(bg)); + } + const std::string text = headerText(column); + const ImU32 color = isDarkTheme() ? toImU32(DarkTheme::bright_text) : ImGui::GetColorU32(ImGuiCol_Text); + // right aligned and word wrapped, one line at a time + const ImRect r(rect.Min.x + 5, rect.Min.y + 3, rect.Max.x - 5, rect.Max.y - 3); + ImFont *font = ImGui::GetFont(); + const float font_size = ImGui::GetFontSize(); + const float wrap_width = std::max(1.0f, r.GetWidth()); + const char *s = text.c_str(); + const char *end = s + text.size(); + float y = r.Min.y; + dl->PushClipRect(rect.Min, rect.Max, true); + while (s < end) { + const char *line_end = font->CalcWordWrapPosition(font_size, s, end, wrap_width); + if (line_end == s) line_end = s + 1; + const float w = ImGui::CalcTextSize(s, line_end).x; + dl->AddText(font, font_size, ImVec2(r.Max.x - w, y), color, s, line_end); + y += ImGui::GetTextLineHeight(); + s = line_end; + while (s < end && ImCharIsBlankA(*s)) s++; + if (s < end && *s == '\n') s++; + } + dl->PopClipRect(); +} + +void LogsWidget::drawTable() { + const ImGuiStyle &style = ImGui::GetStyle(); + const int cols = columnCount(); + // the header viewport excludes the table's cell padding and the vertical scrollbar (the latter is one + // frame behind, it is only known once shown) + const float header_width = ImGui::GetContentRegionAvail().x - style.CellPadding.x * 2 * cols - + (vscrollbar_visible_ ? style.ScrollbarSize : 0.0f); + + std::vector sizes(cols); + float header_height = 0; + for (int i = 0; i < cols; ++i) { + sizes[i] = headerSize(i, header_width); + header_height = std::max(header_height, sizes[i].y); + } + if (hexMode() && !messages_.empty()) { + sizes[1].x = std::max(sizes[1].x, bytesCellSize(messages_.front().data.size(), false).x); + } + const float row_height = bytesCellSize(8, false).y; + + // fixed section sizes and a horizontal scrollbar, no alternating row colors; the grid is drawn between + // rows and columns + ImGuiTableFlags flags = ImGuiTableFlags_ScrollY | ImGuiTableFlags_ScrollX | ImGuiTableFlags_BordersInner | + ImGuiTableFlags_SizingFixedFit; + // an empty viewport draws no grid + if (messages_.empty()) flags &= ~ImGuiTableFlags_BordersInnerV; + // the sum of the fixed section sizes, so the columns keep their size and the table scrolls + float inner_width = 0; + for (int i = 0; i < cols; ++i) inner_width += sizes[i].x + style.CellPadding.x * 2; + + bool fetch_more = false; + if (ImGui::BeginTable("logs", cols, flags, ImVec2(0, 0), inner_width)) { + ImGui::TableSetupScrollFreeze(0, 1); + for (int i = 0; i < cols; ++i) { + ImGui::TableSetupColumn(headerText(i).c_str(), ImGuiTableColumnFlags_WidthFixed, sizes[i].x); + } + ImGuiTable *table = ImGui::GetCurrentTable(); + ImDrawList *painter = ImGui::GetWindowDrawList(); + + ImGui::TableNextRow(ImGuiTableRowFlags_Headers, header_height); + for (int i = 0; i < cols; ++i) { + if (!ImGui::TableSetColumnIndex(i)) continue; + drawHeaderCell(painter, ImGui::TableGetCellBgRect(table, i), i); + ImGui::Dummy(ImVec2(0, header_height - style.CellPadding.y * 2)); + } + + ImGuiListClipper clipper; + clipper.Begin(messages_.size(), row_height); + while (clipper.Step()) { + for (int row = clipper.DisplayStart; row < clipper.DisplayEnd; ++row) { + const auto &m = messages_[row]; + ImGui::TableNextRow(0, row_height); + // rows are prepended while the stream plays, so the id is the message, not the row index + ImGui::PushID((void *)(uintptr_t)m.mono_time); + for (int col = 0; col < cols; ++col) { + if (!ImGui::TableSetColumnIndex(col)) continue; + // cells are selected, not rows; there is no hover highlight, only the selection background + const bool cell_selected = selected_row_ == row && selected_col_ == col; + ImGui::PushID(col); + if (viewSelectable("##cell", cell_selected, ImGuiSelectableFlags_AllowOverlap, ImVec2(0, row_height - style.CellPadding.y * 2))) { + selected_row_ = row; + selected_col_ = col; + } + ImGui::PopID(); + const ImRect rect = ImGui::TableGetCellBgRect(table, col); + if (col == 0) { + drawTextCell(painter, rect, formatTime(m.mono_time), cell_selected, false); + } else if (hexMode()) { + drawBytesCell(painter, rect, m.data, &m.colors, cell_selected, false, false); + } else { + drawTextCell(painter, rect, sigs_[col - 1]->formatValue(m.sig_values[col - 1], false), cell_selected, false); + } + } + ImGui::PopID(); + } + // fetch more when the last row is visible or the scrollbar is at its maximum + if (clipper.DisplayEnd >= (int)messages_.size()) fetch_more = true; + } + if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) fetch_more = true; + vscrollbar_visible_ = table->InnerWindow->ScrollbarY; + ImGui::EndTable(); + } + if (fetch_more && canFetchMore()) fetch(messages_.end(), messages_.back().mono_time, 0); +} diff --git a/openpilot/tools/cabana/ui/widgets/historylog.h b/openpilot/tools/cabana/ui/widgets/historylog.h new file mode 100644 index 0000000000..d8e68a4829 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/historylog.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" + +// the Logs tab: the messages of one id, newest first, as signal values or hex bytes +class LogsWidget { +public: + LogsWidget(); + void setMessage(const MessageId &message_id); + void updateState() { load(false); } // appends what arrived since the last call + void onShown() { load(true); } // reloads the log when the Logs tab becomes visible + void draw(); + +private: + struct Message { + uint64_t mono_time = 0; + std::vector sig_values; + std::vector data; + std::vector colors; + }; + + bool hexMode() const { return sigs_.empty() || hex_mode_; } + int columnCount() const { return hexMode() ? 2 : (int)sigs_.size() + 1; } + void reset(); + void setFilter(int sig_idx, const std::string &value, std::function cmp); + void load(bool clear); + bool canFetchMore() const; + void fetch(std::deque::iterator insert_pos, uint64_t from_time, uint64_t min_time); + void filterChanged(); + void exportToCSV(); + void drawTable(); + std::string headerText(int column) const; + ImVec2 headerSize(int column, float viewport_width) const; + void drawHeaderCell(ImDrawList *dl, const ImRect &rect, int column) const; + + MessageId msg_id_; + std::vector sigs_; + std::deque messages_; + CanData hex_colors_; + bool hex_mode_ = false; + int filter_sig_idx_ = -1; + double filter_value_ = 0; + std::function filter_cmp_; + int signals_cb_ = 0, comp_box_ = 0, display_type_cb_ = 0; // current combo box indices + std::string value_edit_; + bool value_edit_modified_ = false; + bool export_btn_enabled_ = false; + int selected_row_ = -1, selected_col_ = -1; + bool vscrollbar_visible_ = false; + Connections connections_; +}; diff --git a/openpilot/tools/cabana/ui/widgets/messagebytes.cc b/openpilot/tools/cabana/ui/widgets/messagebytes.cc new file mode 100644 index 0000000000..9052949eef --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/messagebytes.cc @@ -0,0 +1,60 @@ +#include "tools/cabana/ui/widgets/messagebytes.h" + +#include +#include + +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" + +ImVec2 byteCellSize() { + pushMonoFont(); + // the width of "00 " by the line height (ascent + descent + 1), not the font size + const ImFontBaked *baked = ImGui::GetFontBaked(); + const ImVec2 size(ImGui::CalcTextSize("00 ").x, std::ceil(baked->Ascent) - std::floor(baked->Descent) + 1 + 2); + popMonoFont(); + return size; +} + +ImVec2 bytesCellSize(int n, bool multiple_lines) { + const ImVec2 byte_size = byteCellSize(); + const int rows = multiple_lines ? std::max(1, (n + 7) / 8) : 1; + const int columns = multiple_lines ? std::min(n, 8) : n; + const ImVec2 margin = ImGui::GetStyle().CellPadding; // the margin is one more than the table's cell padding + return {columns * byte_size.x + (margin.x + 1) * 2, rows * byte_size.y + (margin.y + 1) * 2}; +} + +ImU32 cellTextColor(bool selected, bool inactive) { + if (selected) return inactive ? withAlpha(highlightedTextColor(), 100) : highlightedTextColor(); + return ImGui::GetColorU32(inactive ? ImGuiCol_TextDisabled : ImGuiCol_Text); +} + +void drawTextCell(ImDrawList *dl, const ImRect &rect, const std::string &text, bool selected, bool inactive) { + drawElidedText(dl, rect, text, cellTextColor(selected, inactive)); +} + +void drawBytesCell(ImDrawList *dl, const ImRect &rect, const std::vector &bytes, const std::vector *colors, + bool selected, bool inactive, bool multiple_lines) { + const ImU32 text_pen = cellTextColor(selected, inactive); + const ImVec2 byte_size = byteCellSize(); + pushMonoFont(); + ImFont *font = ImGui::GetFont(); + const float font_size = ImGui::GetFontSize(); + for (int i = 0; i < (int)bytes.size(); ++i) { + const int row = multiple_lines ? i / 8 : 0; + const int column = multiple_lines ? i % 8 : i; + const ImVec2 min(rect.Min.x + column * byte_size.x, rect.Min.y + row * byte_size.y); + const ImRect r(min, ImVec2(min.x + byte_size.x, min.y + byte_size.y)); + + // a colored unselected byte keeps text_pen, like the other cells of the row + ImU32 pen = text_pen; + if (colors && i < (int)colors->size() && (*colors)[i].alpha() > 0) { + if (selected) { + pen = ImGui::GetColorU32(ImGuiCol_Text); + dl->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_WindowBg)); + } + dl->AddRectFilled(r.Min, r.Max, toImU32((*colors)[i])); + } + drawText(dl, r, utils::hexByte(bytes[i]), pen, font, font_size); + } + popMonoFont(); +} diff --git a/openpilot/tools/cabana/ui/widgets/messagebytes.h b/openpilot/tools/cabana/ui/widgets/messagebytes.h new file mode 100644 index 0000000000..7c7aabc984 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/messagebytes.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/core/color.h" + +// the cells of the messages and history log tables. Only valid inside a frame: the sizes come from the +// mono font. + +ImVec2 byteCellSize(); // one "00 " cell +ImVec2 bytesCellSize(int n, bool multiple_lines); // a cell of n bytes, the table's cell padding included +ImU32 cellTextColor(bool selected, bool inactive); // inactive rows gray the text and fade the highlighted text + +void drawTextCell(ImDrawList *dl, const ImRect &rect, const std::string &text, bool selected, bool inactive); +void drawBytesCell(ImDrawList *dl, const ImRect &rect, const std::vector &bytes, const std::vector *colors, + bool selected, bool inactive, bool multiple_lines); diff --git a/openpilot/tools/cabana/ui/widgets/messageswidget.cc b/openpilot/tools/cabana/ui/widgets/messageswidget.cc new file mode 100644 index 0000000000..102960b9a9 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/messageswidget.cc @@ -0,0 +1,512 @@ +#include "tools/cabana/ui/widgets/messageswidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imgui_internal.h" +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/ui/widgets/messagebytes.h" +#include "tools/cabana/utils/strings.h" + +namespace { + +const char *COLUMN_TITLES[MessageList::COLUMN_COUNT] = {"Name", "Bus", "ID", "Node", "Freq", "Count", "Bytes"}; +constexpr float DEFAULT_SECTION_SIZE = 100.0f; + +// surrounding whitespace is ignored; no sign, no 0x prefix +unsigned int toUInt(const std::string &s, bool *ok, int base) { + const char *b = s.data(), *e = b + s.size(); + while (b < e && std::isspace((unsigned char)*b)) ++b; + while (e > b && std::isspace((unsigned char)e[-1])) --e; + unsigned int v = 0; + auto [p, ec] = std::from_chars(b, e, v, base); + *ok = b < e && p == e && ec == std::errc(); + return *ok ? v : 0; +} + +bool parseRange(const std::string &filter, uint32_t value, int base = 10) { + // parse the filter string into a range: "1" -> {1, 1}, "1-3" -> {1, 3}, "1-" -> {1, inf} + unsigned int min = std::numeric_limits::min(); + unsigned int max = std::numeric_limits::max(); + auto s = utils::split(filter, '-'); + bool ok = s.size() >= 1 && s.size() <= 2; + if (ok && !s[0].empty()) min = toUInt(s[0], &ok, base); + if (ok && s.size() == 1) { + max = min; + } else if (ok && s.size() == 2 && !s[1].empty()) { + max = toUInt(s[1], &ok, base); + } + return ok && value >= min && value <= max; +} + +// imgui draws an up arrow for ImGuiSortDirection_Ascending, cabana wants a down pointing one. Feed imgui +// the opposite direction and flip it back before it reaches the list. +inline ImGuiSortDirection flipSortDirection(ImGuiSortDirection dir) { + return dir == ImGuiSortDirection_Ascending ? ImGuiSortDirection_Descending : ImGuiSortDirection_Ascending; +} + +std::string formatFreq(float freq) { + if (freq <= 0) return "--"; + char buf[32]; + snprintf(buf, sizeof(buf), freq >= 0.95 ? "%.0f" : "%.2f", freq >= 0.95 ? std::nearbyint(freq) : freq); + return buf; +} + +// the text of a cell; the DATA cell paints the bytes itself +std::string cellText(const MessageList::Item &item, int column) { + const bool seen = item.id.source != INVALID_SOURCE; + switch (column) { + case MessageList::NAME: return item.name; + case MessageList::SOURCE: return seen ? std::to_string(item.id.source) : "N/A"; + case MessageList::ADDRESS: return utils::toHexString(item.id.address); + case MessageList::NODE: return item.node; + case MessageList::FREQ: return seen ? formatFreq(can->lastMessage(item.id).freq) : "N/A"; + case MessageList::COUNT: return seen ? std::to_string(can->lastMessage(item.id).count) : "N/A"; + case MessageList::DATA: return seen ? "" : "N/A"; + } + return {}; +} + +} // namespace + +// MessageList + +MessageList::MessageList() { + connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool has_new_ids) { msgsReceived(msgs, has_new_ids); })); + connections_.push_back(dbc()->fileChanged.connect([this]() { dbcModified(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { dbcModified(); })); +} + +void MessageList::setFilters(const std::map &filters) { + filters_ = filters; + filterAndSort(); +} + +void MessageList::showInactiveMessages(bool show) { + show_inactive_messages = show; + filterAndSort(); +} + +void MessageList::dbcModified() { + dbc_messages_.clear(); + for (const auto &[_, m] : dbc()->getMessages(-1)) { + dbc_messages_.insert(MessageId{.source = INVALID_SOURCE, .address = m.address}); + } + filterAndSort(); +} + +void MessageList::sortItems(std::vector &list) { + auto compare = [this](const auto &l, const auto &r) { + switch (sort_column_) { + case NAME: return std::tie(l.name, l.id) < std::tie(r.name, r.id); + case SOURCE: return std::tie(l.id.source, l.id.address) < std::tie(r.id.source, r.id.address); + case ADDRESS: return std::tie(l.id.address, l.id.source) < std::tie(r.id.address, r.id.source); + case NODE: return std::tie(l.node, l.id) < std::tie(r.node, r.id); + case FREQ: return std::tie(can->lastMessage(l.id).freq, l.id) < std::tie(can->lastMessage(r.id).freq, r.id); + case COUNT: return std::tie(can->lastMessage(l.id).count, l.id) < std::tie(can->lastMessage(r.id).count, r.id); + default: return false; + } + }; + + if (sort_order_ == ImGuiSortDirection_Descending) + std::stable_sort(list.rbegin(), list.rend(), compare); + else + std::stable_sort(list.begin(), list.end(), compare); +} + +bool MessageList::match(const Item &item) { + if (filters_.empty()) return true; + + bool match = true; + const auto &data = can->lastMessage(item.id); + for (auto it = filters_.cbegin(); it != filters_.cend() && match; ++it) { + const std::string &txt = it->second; + switch (it->first) { + case NAME: { + match = utils::containsCI(item.name, txt); + if (!match) { + const auto m = dbc()->msg(item.id); + match = m && std::any_of(m->sigs.cbegin(), m->sigs.cend(), + [&txt](const auto &s) { return utils::containsCI(s->name, txt); }); + } + break; + } + case SOURCE: + match = parseRange(txt, item.id.source); + break; + case ADDRESS: + match = utils::containsCI(utils::toHexString(item.id.address), txt); + match = match || parseRange(txt, item.id.address, 16); + break; + case NODE: + match = utils::containsCI(item.node, txt); + break; + case FREQ: + match = parseRange(txt, data.freq); + break; + case COUNT: + match = parseRange(txt, data.count); + break; + case DATA: + match = utils::containsCI(utils::toHex(data.dat), txt); + break; + } + } + return match; +} + +bool MessageList::filterAndSort() { + // merge CAN and DBC messages + std::vector all_messages; + all_messages.reserve(can->lastMessages().size() + dbc_messages_.size()); + auto dbc_msgs = dbc_messages_; + for (const auto &[id, m] : can->lastMessages()) { + all_messages.push_back(id); + dbc_msgs.erase(MessageId{.source = INVALID_SOURCE, .address = id.address}); + } + all_messages.insert(all_messages.end(), dbc_msgs.begin(), dbc_msgs.end()); + + std::vector new_items; + new_items.reserve(all_messages.size()); + for (const auto &id : all_messages) { + if (show_inactive_messages || can->isMessageActive(id)) { + auto msg = dbc()->msg(id); + Item item = {.id = id, .name = msg ? msg->name : UNTITLED, .node = msg ? msg->transmitter : std::string()}; + if (match(item)) new_items.emplace_back(item); + } + } + sortItems(new_items); + + if (items != new_items) { + items = std::move(new_items); + changed(); + return true; + } + return false; +} + +void MessageList::msgsReceived(const std::set *new_msgs, bool has_new_ids) { + if (has_new_ids || ((filters_.count(FREQ) || filters_.count(COUNT) || filters_.count(DATA)) && + ++sort_threshold_ == STREAM_UPDATE_FPS)) { + sort_threshold_ = 0; + filterAndSort(); + } +} + +void MessageList::sort(int column, ImGuiSortDirection order) { + if (column != DATA) { + sort_column_ = column; + sort_order_ = order; + filterAndSort(); + } +} + +// MessagesWidget + +MessagesWidget::MessagesWidget() { + std::iota(display_order_.begin(), display_order_.end(), 0); + list_.sort(MessageList::NAME, ImGuiSortDirection_Ascending); + + connections_.push_back(list_.changed.connect([this]() { + current_row_ = -1; // the rows moved + if (current_msg_id_) selectMessage(*current_msg_id_); + updateBytesSectionSize(); + updateTitle(); + })); + + suppressHighlighted(); +} + +std::string MessagesWidget::whatsThis() const { + return R"( + Message View
+ Byte color
+ constant changing
+ increasing
+ decreasing
+ Shortcuts
+ Horizontal Scrolling:  shift+wheel  + )"; +} + +void MessagesWidget::drawToolBar() { + ImGui::Dummy(ImVec2(0, std::max(0.0f, 9 - ImGui::GetStyle().ItemSpacing.y))); + if (ImGui::Button("Suppress Highlighted")) suppressHighlighted(true); + ImGui::SameLine(); + ImGui::BeginDisabled(!suppress_clear_enabled_); + const std::string clear_label = suppress_clear_text_ + "##suppress_clear"; + if (ImGui::Button(clear_label.c_str())) suppressHighlighted(false); + ImGui::EndDisabled(); + disabledItemTooltip("Clear suppressed"); + + const ImGuiStyle &style = ImGui::GetStyle(); + const float checkbox_width = ImGui::CalcTextSize("Suppress Signals").x + ImGui::GetFrameHeight() + style.ItemInnerSpacing.x; + const float view_button_width = ImGui::CalcTextSize(icon::THREE_DOTS).x + style.FramePadding.x * 2; + alignRight(checkbox_width + style.ItemSpacing.x + view_button_width); + + bool suppress_defined_signals = settings.suppress_defined_signals; + if (checkBox("Suppress Signals", &suppress_defined_signals)) can->suppressDefinedSignals(suppress_defined_signals); + ImGui::SetItemTooltip("Suppress defined signals"); + ImGui::SameLine(); + + if (toolButton("view_btn", icon::THREE_DOTS, "View...")) ImGui::OpenPopup("menu"); +} + +void MessagesWidget::updateTitle() { + auto stats = std::accumulate( + list_.items.begin(), list_.items.end(), std::pair(), + [](const auto &pair, const auto &item) { + auto m = dbc()->msg(item.id); + return m ? std::make_pair(pair.first + 1, pair.second + m->sigs.size()) : pair; + }); + char buf[128]; + snprintf(buf, sizeof(buf), "%zu Messages (%zu DBC Messages, %zu Signals)", list_.items.size(), stats.first, stats.second); + title_ = buf; +} + +void MessagesWidget::selectMessage(const MessageId &msg_id) { + auto it = std::find_if(list_.items.cbegin(), list_.items.cend(), [&msg_id](auto &item) { return item.id == msg_id; }); + if (it != list_.items.cend()) setCurrentRow(std::distance(list_.items.cbegin(), it)); +} + +void MessagesWidget::setCurrentRow(int row) { + if (row < 0 || row >= (int)list_.items.size()) return; + current_row_ = row; + scroll_to_current_ = true; + const auto &id = list_.items[row].id; + if (!current_msg_id_ || id != *current_msg_id_) { + current_msg_id_ = id; + msgSelectionChanged(*current_msg_id_); + } +} + +void MessagesWidget::suppressHighlighted(bool from_suppress_add) { + int n = from_suppress_add ? can->suppressHighlighted() : (can->clearSuppressed(), 0); + suppress_clear_text_ = n > 0 ? "Clear (" + std::to_string(n) + ")" : "Clear"; + suppress_clear_enabled_ = n > 0; +} + +void MessagesWidget::drawContextMenu() { + if (!ImGui::BeginPopup("menu")) return; + for (int i = 0; i < MessageList::COLUMN_COUNT; ++i) { + const int column = display_order_[i]; + // can't hide the name column + if (ImGui::MenuItem(COLUMN_TITLES[column], nullptr, !hidden_[column], column > 0)) { + pending_hidden_.emplace_back(column, !hidden_[column]); + } + } + ImGui::Separator(); + if (ImGui::MenuItem("Multi-Line bytes", nullptr, settings.multiple_lines_hex)) { + setMultiLineBytes(!settings.multiple_lines_hex); + } + if (ImGui::MenuItem("Show inactive messages", nullptr, list_.show_inactive_messages)) { + list_.showInactiveMessages(!list_.show_inactive_messages); + } + ImGui::EndPopup(); +} + +void MessagesWidget::setMultiLineBytes(bool multi) { + settings.multiple_lines_hex = multi; + updateBytesSectionSize(); +} + +void MessagesWidget::updateBytesSectionSize() { + int max_bytes = 8; + if (!settings.multiple_lines_hex) { + for (const auto &[_, m] : can->lastMessages()) { + max_bytes = std::max(max_bytes, m.dat.size()); + } + } + bytes_section_bytes_ = max_bytes; +} + +void MessagesWidget::draw() { + drawToolBar(); + drawTable(); + if (std::exchange(header_menu_requested_, false)) ImGui::OpenPopup("menu"); // at the mouse position + drawContextMenu(); +} + +void MessagesWidget::handleKeys() { + if (!ImGui::IsWindowFocused(ImGuiFocusedFlags_ChildWindows) || ImGui::IsAnyItemActive()) return; + const int last = (int)list_.items.size() - 1; + if (last < 0) return; + if (ImGui::IsKeyPressed(ImGuiKey_UpArrow) && current_row_ > 0) { + setCurrentRow(current_row_ - 1); + } else if (ImGui::IsKeyPressed(ImGuiKey_DownArrow) && current_row_ < last) { + setCurrentRow(current_row_ + 1); + } else if (ImGui::IsKeyPressed(ImGuiKey_Home)) { + setCurrentRow(0); + } else if (ImGui::IsKeyPressed(ImGuiKey_End)) { + setCurrentRow(last); + } else if (ImGui::IsKeyPressed(ImGuiKey_PageUp)) { + setCurrentRow(std::max(current_row_ - visible_rows_, 0)); + } else if (ImGui::IsKeyPressed(ImGuiKey_PageDown)) { + setCurrentRow(std::min(current_row_ + visible_rows_, last)); + } +} + +void MessagesWidget::drawTable() { + handleKeys(); + const bool multiple_lines = settings.multiple_lines_hex; + + const ImGuiTableFlags flags = ImGuiTableFlags_Sortable | ImGuiTableFlags_Resizable | ImGuiTableFlags_Reorderable | + ImGuiTableFlags_ScrollX | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Borders | + ImGuiTableFlags_Hideable; + // with ScrollX a stretch column needs an explicit inner width + const float bytes_width = bytesCellSize(bytes_section_bytes_, multiple_lines).x; + const float avail_width = ImGui::GetContentRegionAvail().x - (has_scrollbar_y_ ? ImGui::GetStyle().ScrollbarSize : 0); + const float inner_width = std::max(avail_width, fixed_columns_width_ + bytes_width); + if (!ImGui::BeginTable("messages", MessageList::COLUMN_COUNT, flags, ImVec2(0, 0), inner_width)) return; + + // no frozen column: only the header and the filter row stay put + ImGui::TableSetupScrollFreeze(0, 2); + // the widget shows its own header menu; imgui's default one is never drawn, so clear the flag + // TableHeader() leaves behind (it would keep the column header highlighted forever) + ImGuiTable *table = ImGui::GetCurrentTable(); + table->DisableDefaultContextMenu = true; + table->IsContextPopupOpen = false; + for (int i = 0; i < MessageList::COLUMN_COUNT; ++i) { + // with the flipped direction the first click on a section sorts ascending + ImGuiTableColumnFlags column_flags = ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_PreferSortDescending; + float width = DEFAULT_SECTION_SIZE; + if (i == MessageList::NAME) { + column_flags |= ImGuiTableColumnFlags_DefaultSort | ImGuiTableColumnFlags_NoHide; + } else if (i == MessageList::DATA) { + column_flags = ImGuiTableColumnFlags_WidthStretch | ImGuiTableColumnFlags_NoSort | ImGuiTableColumnFlags_NoResize; + width = 0; + } + ImGui::TableSetupColumn(COLUMN_TITLES[i], column_flags, width); + } + for (const auto &[column, hide] : pending_hidden_) ImGui::TableSetColumnEnabled(column, !hide); + pending_hidden_.clear(); + + if (ImGuiTableSortSpecs *specs = ImGui::TableGetSortSpecs(); specs && specs->SpecsDirty) { + if (specs->SpecsCount > 0) list_.sort(specs->Specs[0].ColumnIndex, flipSortDirection(specs->Specs[0].SortDirection)); + specs->SpecsDirty = false; + if (current_row_ >= 0) scroll_to_current_ = true; // keep the current row visible + } + + drawHeader(); + + const int rows = list_.items.size(); + if (!multiple_lines) { + ImGuiListClipper clipper; + clipper.Begin(rows); + if (scroll_to_current_ && current_row_ >= 0) clipper.IncludeItemByIndex(current_row_); + while (clipper.Step()) { + for (int row = clipper.DisplayStart; row < clipper.DisplayEnd; ++row) drawRow(row); + } + } else { + // non-uniform row heights: no clipper + for (int row = 0; row < rows; ++row) drawRow(row); + } + + // for the next frame: the stretch inner width and the page size of handleKeys + const ImGuiTableColumn &data_column = table->Columns[MessageList::DATA]; + fixed_columns_width_ = data_column.IsEnabled ? table->ColumnsGivenWidth - data_column.WidthGiven : 0; + has_scrollbar_y_ = table->InnerWindow->ScrollbarY; + visible_rows_ = std::max(1, (int)(table->InnerWindow->InnerRect.GetHeight() / bytesCellSize(0, multiple_lines).y) - 2); + ImGui::EndTable(); +} + +void MessagesWidget::drawHeader() { + if (tableHeadersRow() >= 0) header_menu_requested_ = true; + // record the visual order and the visibility of the sections for the menu + ImGuiTable *table = ImGui::GetCurrentTable(); + for (int i = 0; i < MessageList::COLUMN_COUNT; i++) { + display_order_[i] = table->DisplayOrderToIndex[i]; + hidden_[i] = !(ImGui::TableGetColumnFlags(i) & ImGuiTableColumnFlags_IsEnabled); + } + + // the filter editors under the header + const float clear_width = ImGui::CalcTextSize(icon::X).x + ImGui::GetStyle().FramePadding.x * 2; + ImGui::TableNextRow(); + for (int i = 0; i < MessageList::COLUMN_COUNT; i++) { + if (!ImGui::TableSetColumnIndex(i)) continue; + ImGui::PushID(i); + ImGui::SetNextItemWidth(filters_[i].empty() ? -FLT_MIN : std::max(1.0f, ImGui::GetContentRegionAvail().x - clear_width)); + const std::string placeholder = std::string("Filter ") + COLUMN_TITLES[i]; + if (clearableInput("##filter", &filters_[i], placeholder.c_str())) { + std::map filters; + for (int c = 0; c < MessageList::COLUMN_COUNT; ++c) { + if (!filters_[c].empty()) filters[c] = filters_[c]; + } + list_.setFilters(filters); + } + ImGui::PopID(); + } +} + +void MessagesWidget::drawRow(int row) { + const auto &item = list_.items[row]; + const bool selected = row == current_row_; + const bool inactive = !can->isMessageActive(item.id); + const auto &m = can->lastMessage(item.id); + const bool seen = item.id.source != INVALID_SOURCE; + const bool multiple_lines = settings.multiple_lines_hex; + const float row_height = bytesCellSize(seen ? m.dat.size() : 0, multiple_lines).y - ImGui::GetStyle().CellPadding.y * 2; + ImGui::TableNextRow(); + ImGui::PushID(row); + + bool row_item_submitted = false; + for (int column = 0; column < MessageList::COLUMN_COUNT; ++column) { + if (!ImGui::TableSetColumnIndex(column)) continue; + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const float width = ImGui::GetContentRegionAvail().x; + const ImRect rect(pos, ImVec2(pos.x + width, pos.y + row_height)); + + const bool row_item = !row_item_submitted; + if (row_item) { + // the row selection spans all columns; submit it in the first visible column so that it is not + // clipped away when the table is scrolled horizontally + row_item_submitted = true; + // rows select on press + if (viewSelectable("##row", selected, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_SelectOnClick, ImVec2(0, row_height))) { + setCurrentRow(row); + } + if (selected && scroll_to_current_) { + // only scroll when the row is outside the viewport, and only far enough + const ImGuiWindow *inner = ImGui::GetCurrentTable()->InnerWindow; + const float view_top = inner->InnerClipRect.Min.y + inner->DecoInnerSizeY1; + const float view_bottom = inner->InnerClipRect.Max.y; + if (ImGui::GetItemRectMin().y < view_top) { + ImGui::SetScrollHereY(0.0f); + } else if (ImGui::GetItemRectMax().y > view_bottom) { + ImGui::SetScrollHereY(1.0f); + } + scroll_to_current_ = false; + } + // the tooltip belongs to the name, so only show it while the mouse is over the Name column + const ImGuiTableColumn &name_col = ImGui::GetCurrentTable()->Columns[MessageList::NAME]; + const float mouse_x = ImGui::GetIO().MousePos.x; + if (ImGui::IsItemHovered(ImGuiHoveredFlags_ForTooltip) && mouse_x >= name_col.MinX && mouse_x < name_col.MaxX) { + auto msg = dbc()->msg(item.id); + ImGui::BeginTooltip(); + ImGui::TextUnformatted(item.name.c_str()); + if (msg && !msg->comment.empty()) ImGui::TextDisabled("%s", msg->comment.c_str()); + ImGui::EndTooltip(); + } + } + + if (column == MessageList::DATA && seen) { + drawBytesCell(ImGui::GetWindowDrawList(), rect, m.dat, &m.colors, selected, inactive, multiple_lines); + } else { + drawTextCell(ImGui::GetWindowDrawList(), rect, cellText(item, column), selected, inactive); + } + // the Selectable already sized its cell + if (!row_item) ImGui::Dummy(ImVec2(width, row_height)); + } + + ImGui::PopID(); +} diff --git a/openpilot/tools/cabana/ui/widgets/messageswidget.h b/openpilot/tools/cabana/ui/widgets/messageswidget.h new file mode 100644 index 0000000000..abfd43fb50 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/messageswidget.h @@ -0,0 +1,93 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" + +// the rows of the messages table: every CAN message seen plus the DBC messages not seen yet, filtered +// and sorted. `changed` fires when the rows differ from the last time. +class MessageList { +public: + enum Column { NAME = 0, SOURCE, ADDRESS, NODE, FREQ, COUNT, DATA, COLUMN_COUNT }; + + struct Item { + MessageId id; + std::string name; + std::string node; + bool operator==(const Item &other) const { return id == other.id && name == other.name && node == other.node; } + }; + + MessageList(); + void sort(int column, ImGuiSortDirection order); + void setFilters(const std::map &filters); + void showInactiveMessages(bool show); + bool filterAndSort(); + + std::vector items; + bool show_inactive_messages = true; + Observable<> changed; + +private: + void msgsReceived(const std::set *new_msgs, bool has_new_ids); + void dbcModified(); + void sortItems(std::vector &list); + bool match(const Item &item); + + std::map filters_; + std::set dbc_messages_; + int sort_column_ = NAME; + ImGuiSortDirection sort_order_ = ImGuiSortDirection_Ascending; + int sort_threshold_ = 0; + Connections connections_; +}; + +class MessagesWidget { +public: + MessagesWidget(); + void draw(); // content only; MainWindow does ImGui::Begin/End with the dock title + void selectMessage(const MessageId &message_id); + void suppressHighlighted(bool from_suppress_add = false); + const std::string &title() const { return title_; } + std::string whatsThis() const; + + Observable msgSelectionChanged; + +private: + void drawToolBar(); + void drawTable(); + void drawHeader(); // the header row and the filter editors row, inside the table + void drawRow(int row); + void drawContextMenu(); + void handleKeys(); // up/down move the current row + void setCurrentRow(int row); // scrolls to the row + void updateBytesSectionSize(); + void updateTitle(); + void setMultiLineBytes(bool multi); + + MessageList list_; + std::optional current_msg_id_; + int current_row_ = -1; + bool scroll_to_current_ = false; + int bytes_section_bytes_ = 8; // the minimum width of the stretched DATA section + float fixed_columns_width_ = 0; // width of everything but the stretched DATA section + bool has_scrollbar_y_ = false; + int visible_rows_ = 1; + std::array filters_; + std::array hidden_ = {}; // mirror of the table's enabled columns, refreshed every frame + std::array display_order_; // visual index -> logical column + std::vector> pending_hidden_; // applied inside the table + bool header_menu_requested_ = false; + std::string suppress_clear_text_; + bool suppress_clear_enabled_ = false; + std::string title_ = "MESSAGES"; + Connections connections_; +}; diff --git a/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc b/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc new file mode 100644 index 0000000000..feda7ce34d --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc @@ -0,0 +1,88 @@ +#include "tools/cabana/ui/widgets/scrollabletabbar.h" + +#include +#include +#include + +#include "imgui_internal.h" + +namespace { +float scrollButtonsWidth() { + const ImGuiStyle &style = ImGui::GetStyle(); + return ImGui::GetFrameHeight() * 2.0f + style.ItemInnerSpacing.x + style.ItemSpacing.x * 2.0f; +} + +void drawScrollButtons(ImGuiTabBar *tab_bar) { + const ImGuiStyle &style = ImGui::GetStyle(); + const float size = ImGui::GetFrameHeight(); + const float max_scroll = std::max(0.0f, tab_bar->WidthAllTabs - tab_bar->BarRect.GetWidth()); + const float start_x = tab_bar->BarRect.Max.x + style.ItemSpacing.x; + const ImVec2 backup_pos = ImGui::GetCursorScreenPos(); + + ImGui::PushItemFlag(ImGuiItemFlags_ButtonRepeat, true); + for (int i = 0; i < 2; ++i) { + const bool left = i == 0; + ImGui::SetCursorScreenPos(ImVec2(start_x + i * (size + style.ItemInnerSpacing.x), tab_bar->BarRect.Min.y)); + ImGui::BeginDisabled(left ? tab_bar->ScrollingTarget <= 0.0f : tab_bar->ScrollingTarget >= max_scroll); + if (ImGui::Button(left ? "###scroll_left" : "###scroll_right", ImVec2(size, size))) { + const float step = (left ? -4.0f : 4.0f) * ImGui::GetFontSize(); + tab_bar->ScrollingTarget = std::clamp(tab_bar->ScrollingTarget + step, 0.0f, max_scroll); + tab_bar->ScrollingAnim = tab_bar->ScrollingTarget; + } + // the icon font glyph sits off center in its padded advance, so the chevron is drawn in the rect + const ImVec2 c((ImGui::GetItemRectMin().x + ImGui::GetItemRectMax().x) * 0.5f, + (ImGui::GetItemRectMin().y + ImGui::GetItemRectMax().y) * 0.5f); + const float h = std::round(ImGui::GetFontSize() * 0.25f); + const float dx = left ? h * 0.5f : -h * 0.5f; + ImDrawList *painter = ImGui::GetWindowDrawList(); + painter->PathLineTo(ImVec2(c.x + dx, c.y - h)); + painter->PathLineTo(ImVec2(c.x - dx, c.y)); + painter->PathLineTo(ImVec2(c.x + dx, c.y + h)); + painter->PathStroke(ImGui::GetColorU32(ImGuiCol_Text), ImDrawFlags_None, 1.5f); + ImGui::EndDisabled(); + } + ImGui::PopItemFlag(); + ImGui::SetCursorScreenPos(backup_pos); +} + +struct ScrollableTabBar { ImGuiTabBar *tab_bar; bool overflowing; }; +std::vector scrollable_tab_bars; +} // namespace + +bool beginScrollableTabBar(const char *str_id, ImGuiTabBarFlags flags) { + // the buttons take their room from the bar when the tabs overflowed last frame + ImGuiWindow *window = ImGui::GetCurrentWindow(); + ImGuiTabBar *prev_tab_bar = ImGui::TabBarFindByID(window->GetID(str_id)); + const bool overflowing = prev_tab_bar && prev_tab_bar->WidthAllTabsIdeal > prev_tab_bar->BarRect.GetWidth() + 1.0f; + const float backup_work_max_x = window->WorkRect.Max.x; + if (overflowing) window->WorkRect.Max.x -= scrollButtonsWidth(); + const bool open = ImGui::BeginTabBar(str_id, flags | ImGuiTabBarFlags_FittingPolicyScroll | ImGuiTabBarFlags_NoTabListScrollingButtons); + window->WorkRect.Max.x = backup_work_max_x; + if (open) scrollable_tab_bars.push_back({ImGui::GetCurrentTabBar(), overflowing}); + return open; +} + +void endScrollableTabBar() { + ImGui::EndTabBar(); + const ScrollableTabBar bar = scrollable_tab_bars.back(); + scrollable_tab_bars.pop_back(); + if (!bar.overflowing) return; + drawScrollButtons(bar.tab_bar); + + // the wheel scrolls the tabs while the pointer is over them: a two finger swipe on a touchpad, or a + // mouse wheel like a window that only scrolls sideways. Owning the wheel keeps the window behind still + ImGuiTabBar *tab_bar = bar.tab_bar; + if (ImGui::IsWindowHovered() && ImGui::IsMouseHoveringRect(tab_bar->BarRect.Min, tab_bar->BarRect.Max)) { + ImGui::SetKeyOwner(ImGuiKey_MouseWheelX, tab_bar->ID); + ImGui::SetKeyOwner(ImGuiKey_MouseWheelY, tab_bar->ID); + const ImGuiIO &io = ImGui::GetIO(); + const float wheel = io.MouseWheelH + io.MouseWheel; + if (wheel != 0.0f) { + const float max_scroll = std::max(0.0f, tab_bar->WidthAllTabs - tab_bar->BarRect.GetWidth()); + const float step = std::floor(ImGui::GetFontSize() * 2.0f); + tab_bar->ScrollingTarget = std::clamp(tab_bar->ScrollingTarget - wheel * step, 0.0f, max_scroll); + tab_bar->ScrollingAnim = tab_bar->ScrollingTarget; + } + } +} + diff --git a/openpilot/tools/cabana/ui/widgets/scrollabletabbar.h b/openpilot/tools/cabana/ui/widgets/scrollabletabbar.h new file mode 100644 index 0000000000..6c119a7d7c --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/scrollabletabbar.h @@ -0,0 +1,8 @@ +#pragma once + +#include "imgui.h" + +// a tab bar that scrolls with a pair of chevron buttons at its right end when the tabs overflow, in place of +// imgui's small arrows. Use like BeginTabBar/EndTabBar, the fitting policy is always scroll +bool beginScrollableTabBar(const char *str_id, ImGuiTabBarFlags flags = 0); +void endScrollableTabBar(); diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc new file mode 100644 index 0000000000..af926b5bb0 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -0,0 +1,975 @@ +#include "tools/cabana/ui/widgets/signalview.h" + +#include +#include +#include +#include +#include + +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/ui/threadpool.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" +#include "tools/cabana/ui/icons.h" + +namespace { +constexpr float INDENTATION = 20.0f; +constexpr float H_MARGIN = 3.0f; +constexpr float V_MARGIN = 2.0f; +// signal rows are taller than a frame so the sparklines have room to read +constexpr float SIGNAL_ROW_EXTRA = 5.0f; // the tool button in the row makes it 27 px tall at the 16 px font +constexpr float SIGNAL_ROW_SCALE = 1.25f; +constexpr float FILTER_WIDTH = 160.0f; +constexpr float SPARKLINE_SLIDER_WIDTH = 120.0f; +constexpr float COLLAPSE_ICON_SIZE = 12.0f; +// WARNING: increasing the maximum range can result in severe performance degradation. +// 30s is a reasonable value at present. +constexpr int SPARKLINE_RANGE_MAX = 30; +constexpr float LABEL_FONT = 12.0f; // Inter needs 12 px for 8 px tall digits +constexpr float MINMAX_FONT = 10.0f; +constexpr int COLOR_LABEL_WIDTH = 18; + +std::string signalTypeToString(cabana::Signal::Type type) { + if (type == cabana::Signal::Type::Multiplexor) return "Multiplexor Signal"; + else if (type == cabana::Signal::Type::Multiplexed) return "Multiplexed Signal"; + else return "Normal Signal"; +} + +std::string multiplexIndicator(const cabana::Signal *sig) { + return sig->type == cabana::Signal::Type::Multiplexor ? std::string(" M ") : " m" + std::to_string(sig->multiplex_value) + " "; +} + +std::string nameText(const SignalModel::Item *item) { + return item->type == SignalModel::Item::Sig ? item->sig->name : item->title; +} + +float rowHeight() { + return ImGui::GetFrameHeight(); +} + +// only the top level signal rows are taller; the expanded sub-rows keep the default row height +float signalRowHeight() { + return std::floor((ImGui::GetFrameHeight() + SIGNAL_ROW_EXTRA) * SIGNAL_ROW_SCALE); +} + +// column 0 has a double validator. Returns true when the cell was clicked (row selection); the two cells +// of a row share the row's id scope, so each editor needs its own column id +bool valueDescriptionEditor(int column, std::string *text) { + ImGui::PushID(column); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + validatedInput("##edit", text, column == 0 ? doubleValidator : nullptr); + ImGui::PopStyleVar(); + const bool clicked = ImGui::IsItemActivated() || ImGui::IsItemClicked(); + ImGui::PopID(); + return clicked; +} + +} // namespace + +SignalModel::SignalModel() : root_(new Item) { + connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); + connections_.push_back(dbc()->msgUpdated.connect([this](MessageId id) { handleMsgChanged(id); })); + connections_.push_back(dbc()->msgRemoved.connect([this](MessageId id) { handleMsgChanged(id); })); + connections_.push_back(dbc()->signalAdded.connect([this](MessageId id, const cabana::Signal *sig) { handleSignalAdded(id, sig); })); + connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { handleSignalUpdated(sig); })); + connections_.push_back(dbc()->signalRemoved.connect([this](const cabana::Signal *sig) { handleSignalRemoved(sig); })); +} + +void SignalModel::insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig) { + Item *parent_item = new Item{.type = Item::Sig, .parent = root_item, .sig = sig, .title = sig->name}; + root_item->children.insert(root_item->children.begin() + pos, parent_item); + std::string titles[]{"Name", "Size", "Receiver Nodes", "Little Endian", "Signed", "Offset", "Factor", "Type", + "Multiplex Value", "Extra Info", "Unit", "Comment", "Minimum Value", "Maximum Value", "Value Table"}; + for (int i = 0; i < std::size(titles); ++i) { + auto item = new Item{.type = (Item::Type)(i + Item::Name), .parent = parent_item, .sig = sig, .title = titles[i]}; + parent_item->children.push_back(item); + if (item->type == Item::ExtraInfo) { + parent_item = item; + } + } +} + +void SignalModel::setMessage(const MessageId &id) { + msg_id_ = id; + filter_str_ = ""; + refresh(); +} + +void SignalModel::setFilter(const std::string &txt) { + filter_str_ = txt; + refresh(); +} + +void SignalModel::refresh() { + root_.reset(new SignalModel::Item); + if (auto msg = dbc()->msg(msg_id_)) { + for (auto s : msg->getSignals()) { + if (filter_str_.empty() || utils::containsCI(s->name, filter_str_)) { + insertItem(root_.get(), root_->children.size(), s); + } + } + } + modelReset(); + rowsChanged(); +} + +bool SignalModel::isEnabled(const Item *item) { + return !(item->type == Item::MultiplexValue && item->sig->type != cabana::Signal::Type::Multiplexed); +} + +bool SignalModel::isCheckable(const Item *item) { + return item->type == Item::Endian || item->type == Item::Signed; +} + +bool SignalModel::isEditable(const Item *item) { + return item->children.empty() && !isCheckable(item); +} + +int SignalModel::signalRow(const cabana::Signal *sig) const { + for (int i = 0; i < root_->children.size(); ++i) { + if (root_->children[i]->sig == sig) return i; + } + return -1; +} + +std::string SignalModel::valueText(const Item *item) const { + switch (item->type) { + case Item::Sig: return item->sig_val; + case Item::Name: return item->sig->name; + case Item::Size: return std::to_string(item->sig->size); + case Item::Node: return item->sig->receiver_name; + case Item::SignalType: return signalTypeToString(item->sig->type); + case Item::MultiplexValue: return std::to_string(item->sig->multiplex_value); + case Item::Offset: return doubleToString(item->sig->offset); + case Item::Factor: return doubleToString(item->sig->factor); + case Item::Unit: return item->sig->unit; + case Item::Comment: return item->sig->comment; + case Item::Min: return doubleToString(item->sig->min); + case Item::Max: return doubleToString(item->sig->max); + case Item::Desc: { + std::string val_desc; + for (auto &[val, desc] : item->sig->val_desc) { + if (!val_desc.empty()) val_desc += " "; + val_desc += utils::toString(val) + " \"" + desc + "\""; + } + return val_desc; + } + default: return {}; + } +} + +bool SignalModel::setData(Item *item, const ItemValue &value) { + cabana::Signal s = *item->sig; + switch (item->type) { + case Item::Name: s.name = value.toString(); break; + case Item::Size: s.size = value.toInt(); break; + case Item::Node: s.receiver_name = utils::trimmed(value.toString()); break; + case Item::SignalType: s.type = (cabana::Signal::Type)value.toInt(); break; + case Item::MultiplexValue: s.multiplex_value = value.toInt(); break; + case Item::Endian: s.is_little_endian = value.toBool(); break; + case Item::Signed: s.is_signed = value.toBool(); break; + case Item::Offset: s.offset = value.toDouble(); break; + case Item::Factor: s.factor = value.toDouble(); break; + case Item::Unit: s.unit = value.toString(); break; + case Item::Comment: s.comment = value.toString(); break; + case Item::Min: s.min = value.toDouble(); break; + case Item::Max: s.max = value.toDouble(); break; + case Item::Desc: s.val_desc = value.toValueDescription(); break; + default: return false; + } + return saveSignal(item->sig, s); +} + +bool SignalModel::saveSignal(const cabana::Signal *origin_s, cabana::Signal &s) { + auto msg = dbc()->msg(msg_id_); + if (s.name != origin_s->name && msg->sig(s.name) != nullptr) { + std::string text = "There is already a signal with the same name '" + s.name + "'"; + MessageBox::warning("Failed to save signal", text); + return false; + } + + if (s.is_little_endian != origin_s->is_little_endian) { + s.start_bit = flipBitPos(s.start_bit); + } + UndoStack::instance()->push(new EditSignalCommand(msg_id_, origin_s, s)); + return true; +} + +void SignalModel::handleMsgChanged(MessageId id) { + if (id.address == msg_id_.address) { + refresh(); + } +} + +void SignalModel::handleSignalAdded(MessageId id, const cabana::Signal *sig) { + if (id == msg_id_) { + if (filter_str_.empty()) { + int i = dbc()->msg(msg_id_)->indexOf(sig); + insertItem(root_.get(), i, sig); + rowsChanged(); + } else if (utils::containsCI(sig->name, filter_str_)) { + refresh(); + } + } +} + +void SignalModel::handleSignalUpdated(const cabana::Signal *sig) { + if (int row = signalRow(sig); row != -1) { + if (filter_str_.empty()) { + // move row when the order changes. + int to = dbc()->msg(msg_id_)->indexOf(sig); + if (to != row) { + auto item = root_->children[row]; + root_->children.erase(root_->children.begin() + row); + root_->children.insert(root_->children.begin() + to, item); + } + } + } +} + +void SignalModel::handleSignalRemoved(const cabana::Signal *sig) { + if (int row = signalRow(sig); row != -1) { + delete root_->children[row]; + root_->children.erase(root_->children.begin() + row); + rowsChanged(); + } +} + +float SignalView::textWidth(const std::string &text, float font_size) { + ImFont *font = ImGui::GetFont(); + if (!font || ImGui::GetFontSize() <= 0) return 0; // no frame rendered yet + return font->CalcTextSizeA(font_size > 0 ? font_size : ImGui::GetFontSize(), FLT_MAX, 0.0f, text.c_str()).x; +} + +float SignalView::nameColumnWidth(const SignalModel::Item *item, float widget_width, const std::string &text) const { + float spacing = INDENTATION + COLOR_LABEL_WIDTH + 8; + std::string txt = text; + if (item->type == SignalModel::Item::Sig && item->sig->type != cabana::Signal::Type::Normal) { + txt += multiplexIndicator(item->sig); + spacing += H_MARGIN * 2; + } + return std::min(widget_width / 3.0, textWidth(txt) + spacing); +} + +void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const SignalModel::Item *item, int column, + bool selected, const std::string &text, float viewport_x) const { + const float h_margin = H_MARGIN; + const float v_margin = V_MARGIN; + + ImRect rect(option_rect.Min.x + h_margin, option_rect.Min.y + v_margin, option_rect.Max.x - h_margin, option_rect.Max.y - v_margin); + // selection background is painted by the row's Selectable + const ImU32 text_color = selected ? highlightedTextColor() : ImGui::GetColorU32(ImGuiCol_Text); + + if (column == 0) { + if (item->type == SignalModel::Item::Sig) { + // color label + ImRect icon_rect(rect.Min.x, rect.Min.y, rect.Min.x + COLOR_LABEL_WIDTH, rect.Max.y); + painter->AddRectFilled(icon_rect.Min, icon_rect.Max, toImU32(item->sig->color.darker(item->highlight ? 125 : 0)), 3.0f); + drawText(painter, icon_rect, std::to_string(item->row() + 1).c_str(), item->highlight ? IM_COL32_WHITE : IM_COL32_BLACK, + nullptr, LABEL_FONT); + + rect.Min.x = icon_rect.Max.x + h_margin * 2; + // multiplexer indicator + if (item->sig->type != cabana::Signal::Type::Normal) { + const std::string indicator = multiplexIndicator(item->sig); + ImRect indicator_rect(rect.Min.x, rect.Min.y, rect.Min.x + ImGui::CalcTextSize(indicator.c_str()).x, rect.Max.y); + painter->AddRectFilled(indicator_rect.Min, indicator_rect.Max, IM_COL32(160, 160, 164, 255), 3.0f); + drawElidedText(painter, indicator_rect, indicator, IM_COL32_WHITE, false); + rect.Min.x = indicator_rect.Max.x + h_margin * 2; + } + } else { + rect.Min.x = viewport_x + INDENTATION + COLOR_LABEL_WIDTH + h_margin * 3; + } + + // name + if (rect.GetWidth() > 0) drawElidedText(painter, rect, text, text_color, false); + } else if (column == 1) { + if (!item->sparkline.isEmpty()) { + const ImVec2 sparkline_size = item->sparkline.size; + item->sparkline.draw(painter, rect.Min); + // min-max value + rect.Min.x += sparkline_size.x + 1; + float value_adjust = 10; + if (item->highlight || selected) { + painter->AddLine(rect.Min, ImVec2(rect.Min.x, rect.Max.y), text_color); + rect.Min.x += 5; + rect.Min.y -= v_margin; + rect.Max.y += v_margin; + std::string min = utils::toString(item->sparkline.min_val); + std::string max = utils::toString(item->sparkline.max_val); + drawText(painter, rect, max.c_str(), text_color, nullptr, MINMAX_FONT, ImVec2(0.0f, 0.0f)); + drawText(painter, rect, min.c_str(), text_color, nullptr, MINMAX_FONT, ImVec2(0.0f, 1.0f)); + value_adjust = std::max(textWidth(min, MINMAX_FONT), textWidth(max, MINMAX_FONT)) + 5; + } else if (item->sig->type == cabana::Signal::Type::Multiplexed) { + // display freq of multiplexed signal + char freq[64]; + snprintf(freq, sizeof(freq), "%.2g hz", item->sparkline.freq()); + ImRect freq_rect(rect.Min.x + 5, rect.Min.y, rect.Max.x, rect.Max.y); + drawText(painter, freq_rect, freq, text_color, nullptr, LABEL_FONT, ImVec2(0.0f, 0.5f)); + value_adjust = textWidth(freq, LABEL_FONT) + 10; + } + // signal value + rect.Min.x += value_adjust; + rect.Max.x -= button_size_.x; + if (rect.GetWidth() > 0) drawElidedText(painter, rect, text, text_color, true); + } else { + // no sparkline yet: the value still belongs against the buttons, where it sits once there is one + rect.Max.x -= button_size_.x; + if (rect.GetWidth() > 0) drawElidedText(painter, rect, text, text_color, true); + } + } +} + +void SignalView::drawEditor(SignalModel::Item *item) { + const bool take_focus = focus_item_ == item; + if (take_focus) focus_item_ = nullptr; + if (item->type == SignalModel::Item::Name || item->type == SignalModel::Item::Node || item->type == SignalModel::Item::Offset || + item->type == SignalModel::Item::Factor || item->type == SignalModel::Item::MultiplexValue || + item->type == SignalModel::Item::Min || item->type == SignalModel::Item::Max) { + ImGuiInputTextCallback validator = nullptr; + if (item->type == SignalModel::Item::Name) validator = nameValidator; + else if (item->type == SignalModel::Item::Node) validator = nodeValidator; + else validator = doubleValidator; + + drawLineEditor(item, validator, take_focus); + } else if (item->type == SignalModel::Item::Size) { + int v = item->sig->size; + if (take_focus) ImGui::SetKeyboardFocusHere(); + bool changed = ImGui::InputInt("##editor", &v, 1, 100, ImGuiInputTextFlags_AutoSelectAll); + if (ImGui::IsItemDeactivated() && ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { + open_item_ = nullptr; // InputInt already reverted the value; only the commit has to be skipped + return; + } + if (ImGui::IsItemDeactivatedAfterEdit() || (changed && !ImGui::IsItemActive())) { + queueCommit(item, std::clamp(v, 1, CAN_MAX_DATA_BYTES)); + } + // Enter, Escape and a click outside close the editor; the step buttons keep it open + if (ImGui::IsItemDeactivated() && (!ImGui::IsItemHovered() || ImGui::IsKeyPressed(ImGuiKey_Enter, false) || + ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false) || ImGui::IsKeyPressed(ImGuiKey_Escape, false))) { + open_item_ = nullptr; + } + } else if (item->type == SignalModel::Item::SignalType) { + // the combo editor is closed by Enter and Escape; the cell is painted as text again next frame + if (combo_focused_ && (ImGui::IsKeyPressed(ImGuiKey_Escape, false) || ImGui::IsKeyPressed(ImGuiKey_Enter, false) || + ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false))) { + open_item_ = nullptr; + combo_focused_ = false; + return; + } + std::vector> items; + items.emplace_back(signalTypeToString(cabana::Signal::Type::Normal), (int)cabana::Signal::Type::Normal); + if (!dbc()->msg(model_.msgId())->multiplexor) { + items.emplace_back(signalTypeToString(cabana::Signal::Type::Multiplexor), (int)cabana::Signal::Type::Multiplexor); + } else if (item->sig->type != cabana::Signal::Type::Multiplexor) { + items.emplace_back(signalTypeToString(cabana::Signal::Type::Multiplexed), (int)cabana::Signal::Type::Multiplexed); + } + std::vector names; + int current = -1; // -1 when the current type is not an item (Multiplexor) + for (int i = 0; i < items.size(); ++i) { + names.push_back(items[i].first.c_str()); + if (items[i].second == (int)item->sig->type) current = i; + } + const ImGuiID popup_id = ImHashStr("##ComboPopup", 0, ImGui::GetID("##editor")); + if (take_focus) ImGui::SetKeyboardFocusHere(); + if (ImGui::Combo("##editor", ¤t, names.data(), names.size())) { + queueCommit(item, items[current].second); + open_item_ = nullptr; // commit and close the editor + } + combo_focused_ = ImGui::IsItemFocused() || ImGui::IsPopupOpen(popup_id, ImGuiPopupFlags_None); + if (!take_focus && !combo_focused_) open_item_ = nullptr; // the editor is closed when it loses the focus + } else if (item->type == SignalModel::Item::Desc) { + ImGui::PushStyleColor(ImGuiCol_Header, (ImU32)0); + const bool clicked = ImGui::Selectable("##editor", false, 0, ImVec2(0, rowHeight())); + ImGui::PopStyleColor(); + drawElidedText(ImGui::GetWindowDrawList(), ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()), model_.valueText(item), + highlightedTextColor(), false); + if (clicked || take_focus) { + desc_dlg_ = std::make_unique(item->sig->val_desc); + desc_dlg_->title = item->sig->name; + desc_sig_ = item->sig; + } + } else { + // plain text input, no validator + drawLineEditor(item, nullptr, take_focus); + } +} + +void SignalView::commitEditor() { + SignalModel::Item *item = editing_item_; + std::string text = edit_text_; + closeEditor(); + if (item && validateEditor(item, text) == ValidState::Acceptable) { + queueCommit(item, text); + } +} + +void SignalView::closeEditor() { + editing_item_ = open_item_ = focus_item_ = nullptr; + editor_active_ = refocus_editor_ = enter_pressed_ = combo_focused_ = false; + pending_commit_ = nullptr; // the items it captured are deleted by the caller +} + +// validate the editor of `item`; mutates `text` like the name validator does (spaces -> '_') +ValidState SignalView::validateEditor(const SignalModel::Item *item, std::string &text) { + if (item->type == SignalModel::Item::Name) return validateName(text); + if (item->type == SignalModel::Item::Node) return validateNodes(text); + if (item->type == SignalModel::Item::Offset || item->type == SignalModel::Item::Factor || + item->type == SignalModel::Item::MultiplexValue || item->type == SignalModel::Item::Min || + item->type == SignalModel::Item::Max) { + return validateDouble(text); + } + return ValidState::Acceptable; // no validator +} + +// Enter and focus out only commit when the validator reports Acceptable (an Intermediate or Invalid value +// keeps the editor open with the typed text and commits nothing), Escape reverts to the value the editor +// was opened with. +void SignalView::drawLineEditor(SignalModel::Item *item, ImGuiInputTextCallback validator, bool take_focus) { + const bool editing = editing_item_ == item; + const bool was_active = editing && editor_active_; // the editor had the focus at the end of the last frame + + std::string text = editing ? edit_text_ : model_.valueText(item); + if (take_focus) ImGui::SetKeyboardFocusHere(); + if (editing && refocus_editor_) { + ImGui::SetKeyboardFocusHere(); // keep the focus when the input is not acceptable + refocus_editor_ = false; + } + validatedInput("##editor", &text, validator, "", ImGuiInputTextFlags_AutoSelectAll); + if (ImGui::IsItemActivated()) editing_item_ = item; + if (editing_item_ != item) return; + + edit_text_ = text; + editor_active_ = ImGui::IsItemActive(); + if (was_active) { + if (ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { + // InputText already reverted the text; only the commit has to be skipped + editing_item_ = open_item_ = nullptr; + enter_pressed_ = false; + return; + } + if (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false)) { + enter_pressed_ = true; + } + } + if (ImGui::IsItemDeactivated()) { + const bool by_enter = std::exchange(enter_pressed_, false); + if (!ImGui::IsItemDeactivatedAfterEdit()) { + editing_item_ = open_item_ = nullptr; // nothing was typed, nothing to commit + } else if (validateEditor(item, edit_text_) == ValidState::Acceptable) { + queueCommit(item, edit_text_); + editing_item_ = open_item_ = nullptr; + } else if (by_enter) { + refocus_editor_ = true; // the editor stays open with the text the user typed + } else { + editing_item_ = open_item_ = nullptr; + } + } +} + +void SignalView::queueCommit(SignalModel::Item *item, const ItemValue &value) { + pending_commit_ = [this, item, value]() { model_.setData(item, value); }; +} + +void SignalView::drawValueDescriptionDlg() { + if (!desc_dlg_) return; + if (desc_dlg_->draw()) return; + + if (desc_dlg_->accepted) { + // the dialog closed: apply to the Desc item of the edited signal + for (auto sig_item : model_.root()->children) { + if (sig_item->sig != desc_sig_) continue; + for (auto child : sig_item->children) { + if (child->type != SignalModel::Item::ExtraInfo) continue; + for (auto extra : child->children) { + if (extra->type == SignalModel::Item::Desc) queueCommit(extra, desc_dlg_->val_desc); + } + } + } + } + desc_dlg_.reset(); + desc_sig_ = nullptr; +} + +SignalView::SignalView(ChartsWidget *charts) : charts_(charts) { + settings.sparkline_range = std::clamp(settings.sparkline_range, 1, SPARKLINE_RANGE_MAX); + + // seed the size of the [plot][remove] widget (two 22px tool buttons plus the spacing) so the first + // updateState() calls already leave room for the sparklines + button_size_ = ImVec2(22 * 2 + TOOLBAR_ITEM_SPACING, 22); + updateToolBar(); + + connections_.push_back(model_.rowsChanged.connect([this]() { rowsChanged(); })); + // a reset closes the open editors; the items they point at are deleted by refresh() + connections_.push_back(model_.modelReset.connect([this]() { + closeEditor(); + // the visible range is computed while drawing; reset it to the top so the sparklines are ready in the + // frame that paints them + if (first_visible_row_ != -1) { + last_visible_row_ = std::min(model_.rowCount() - 1, last_visible_row_ - first_visible_row_); + first_visible_row_ = 0; + } + })); + connections_.push_back(dbc()->signalAdded.connect([this](MessageId id, const cabana::Signal *sig) { handleSignalAdded(id, sig); })); + connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { handleSignalUpdated(sig); })); + // the sig pointers die with the signal + connections_.push_back(dbc()->signalRemoved.connect([this](const cabana::Signal *sig) { + if (desc_sig_ == sig) desc_sig_ = nullptr; + if ((editing_item_ && editing_item_->sig == sig) || (open_item_ && open_item_->sig == sig) || + (focus_item_ && focus_item_->sig == sig)) closeEditor(); + handleSignalRemoved(sig); + })); + connections_.push_back(dbc()->fileChanged.connect([this]() { + desc_sig_ = nullptr; + closeEditor(); + handleSignalRemoved(nullptr); + })); + connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool) { updateState(msgs); })); +} + +std::string SignalView::whatsThis() const { + return R"( + Signal view
+ )"; +} + +void SignalView::setMessage(const MessageId &id) { + filter_edit_.clear(); + model_.setMessage(id); +} + +void SignalView::rowsChanged() { + updateToolBar(); + updateChartState(); + updateState(); +} + +void SignalView::rowClicked(SignalModel::Item *item) { + if (item->type == SignalModel::Item::Sig || item->type == SignalModel::Item::ExtraInfo) { + item->expanded = !item->expanded; + } +} + +void SignalView::selectSignal(const cabana::Signal *sig, bool expand) { + if (int row = model_.signalRow(sig); row != -1) { + auto item = model_.root()->children[row]; + if (expand) { + item->expanded = !item->expanded; + } + scroll_to_sig_ = sig; // scroll the signal to the top + current_sig_ = sig; + current_type_ = SignalModel::Item::Sig; + } +} + +void SignalView::updateChartState() { + for (auto item : model_.root()->children) { + item->chart_opened = charts_->hasSignal(model_.msgId(), item->sig); + } +} + +void SignalView::signalHovered(const cabana::Signal *sig) { + auto &children = model_.root()->children; + for (int i = 0; i < children.size(); ++i) { + children[i]->highlight = children[i]->sig == sig; + } +} + +void SignalView::updateToolBar() { + signal_count_lb_ = "Signals: " + std::to_string(model_.rowCount()); + sparkline_label_ = utils::formatSeconds(settings.sparkline_range); +} + +void SignalView::setSparklineRange(int value) { + settings.sparkline_range = value; + updateToolBar(); + updateState(); +} + +void SignalView::handleSignalAdded(MessageId id, const cabana::Signal *sig) { + if (id.address == model_.msgId().address) { + selectSignal(sig); + } +} + +void SignalView::handleSignalUpdated(const cabana::Signal *sig) { + if (int row = model_.signalRow(sig); row != -1) + updateState(); +} + +void SignalView::handleSignalRemoved(const cabana::Signal *sig) { + if (!sig || current_sig_ == sig) { + // the current index moves to the row that took the removed one, or to the last row + current_sig_ = nullptr; + auto &children = model_.root()->children; + if (sig && !children.empty() && current_row_ >= 0) { + current_sig_ = children[std::min(current_row_, children.size() - 1)]->sig; + current_type_ = SignalModel::Item::Sig; + } + } + if (!sig || scroll_to_sig_ == sig) scroll_to_sig_ = nullptr; + if (!sig || hovered_sig_ == sig) hovered_sig_ = nullptr; +} + +float SignalView::widestValueWidth(const cabana::Signal *sig) { + const double raw_max = sig->is_signed ? std::ldexp(1.0, sig->size - 1) - 1 : std::ldexp(1.0, sig->size) - 1; + const double raw_min = sig->is_signed ? -std::ldexp(1.0, sig->size - 1) : 0.0; + float width = 0; + for (double raw : {raw_min, raw_max}) { + width = std::max(width, textWidth(sig->formatValue(raw * sig->factor + sig->offset))); + } + for (const auto &[_, desc] : sig->val_desc) { + width = std::max(width, textWidth(desc)); + } + return width; +} + +void SignalView::updateState(const std::set *msgs) { + const auto &last_msg = can->lastMessage(model_.msgId()); + if (model_.rowCount() == 0 || (msgs && !msgs->count(model_.msgId())) || last_msg.dat.size() == 0) return; + + // sized for the widest value the signals can produce, not the widest one in the last message: sizing + // to the current values moved the sparklines every time a value changed length + float max_value_width = 0; + for (auto item : model_.root()->children) { + double value = 0; + if (item->sig->getValue(last_msg.dat.data(), last_msg.dat.size(), &value)) { + item->sig_val = item->sig->formatValue(value); + } + max_value_width = std::max(max_value_width, widestValueWidth(item->sig)); + } + + if (first_visible_row_ != -1 && last_visible_row_ != -1 && last_visible_row_ < model_.rowCount()) { + const float min_max_width = textWidth("-000.00", MINMAX_FONT) + 5; + float available_width = value_column_width_ - button_size_.x; + float value_width = std::min(max_value_width + min_max_width, available_width / 2); + ImVec2 size(std::floor(available_width - value_width), + std::floor(signalRowHeight() - V_MARGIN * 2)); + + // the window ends at the playback clock, not at the last message: its timestamp only moves when a + // message of this id arrives, so a slow message held the sparkline still for several updates and + // then jumped it, which reads as a hitch at the message rate + const double window_end = can->currentSec(); + // a little data from before the window: the sparkline clips it, so the oldest samples and their + // points slide off the left edge instead of disappearing the moment they age out + const double lead_in = settings.sparkline_range * 0.05; + // plain locals: capturing structured bindings in a lambda is C++20 + const auto range = can->eventsInRange(model_.msgId(), std::make_pair(window_end - settings.sparkline_range - lead_in, window_end)); + const CanEventIter first = range.first, last = range.second; + std::vector> futures; + for (int i = first_visible_row_; i <= last_visible_row_; ++i) { + auto item = model_.root()->children[i]; + futures.push_back(ThreadPool::instance().run([item, first, last, size, window_end]() { + item->sparkline.update(item->sig, first, last, settings.sparkline_range, size, window_end); + })); + } + for (auto &f : futures) f.get(); + } +} + +// the sparkline label, the range slider and the collapse button +float SignalView::toolBarRightWidth(const std::string &range_label) { + const ImGuiStyle &style = ImGui::GetStyle(); + return ImGui::CalcTextSize(range_label.c_str()).x + style.ItemSpacing.x + SPARKLINE_SLIDER_WIDTH + style.ItemSpacing.x + + ImGui::GetFont()->CalcTextSizeA(COLLAPSE_ICON_SIZE, FLT_MAX, 0.0f, icon::DASH_SQUARE).x + style.FramePadding.x * 2; +} + +// the width at which the tool bar stops squishing: the signal count and the filter box on the left, the +// sparkline controls on the right, plus the borders and padding of the view's own child window +float SignalView::minimumWidth() { + const ImGuiStyle &style = ImGui::GetStyle(); + const float left_width = ImGui::CalcTextSize("Signals: 000").x + style.ItemSpacing.x + FILTER_WIDTH; + // formatSeconds is mm:ss for every value the range slider allows + return left_width + style.ItemSpacing.x + toolBarRightWidth("00:00") + (style.WindowPadding.x + style.ChildBorderSize) * 2; +} + +void SignalView::draw() { + if (!ImGui::BeginChild("SignalView", ImVec2(0, 0), ImGuiChildFlags_Borders)) { + ImGui::EndChild(); + return; + } + + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(signal_count_lb_.c_str()); + ImGui::SameLine(); + ImGui::SetNextItemWidth(FILTER_WIDTH); + if (clearableInput("##filter_edit", &filter_edit_, "Filter Signal", nonWhitespaceValidator)) { + model_.setFilter(filter_edit_); + } + + // stretch: the sparkline controls sit at the right edge + alignRight(toolBarRightWidth(sparkline_label_)); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(sparkline_label_.c_str()); + ImGui::SameLine(); + int range = settings.sparkline_range; + if (fusionSliderInt("##sparkline_range_slider", &range, 1, SPARKLINE_RANGE_MAX, SPARKLINE_SLIDER_WIDTH)) { + setSparklineRange(range); + } + ImGui::SetItemTooltip("Sparkline time range"); + ImGui::SameLine(); + // auto-raise tool button with a 12x12 icon + ImGui::PushFont(ImGui::GetFont(), COLLAPSE_ICON_SIZE); + const bool collapse = toolButton("collapse_all", icon::DASH_SQUARE, "Collapse All"); + ImGui::PopFont(); + if (collapse) collapseAll(); + + drawTree(); + drawValueDescriptionDlg(); + // model changes run after the tree is drawn: dbc()->signalUpdated/signalRemoved reorder or delete the rows + if (pending_commit_) std::exchange(pending_commit_, nullptr)(); + if (pending_action_) std::exchange(pending_action_, nullptr)(); + current_row_ = model_.signalRow(current_sig_); // used when the row is removed + + ImGui::EndChild(); +} + +void SignalView::collapseAll() { + commitEditor(); // the editor loses the focus, which commits it + for (auto item : model_.root()->children) { + item->expanded = false; + for (auto child : item->children) child->expanded = false; + } +} + +void SignalView::drawTree() { + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + const float min_height = std::max(ImGui::GetContentRegionAvail().y, 300.0f); + const bool visible = ImGui::BeginChild("tree", ImVec2(0, min_height), ImGuiChildFlags_None); + ImGui::PopStyleVar(); + if (visible) { + DrawContext ctx{ImGui::GetWindowDrawList(), ImGui::GetCursorScreenPos().x, ImGui::GetContentRegionAvail().x, rowHeight()}; + // the press that closes an open editor is consumed by the focus change, the index widgets never see it + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) editor_open_on_press_ = open_item_ != nullptr; + + int first_visible = -1, last_visible = -1; + auto &children = model_.root()->children; + for (int i = 0; i < children.size(); ++i) { + ctx.any_visible = false; + const bool header_visible = drawItem(children[i], 0, ctx); + if (header_visible && first_visible == -1) first_visible = i; + if (ctx.any_visible) last_visible = i; + } + if (first_visible == -1 && last_visible != -1) last_visible = -1; + // the rows that just became visible have no sparkline yet + bool changed = first_visible != first_visible_row_ || last_visible != last_visible_row_; + first_visible_row_ = first_visible; + last_visible_row_ = last_visible; + scroll_to_sig_ = nullptr; + + if (ctx.name_width > 0) name_column_width_ = ctx.name_width; + if (ctx.value_column_width > 0 && ctx.value_column_width != value_column_width_) { + value_column_width_ = ctx.value_column_width; + changed = true; + } + if (changed) updateState(); + + // a press on the viewport that hits no row clears the selection and the current index; rowClicked() + // does not run + if (!ctx.mouse_on_row && ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + current_sig_ = nullptr; + current_type_ = SignalModel::Item::Root; + } + + if (ctx.hovered_sig != hovered_sig_) { + hovered_sig_ = ctx.hovered_sig; + highlight(hovered_sig_); + } + } + ImGui::EndChild(); + ImGui::PopStyleVar(); +} + +bool SignalView::drawItem(SignalModel::Item *item, int depth, DrawContext &ctx) { + const bool selected = item->sig == current_sig_ && item->type == current_type_; + const float row_height = item->type == SignalModel::Item::Sig ? signalRowHeight() : ctx.row_height; + const ImVec2 row_min = ImGui::GetCursorScreenPos(); + const ImVec2 row_max(row_min.x + ctx.width, row_min.y + row_height); + const bool row_visible = ImGui::IsRectVisible(row_min, row_max); + ctx.any_visible |= row_visible; + + ImGui::PushID(item); + ImGui::BeginDisabled(!SignalModel::isEnabled(item)); + const bool row_clicked = viewSelectable("##row", selected, ImGuiSelectableFlags_AllowOverlap, ImVec2(0, row_height)); + // a press on the branch indicator only toggles the expansion; the current index does not change and + // rowClicked() does not run + const float branch_x = row_min.x + depth * INDENTATION; + const bool on_branch = !item->children.empty() && ImGui::GetMousePos().x >= branch_x && + ImGui::GetMousePos().x < branch_x + INDENTATION; + if (row_clicked && on_branch) { + item->expanded = !item->expanded; + } else if (row_clicked) { + current_sig_ = item->sig; + current_type_ = item->type; + // the new current item opens its editor. The name column and the non-editable cells (signal rows, + // check boxes) have no editor, so a click there only makes the cell current. + closeEditor(); + if (SignalModel::isEditable(item) && ImGui::GetMousePos().x >= row_min.x + name_column_width_) { + focus_item_ = open_item_ = item; + } + rowClicked(item); + } + if (item->type == SignalModel::Item::Sig && item->sig == scroll_to_sig_) { + ImGui::SetScrollHereY(0.0f); + scroll_to_sig_ = nullptr; + } + if (ImGui::IsMouseHoveringRect(row_min, row_max)) { + ctx.mouse_on_row = true; + if (ImGui::IsWindowHovered()) ctx.hovered_sig = item->sig; + } + + if (!item->children.empty()) { + const float arrow_size = ImGui::GetFontSize() * 0.7f; + ImGui::RenderArrow(ctx.draw_list, ImVec2(row_min.x + depth * INDENTATION + 4.0f, row_min.y + (row_height - arrow_size) * 0.5f), + ImGui::GetColorU32(ImGuiCol_Text), item->expanded ? ImGuiDir_Down : ImGuiDir_Right, 0.7f); + } + + // every row is measured, the header sizes column 0 to the contents of the whole tree + const std::string text0 = nameText(item); + ctx.name_width = std::max(ctx.name_width, nameColumnWidth(item, ctx.width, text0)); + const ImRect rect1(ImVec2(row_min.x + name_column_width_, row_min.y), row_max); + ctx.value_column_width = rect1.GetWidth(); + + // a row outside the viewport is not painted and has no index widget, like a QTreeView row. The row that + // holds the open editor is always submitted, so scrolling it out does not drop the edit. + const bool editor_open = selected && open_item_ == item; + if (row_visible || editor_open) { + const ImRect rect0(ImVec2(row_min.x + (depth + 1) * INDENTATION, row_min.y), ImVec2(row_min.x + name_column_width_, row_max.y)); + paintCell(ctx.draw_list, rect0, item, 0, selected, text0, ctx.viewport_x); + if (item->type == SignalModel::Item::Sig && ImGui::IsMouseHoveringRect(ImVec2(row_min.x, row_min.y), rect0.Max) && + ImGui::IsItemHovered(ImGuiHoveredFlags_ForTooltip) && ImGui::BeginTooltip()) { + ImGui::TextUnformatted(utils::stripHtml(utils::signalToolTip(item->sig)).c_str()); + ImGui::EndTooltip(); + } + + if (item->type == SignalModel::Item::Sig) { + paintCell(ctx.draw_list, rect1, item, 1, selected, item->sig_val, ctx.viewport_x); + drawIndexWidget(item, rect1); + } else if (SignalModel::isCheckable(item)) { + bool checked = item->type == SignalModel::Item::Endian ? item->sig->is_little_endian : item->sig->is_signed; + ImGui::SetCursorScreenPos(ImVec2(rect1.Min.x + H_MARGIN, rect1.Min.y)); + if (checkBox("##check", &checked)) queueCommit(item, checked); + } else if (SignalModel::isEditable(item) && editor_open) { + // only the current item gets an editor; the others are painted as text + ImGui::SetCursorScreenPos(rect1.Min); + ImGui::SetNextItemWidth(rect1.GetWidth()); + drawEditor(item); + } else { + paintCell(ctx.draw_list, rect1, item, 1, selected, model_.valueText(item), ctx.viewport_x); + } + } + ImGui::EndDisabled(); + ImGui::PopID(); + ImGui::SetCursorScreenPos(ImVec2(row_min.x, row_max.y)); + + if (item->expanded) { + for (auto child : item->children) drawItem(child, depth + 1, ctx); + } + return row_visible; +} + +void SignalView::drawIndexWidget(SignalModel::Item *item, const ImRect &rect) { + // plot_btn + remove_btn, right aligned in the value column + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(3.0f, 2.0f)); + const ImVec2 btn_size(ImGui::CalcTextSize(icon::GRAPH_UP).x + 6.0f, ImGui::GetFrameHeight()); + const ImVec2 size(btn_size.x * 2 + TOOLBAR_ITEM_SPACING, btn_size.y); + ImGui::SetCursorScreenPos(ImVec2(rect.Max.x - size.x, rect.Min.y + (rect.GetHeight() - size.y) * 0.5f)); + + const auto sig = item->sig; + const bool checked = item->chart_opened; + if (checked) ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive)); + if (ImGui::Button((std::string(icon::GRAPH_UP) + "##plot").c_str(), btn_size) && !editor_open_on_press_) { + item->chart_opened = !checked; + showChart(model_.msgId(), sig, item->chart_opened, ImGui::GetIO().KeyShift); + } + if (checked) ImGui::PopStyleColor(); + ImGui::SetItemTooltip("%s", checked ? "Close Plot" : "Show Plot\nSHIFT click to add to previous opened plot"); + ImGui::SameLine(0.0f, TOOLBAR_ITEM_SPACING); + if (ImGui::Button((std::string(icon::X) + "##remove").c_str(), btn_size) && !editor_open_on_press_) { + pending_action_ = [this, sig]() { UndoStack::instance()->push(new RemoveSigCommand(model_.msgId(), sig)); }; + } + ImGui::SetItemTooltip("Remove signal"); + ImGui::PopStyleVar(); + button_size_ = size; +} + +ValueDescriptionDlg::ValueDescriptionDlg(const ValueDescription &descriptions) { + for (auto &[val, desc] : descriptions) { + table_.emplace_back(utils::toString(val), desc); + } +} + +bool ValueDescriptionDlg::draw() { + const std::string popup_id = title + "###ValueDescriptionDlg"; + if (!opened_) { + ImGui::OpenPopup(popup_id.c_str()); + opened_ = true; + } + setNextDialogWindow(ImVec2(500.0f, 0.0f)); + bool open = true; + // not drawn while the dock is collapsed or another modal is on top; only closed once the popup is gone + if (!ImGui::BeginPopupModal(popup_id.c_str(), &open, ImGuiWindowFlags_NoSavedSettings)) return ImGui::IsPopupOpen(popup_id.c_str()); + + bool closing = false; + if (ImGui::Button(icon::PLUS)) { + table_.emplace_back("", ""); + } + ImGui::SameLine(); + ImGui::BeginDisabled(current_row_ == -1); + if (ImGui::Button(icon::DASH) && current_row_ < table_.size()) { + table_.erase(table_.begin() + current_row_); + current_row_ = -1; + } + ImGui::EndDisabled(); + + const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY; + if (ImGui::BeginTable("table", 3, flags, ImVec2(0.0f, 300.0f))) { + ImGui::TableSetupScrollFreeze(1, 1); + // vertical header: the 1 based row number + ImGui::TableSetupColumn("##row_number", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoHeaderLabel, + ImGui::CalcTextSize("000").x + ImGui::GetStyle().CellPadding.x * 2); + ImGui::TableSetupColumn("Value", ImGuiTableColumnFlags_WidthFixed, 120.0f); + ImGui::TableSetupColumn("Description", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableHeadersRow(); + for (int row = 0; row < table_.size(); ++row) { + ImGui::PushID(row); + ImGui::TableNextRow(); + if (row == current_row_) ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, ImGui::GetColorU32(ImGuiCol_Header)); + ImGui::TableSetColumnIndex(0); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(std::to_string(row + 1).c_str()); + ImGui::TableSetColumnIndex(1); + ImGui::SetNextItemWidth(-FLT_MIN); + if (valueDescriptionEditor(0, &table_[row].first)) current_row_ = row; + ImGui::TableSetColumnIndex(2); + ImGui::SetNextItemWidth(-FLT_MIN); + if (valueDescriptionEditor(1, &table_[row].second)) current_row_ = row; + ImGui::PopID(); + } + ImGui::EndTable(); + } + + bool accept = false, reject = false; + if (dialogButtons("OK", &accept, &reject)) { + if (accept) save(); + closing = true; + } + if (!open) closing = true; + if (closing) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + return !closing; +} + +void ValueDescriptionDlg::save() { + for (int i = 0; i < table_.size(); ++i) { + std::string val = utils::trimmed(table_[i].first); + std::string desc = utils::trimmed(table_[i].second); + if (!val.empty() && !desc.empty()) { + val_desc.push_back({utils::toDouble(val), desc}); + } + } + accepted = true; +} diff --git a/openpilot/tools/cabana/ui/widgets/signalview.h b/openpilot/tools/cabana/ui/widgets/signalview.h new file mode 100644 index 0000000000..eaeb6d2aed --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/signalview.h @@ -0,0 +1,201 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/core/observable.h" +#include "tools/cabana/ui/chart/chartswidget.h" +#include "tools/cabana/ui/chart/sparkline.h" +#include "tools/cabana/utils/strings.h" + +// the value SignalModel::setData takes: text, a number, a check state or a value table +class ItemValue { +public: + ItemValue(const std::string &s) : str_(s) {} + ItemValue(int v) : str_(std::to_string(v)) {} + ItemValue(bool v) : str_(v ? "1" : "0") {} + ItemValue(const ValueDescription &v) : val_desc_(v) {} + ItemValue(const char *) = delete; // a literal would bind to ItemValue(bool) + std::string toString() const { return str_; } + int toInt() const { return utils::toInt(str_); } + bool toBool() const { return str_ == "1"; } + double toDouble() const { return utils::toDouble(str_); } + const ValueDescription &toValueDescription() const { return val_desc_; } + +private: + std::string str_; + ValueDescription val_desc_; +}; + +class SignalModel { +public: + struct Item { + enum Type {Root, Sig, Name, Size, Node, Endian, Signed, Offset, Factor, SignalType, MultiplexValue, ExtraInfo, Unit, Comment, Min, Max, Desc }; + ~Item() { for (auto c : children) delete c; } + inline int row() const { + auto it = std::find(parent->children.begin(), parent->children.end(), this); + return it != parent->children.end() ? std::distance(parent->children.begin(), it) : -1; + } + + Type type = Type::Root; + Item *parent = nullptr; + std::vector children; + + const cabana::Signal *sig = nullptr; + std::string title; + bool highlight = false; + std::string sig_val = "-"; + Sparkline sparkline; + bool expanded = false; + bool chart_opened = false; // plot_btn checked state + }; + + SignalModel(); + Item *root() const { return root_.get(); } + const MessageId &msgId() const { return msg_id_; } + int rowCount() const { return root_->children.size(); } + static bool isEnabled(const Item *item); + static bool isEditable(const Item *item); // a leaf cell in the value column with an editor + static bool isCheckable(const Item *item); // Endian and Signed + std::string valueText(const Item *item) const; + bool setData(Item *item, const ItemValue &value); + void setMessage(const MessageId &id); + void setFilter(const std::string &txt); + bool saveSignal(const cabana::Signal *origin_s, cabana::Signal &s); + int signalRow(const cabana::Signal *sig) const; + + Observable<> rowsChanged; // rows were added, removed or reset + Observable<> modelReset; // the items are gone: the view closes its editors + +private: + void insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig); + void handleSignalAdded(MessageId id, const cabana::Signal *sig); + void handleSignalUpdated(const cabana::Signal *sig); + void handleSignalRemoved(const cabana::Signal *sig); + void handleMsgChanged(MessageId id); + void refresh(); + + MessageId msg_id_; + std::string filter_str_; + std::unique_ptr root_; + Connections connections_; +}; + +// non-blocking: draw() returns false once closed, `accepted` tells whether Ok was pressed +class ValueDescriptionDlg { +public: + ValueDescriptionDlg(const ValueDescription &descriptions); + bool draw(); + ValueDescription val_desc; + std::string title; + bool accepted = false; + +private: + void save(); + std::vector> table_; // rows of {Value, Description} + int current_row_ = -1; + bool opened_ = false; +}; + +class SignalView { +public: + SignalView(ChartsWidget *charts); + void setMessage(const MessageId &id); + void draw(); + static float minimumWidth(); + void signalHovered(const cabana::Signal *sig); // handler for BinaryView::signalHovered + void updateChartState(); + void selectSignal(const cabana::Signal *sig, bool expand = false); + bool saveSignal(const cabana::Signal *origin, cabana::Signal &s) { return model_.saveSignal(origin, s); } + std::string whatsThis() const; + + Observable highlight; + Observable showChart; + +private: + void rowsChanged(); + void rowClicked(SignalModel::Item *item); + static float toolBarRightWidth(const std::string &range_label); + void updateToolBar(); + void setSparklineRange(int value); + void handleSignalAdded(MessageId id, const cabana::Signal *sig); + void handleSignalUpdated(const cabana::Signal *sig); + void handleSignalRemoved(const cabana::Signal *sig); // drops the row pointers to a removed signal (nullptr: all) + void updateState(const std::set *msgs = nullptr); + + struct DrawContext { + ImDrawList *draw_list; + float viewport_x; + float width; + float row_height; + float name_width = 0; + float value_column_width = 0; + bool any_visible = false; + bool mouse_on_row = false; + const cabana::Signal *hovered_sig = nullptr; + }; + void drawTree(); + bool drawItem(SignalModel::Item *item, int depth, DrawContext &ctx); // returns whether the row is visible + void drawIndexWidget(SignalModel::Item *item, const ImRect &rect); // the [plot][remove] widget + void collapseAll(); + static float widestValueWidth(const cabana::Signal *sig); + + // viewport_x: left edge of the tree viewport + void paintCell(ImDrawList *painter, const ImRect &rect, const SignalModel::Item *item, int column, bool selected, + const std::string &text, float viewport_x) const; + float nameColumnWidth(const SignalModel::Item *item, float widget_width, const std::string &text) const; + // draws the editor for `item` at the cursor; commits through queueCommit on focus out + void drawEditor(SignalModel::Item *item); + // queues the commit in pending_commit_: EditSignalCommand fires dbc()->signalUpdated synchronously, which reorders + // the rows, so the model is only changed after the tree is drawn (see draw) + void queueCommit(SignalModel::Item *item, const ItemValue &value); + void drawValueDescriptionDlg(); // continuation of the ValueDescriptionDlg opened in drawEditor + static float textWidth(const std::string &text, float font_size = 0); + void closeEditor(); + void commitEditor(); // Qt commits an open editor on focus out + // only an Acceptable value is committed + void drawLineEditor(SignalModel::Item *item, ImGuiInputTextCallback validator, bool take_focus); + static ValidState validateEditor(const SignalModel::Item *item, std::string &text); + + float value_column_width_ = 0; + float name_column_width_ = 150; + bool editor_open_on_press_ = false; + // computed while drawing the tree: the first top-level row whose own row is visible (a signal whose header + // is scrolled out but whose children are visible is skipped), and the last top-level row with any visible row + int first_visible_row_ = -1; + int last_visible_row_ = -1; + const cabana::Signal *current_sig_ = nullptr; + int current_row_ = -1; // row of current_sig_ at the end of the last draw() + SignalModel::Item::Type current_type_ = SignalModel::Item::Root; + const cabana::Signal *scroll_to_sig_ = nullptr; + const cabana::Signal *hovered_sig_ = nullptr; + std::function pending_action_; // button clicks that destroy rows run after the tree is drawn + std::string sparkline_label_; + std::string filter_edit_; + ChartsWidget *charts_; + std::string signal_count_lb_; + + ImVec2 button_size_ = {}; + // the editor is created for the item that just became current and takes the focus + SignalModel::Item *focus_item_ = nullptr; + // the item whose editor is open; closeEditor() returns the cell to the painted text while the row stays current + SignalModel::Item *open_item_ = nullptr; + std::function pending_commit_; + SignalModel::Item *editing_item_ = nullptr; // the open text editor + std::string edit_text_; + bool editor_active_ = false; // editor had the keyboard focus last frame + bool refocus_editor_ = false; // reopen the editor rejected by the validator + bool enter_pressed_ = false; + bool combo_focused_ = false; // the SignalType combo had the focus last frame + std::unique_ptr desc_dlg_; + const cabana::Signal *desc_sig_ = nullptr; + SignalModel model_; + Connections connections_; +}; diff --git a/openpilot/tools/cabana/ui/widgets/tabbar.cc b/openpilot/tools/cabana/ui/widgets/tabbar.cc new file mode 100644 index 0000000000..5e194b965e --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/tabbar.cc @@ -0,0 +1,92 @@ +#include "tools/cabana/ui/widgets/tabbar.h" + +#include +#include + +#include "tools/cabana/ui/widgets/scrollabletabbar.h" + +int TabBar::addTab(const std::string &text) { + tabs_.push_back({text, 0, next_id_++}); + int index = count() - 1; + if (current_index_ == -1) { // the first tab is current + current_index_ = index; + select_current_ = true; + currentChanged(index); + } + return index; +} + +void TabBar::setCurrentIndex(int index) { + if (index == current_index_ || index < -1 || index >= count()) return; + current_index_ = index; + select_current_ = true; + currentChanged(index); +} + +int TabBar::tabAt(const ImVec2 &pos) const { + for (int i = 0; i < count(); ++i) { + if (tabs_[i].rect.Contains(pos)) return i; + } + return -1; +} + +void TabBar::removeTab(int index) { + tabs_.erase(tabs_.begin() + index); + if (index == current_index_) { + // select the tab that moved into this index, else the one to the left + current_index_ = count() ? std::min(index, count() - 1) : -1; + select_current_ = true; + currentChanged(current_index_); + } else if (index < current_index_) { + --current_index_; + } +} + +void TabBar::moveTab(int from, int to) { + if (from == to || from < 0 || from >= count() || to < 0 || to >= count()) return; + const int current_id = current_index_ >= 0 ? tabs_[current_index_].id : -1; + Tab tab = std::move(tabs_[from]); + tabs_.erase(tabs_.begin() + from); + tabs_.insert(tabs_.begin() + to, std::move(tab)); + for (int i = 0; i < count(); ++i) { + if (tabs_[i].id == current_id) current_index_ = i; + } + select_current_ = true; // imgui orders the tabs as submitted only when a tab is (re)selected +} + +void TabBar::draw() { + if (auto_hide_ && count() < 2) return; // auto hidden with fewer than two tabs + ImGui::PushID(this); + // no default tooltip, the tabs carry their own + if (!(scroll_buttons_ ? beginScrollableTabBar("##tabbar", ImGuiTabBarFlags_NoTooltip) : ImGui::BeginTabBar("##tabbar", ImGuiTabBarFlags_NoTooltip))) { + ImGui::PopID(); + return; + } + // every tab gets a close button, not only the hovered/selected one + ImGuiStyle &style = ImGui::GetStyle(); + const float close_button_min_width = tabs_closable_ ? std::exchange(style.TabCloseButtonMinWidthUnselected, -1.0f) : 0.0f; + // setCurrentIndex requests are applied on the next frame + const bool select_current = std::exchange(select_current_, false); + int close_index = -1; + for (int i = 0; i < count(); ++i) { + bool open = true; + const std::string label = tabs_[i].text + "###tab" + std::to_string(tabs_[i].id); + const ImGuiTabItemFlags flags = (select_current && i == current_index_) ? ImGuiTabItemFlags_SetSelected : 0; + if (ImGui::BeginTabItem(label.c_str(), tabs_closable_ ? &open : nullptr, flags)) { + // a programmatic selection takes effect on the next frame, ignore the old tab until then + if (!select_current && i != current_index_) { + current_index_ = i; + currentChanged(i); + } + ImGui::EndTabItem(); + } + tabs_[i].rect = ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + if (!tabs_[i].tooltip.empty()) ImGui::SetItemTooltip("%s", tabs_[i].tooltip.c_str()); + tabContextMenu(i); + if (!open) close_index = i; + } + if (tabs_closable_) style.TabCloseButtonMinWidthUnselected = close_button_min_width; + scroll_buttons_ ? endScrollableTabBar() : ImGui::EndTabBar(); + ImGui::PopID(); + if (close_index >= 0) tabCloseRequested(close_index); +} diff --git a/openpilot/tools/cabana/ui/widgets/tabbar.h b/openpilot/tools/cabana/ui/widgets/tabbar.h new file mode 100644 index 0000000000..9a8b7c7835 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/tabbar.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" + +#include "tools/cabana/core/observable.h" + +// QTabBar: tabs are closable when setTabsClosable(true) +class TabBar { +public: + TabBar() = default; + int addTab(const std::string &text); + int count() const { return (int)tabs_.size(); } + void setTabText(int index, const std::string &text) { if (index >= 0 && index < count()) tabs_[index].text = text; } + const std::string &tabText(int index) const { return tabs_[index].text; } + void setTabToolTip(int index, const std::string &tip) { if (index >= 0 && index < count()) tabs_[index].tooltip = tip; } + void setTabData(int index, int data) { if (index >= 0 && index < count()) tabs_[index].data = data; } + int tabData(int index) const { return index >= 0 && index < count() ? tabs_[index].data : 0; } + int currentIndex() const { return current_index_; } + void setCurrentIndex(int index); + int tabAt(const ImVec2 &pos) const; // -1 when no tab covers pos + void removeTab(int index); + void moveTab(int from, int to); + void setAutoHide(bool hide) { auto_hide_ = hide; } + void setTabsClosable(bool closable) { tabs_closable_ = closable; } // off by default + void setUsesScrollButtons(bool use) { scroll_buttons_ = use; } + void draw(); + + Observable currentChanged; + Observable tabCloseRequested; + Observable tabContextMenu; // emitted while drawing, right after the tab: open a context popup from it + +private: + struct Tab { std::string text; int data = 0; int id = 0; std::string tooltip; ImRect rect; }; + std::vector tabs_; + int current_index_ = -1; + int next_id_ = 0; + bool select_current_ = false; // programmatic current change, applied at the next draw() + bool auto_hide_ = false; + bool tabs_closable_ = false; + bool scroll_buttons_ = false; +}; diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc new file mode 100644 index 0000000000..fa6b9c7e6c --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -0,0 +1,612 @@ +#include "tools/cabana/ui/widgets/videowidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +} +#include + +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/threadpool.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" + +const int MIN_VIDEO_HEIGHT = 100; +const int THUMBNAIL_MARGIN = 3; +const float POINT_10_FONT_SIZE = 13.0f; // 10 pt at 96 dpi +const float POINT_16_FONT_SIZE = 21.0f; // 16 pt at 96 dpi +const float TOOLBAR_MARGIN_Y = 6.0f; // between the slider and the buttons, which are as tall as the ones in the charts toolbar +const float TOOLBAR_SEPARATOR_EXTENT = 6.0f; +const float SLIDER_HEIGHT = 15.0f; // the handle plus a 1 px margin + +// Indexed by TimelineType: None, Engaged, AlertInfo, AlertWarning, AlertCritical, UserBookmark +static const ImU32 timeline_colors[] = { + IM_COL32(111, 143, 175, 255), + IM_COL32(0, 163, 108, 255), + IM_COL32(0, 255, 0, 255), + IM_COL32(255, 195, 0, 255), + IM_COL32(199, 0, 57, 255), + IM_COL32(255, 0, 255, 255), +}; + +static const float speeds[] = {0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 0.8, 1., 2., 3., 5.}; +static const int NORMAL_SPEED_INDEX = std::find(std::begin(speeds), std::end(speeds), 1.0f) - std::begin(speeds); + +static Replay *getReplay() { + auto stream = dynamic_cast(can); + return stream ? stream->getReplay() : nullptr; +} + +static std::string colorName(ImU32 c) { + char buf[16]; + snprintf(buf, sizeof(buf), "#%02x%02x%02x", (c >> IM_COL32_R_SHIFT) & 0xff, (c >> IM_COL32_G_SHIFT) & 0xff, (c >> IM_COL32_B_SHIFT) & 0xff); + return buf; +} + +// the zoomed range, or the whole route +static std::pair displayedTimeRange() { + return can->timeRange().value_or(std::make_pair(can->minSeconds(), can->maxSeconds())); +} + +// decode with libavcodec, already linked for the replay video decoder +static bool decodeJpeg(const uint8_t *data, size_t size, RgbImage *out) { + const AVCodec *codec = avcodec_find_decoder(AV_CODEC_ID_MJPEG); + AVCodecContext *context = codec ? avcodec_alloc_context3(codec) : nullptr; + AVFrame *frame = av_frame_alloc(); + AVPacket *packet = av_packet_alloc(); + bool ok = false; + if (context && frame && packet && size > 0 && size <= (size_t)INT32_MAX && av_new_packet(packet, (int)size) >= 0) { + std::copy(data, data + size, packet->data); + ok = avcodec_open2(context, codec, nullptr) >= 0 && avcodec_send_packet(context, packet) >= 0 && + avcodec_receive_frame(context, frame) >= 0 && frame->width > 0 && frame->height > 0; + } + int chroma_x_shift = 0, chroma_y_shift = 0; + if (ok) { + switch ((AVPixelFormat)frame->format) { + case AV_PIX_FMT_YUV420P: case AV_PIX_FMT_YUVJ420P: chroma_x_shift = chroma_y_shift = 1; break; + case AV_PIX_FMT_YUV422P: case AV_PIX_FMT_YUVJ422P: chroma_x_shift = 1; break; + case AV_PIX_FMT_YUV444P: case AV_PIX_FMT_YUVJ444P: break; + default: ok = false; break; + } + } + if (ok) { + out->resize(frame->width, frame->height); + const bool full_range = frame->color_range == AVCOL_RANGE_JPEG || frame->format == AV_PIX_FMT_YUVJ420P || + frame->format == AV_PIX_FMT_YUVJ422P || frame->format == AV_PIX_FMT_YUVJ444P; + const float y_scale = full_range ? 1.0f : 1.164383f; + const float y_offset = full_range ? 0.0f : 16.0f; + const float kr = full_range ? 1.402f : 1.596027f; + const float kgu = full_range ? 0.344136f : 0.391762f; + const float kgv = full_range ? 0.714136f : 0.812968f; + const float kb = full_range ? 1.772f : 2.017232f; + for (int y = 0; y < frame->height; ++y) { + const uint8_t *y_row = frame->data[0] + y * frame->linesize[0]; + const uint8_t *u_row = frame->data[1] + (y >> chroma_y_shift) * frame->linesize[1]; + const uint8_t *v_row = frame->data[2] + (y >> chroma_y_shift) * frame->linesize[2]; + uint8_t *dst = out->data.data() + (size_t)y * out->bytesPerLine(); + for (int x = 0; x < frame->width; ++x) { + const float luma = y_scale * ((float)y_row[x] - y_offset); + const float u = (float)u_row[x >> chroma_x_shift] - 128.0f; + const float v = (float)v_row[x >> chroma_x_shift] - 128.0f; + const float r = luma + kr * v; + const float g = luma - kgu * u - kgv * v; + const float b = luma + kb * u; + dst[x * 4 + 0] = (uint8_t)std::clamp(std::lround(r), 0L, 255L); + dst[x * 4 + 1] = (uint8_t)std::clamp(std::lround(g), 0L, 255L); + dst[x * 4 + 2] = (uint8_t)std::clamp(std::lround(b), 0L, 255L); + dst[x * 4 + 3] = 255; + } + } + } + av_packet_free(&packet); + av_frame_free(&frame); + avcodec_free_context(&context); + return ok; +} + +VideoWidget::VideoWidget() { + if (!can->liveStreaming()) + createCameraWidget(); + + createSpeedDropdown(); + + connections_.push_back(can->timeRangeChanged.connect([this](const auto &) { timeRangeChanged(); })); + connections_.push_back(can->msgsReceived.connect([this](const std::set *, bool) { msgs_received_ = true; })); +} + +std::string VideoWidget::whatsThis() const { + // one
separated line per legend row, with the same entries and colors + return "Video
\n" + "Timeline color
\n" + + colorName(timeline_colors[(int)TimelineType::None]) + " Disengaged   " + + colorName(timeline_colors[(int)TimelineType::Engaged]) + " Engaged
\n" + + colorName(timeline_colors[(int)TimelineType::UserBookmark]) + " User Flag   " + + colorName(timeline_colors[(int)TimelineType::AlertInfo]) + " Info
\n" + + colorName(timeline_colors[(int)TimelineType::AlertWarning]) + " Warning   " + + colorName(timeline_colors[(int)TimelineType::AlertCritical]) + " Critical
\n" + "Shortcuts
\n" + "Pause/Resume:  space "; +} + +static float toolbarHeight() { return TOOLBAR_MARGIN_Y + ImGui::GetFrameHeight(); } + +void VideoWidget::drawPlaybackController() { + beginToolbar(); + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + TOOLBAR_MARGIN_Y); + const float speed_width = menuButtonWidth("0.05x ", true); + + const char *play_icon = can->isPaused() ? icon::PLAY : icon::PAUSE; + const char *play_tooltip = can->isPaused() ? "Play" : "Pause"; + const char *loop_icon = getReplay() && getReplay()->loop() ? icon::REPEAT : icon::REPEAT_1; + const std::string time_text = slider_ ? formatTime(can->currentSec(), true) + " / " + formatTime(slider_->maximum() / slider_->factor) + : formatTime(can->currentSec(), true); + const char *time_tooltip = settings.absolute_time ? "Elapsed time" : "Absolute time"; + + auto seek_backward = []() { can->seekTo(can->currentSec() - 1); }; + auto toggle_play = []() { can->pause(!can->isPaused()); }; + auto seek_forward = []() { can->seekTo(can->currentSec() + 1); }; + + std::vector items = { + {toolbarButtonWidth(icon::REWIND), [&]() { if (toolButton("rewind", icon::REWIND, "Seek backward")) seek_backward(); }, + "Seek backward", seek_backward}, + {toolbarButtonWidth(play_icon), [&]() { if (toolButton("play", play_icon, play_tooltip)) toggle_play(); }, + play_tooltip, toggle_play}, + {toolbarButtonWidth(icon::FAST_FORWARD), [&]() { if (toolButton("fast-forward", icon::FAST_FORWARD, "Seek forward")) seek_forward(); }, + "Seek forward", seek_forward}, + }; + if (can->liveStreaming()) { + items.push_back({toolbarButtonWidth(icon::SKIP_END), [&]() { + ImGui::BeginDisabled(!skip_to_end_enabled_); + if (toolButton("skip-end", icon::SKIP_END, "Skip to the end")) skipToEnd(); + ImGui::EndDisabled(); + }, "Skip to the end", [this]() { skipToEnd(); }, skip_to_end_enabled_}); + } + if (slider_ || msgs_received_) { + // a mono font: with proportional digits the time changed width as it ticked and the items after it moved + pushMonoFont(ImGui::GetFontSize()); + const float time_width = toolbarButtonWidth(time_text); + popMonoFont(); + items.push_back({time_width, + [&]() { + pushMonoFont(ImGui::GetFontSize()); + if (toolButton("time_display", time_text.c_str(), time_tooltip)) toggleTimeDisplay(); + popMonoFont(); + }, + time_text, [this]() { toggleTimeDisplay(); }}); + } + // the expanding spacer: the items after it are right aligned as long as everything fits + const size_t spacer_index = items.size(); + if (!can->liveStreaming()) { + items.push_back({toolbarButtonWidth(loop_icon), [&]() { if (toolButton("loop", loop_icon, "Loop playback")) loopPlaybackClicked(); }, + "Loop playback", [this]() { loopPlaybackClicked(); }}); + } + items.push_back({speed_width, [&]() { drawSpeedDropdown(speed_width); }}); + if (!can->liveStreaming()) { + ToolbarItem separator{TOOLBAR_SEPARATOR_EXTENT, []() { + // a 1 px separator line centered in TOOLBAR_SEPARATOR_EXTENT, inset from the top and bottom + const ImVec2 min = ImGui::GetCursorScreenPos(); + ImGui::Dummy(ImVec2(TOOLBAR_SEPARATOR_EXTENT, ImGui::GetFrameHeight())); + const float x = std::floor(min.x + TOOLBAR_SEPARATOR_EXTENT * 0.5f); + ImGui::GetWindowDrawList()->AddLine(ImVec2(x, min.y + 4.0f), ImVec2(x, min.y + ImGui::GetFrameHeight() - 4.0f), ImGui::GetColorU32(ImGuiCol_Separator)); + }}; + separator.in_menu = false; + items.push_back(std::move(separator)); + items.push_back({toolbarButtonWidth(icon::INFO_CIRCLE), + [&]() { if (toolButton("route_info", icon::INFO_CIRCLE, "View route details")) showRouteInfo(); }, + "View route details", [this]() { showRouteInfo(); }}); + } + + drawToolbar(items, spacer_index); + endToolbar(); +} + +void VideoWidget::skipToEnd() { + // set speed to 1.0; this only checks the menu entry, the speed and the button text are unchanged + speed_index_ = NORMAL_SPEED_INDEX; + can->pause(false); + can->seekTo(can->maxSeconds() + 1); +} + +void VideoWidget::toggleTimeDisplay() { + settings.absolute_time = !settings.absolute_time; +} + +static std::string speedText(float speed, const char *suffix) { + char buf[32]; + snprintf(buf, sizeof(buf), "%gx%s", speed, suffix); + return buf; +} + +void VideoWidget::createSpeedDropdown() { + speed_index_ = NORMAL_SPEED_INDEX; + can->setSpeed(speeds[speed_index_]); + speed_text_ = speedText(speeds[speed_index_], " "); +} + +void VideoWidget::drawSpeedDropdown(float width) { + menuButton("speed_btn", speed_text_, "speed_menu", true, width); + if (ImGui::BeginPopup("speed_menu")) { + drawSpeedMenuItems(); + ImGui::EndPopup(); + } +} + +void VideoWidget::drawSpeedMenuItems() { + // every row declares the same width, so the popup is exactly as wide as the widest one and all the + // highlights reach both edges; the label is padded on the right as much as the check column on the left + const float indent = ImGui::GetFontSize(); + float label_width = 0; + for (int i = 0; i < (int)std::size(speeds); ++i) { + label_width = std::max(label_width, ImGui::CalcTextSize(speedText(speeds[i], "").c_str()).x); + } + for (int i = 0; i < (int)std::size(speeds); ++i) { + const float speed = speeds[i]; + if (radioMenuItem(speedText(speed, "").c_str(), speed_index_ == i, indent + label_width + indent)) { + speed_index_ = i; + can->setSpeed(speed); + speed_text_ = speedText(speed, " "); + } + } +} + +void VideoWidget::createCameraWidget() { + camera_tab_ = std::make_unique(); + camera_tab_->setAutoHide(true); + + cam_widget_ = std::make_unique("camerad", VISION_STREAM_NARROW_ROAD); + + slider_ = std::make_unique(); + slider_->setTimeRange(can->minSeconds(), can->maxSeconds()); + + connections_.push_back(slider_->sliderReleased.connect([this]() { can->seekTo(slider_->currentSecond()); })); + connections_.push_back(cam_widget_->clicked.connect([]() { can->pause(!can->isPaused()); })); + connections_.push_back(cam_widget_->availableStreamsUpdated.connect([this](std::set streams) { vipcAvailableStreamsUpdated(streams); })); + connections_.push_back(camera_tab_->currentChanged.connect([this](int index) { + if (index != -1) cam_widget_->setStreamType((VisionStreamType)camera_tab_->tabData(index)); + })); + connections_.push_back(static_cast(can)->qLogLoaded.connect([this](std::shared_ptr qlog) { cam_widget_->parseQLog(qlog); })); +} + +void VideoWidget::drawCameraWidget() { + camera_tab_->draw(); + + // cam_widget_: minimum height MIN_VIDEO_HEIGHT, takes the space left by the slider and the toolbar + const ImVec2 avail = ImGui::GetContentRegionAvail(); + const float cam_height = std::max((float)MIN_VIDEO_HEIGHT, avail.y - SLIDER_HEIGHT - toolbarHeight()); + cam_widget_->draw(ImVec2(avail.x, cam_height), thumbnail_display_time_); + + if (!slider_->isSliderDown()) slider_->setCurrentSecond(can->currentSec()); + slider_->draw(thumbnail_display_time_); + updateSliderThumbnail(); +} + +void VideoWidget::vipcAvailableStreamsUpdated(std::set streams) { + static const std::string stream_names[] = {"Road camera", "Driver camera", "Wide road camera"}; + for (int i = 0; i < streams.size(); ++i) { + if (camera_tab_->count() <= i) { + camera_tab_->addTab(std::string()); + } + int type = *std::next(streams.begin(), i); + camera_tab_->setTabText(i, stream_names[type]); + camera_tab_->setTabData(i, type); + } + while (camera_tab_->count() > streams.size()) { + camera_tab_->removeTab(camera_tab_->count() - 1); + } +} + +void VideoWidget::loopPlaybackClicked() { + getReplay()->setLoop(!getReplay()->loop()); +} + +void VideoWidget::timeRangeChanged() { + const auto time_range = can->timeRange(); + if (can->liveStreaming()) { + skip_to_end_enabled_ = !time_range.has_value(); + return; + } + time_range ? slider_->setTimeRange(time_range->first, time_range->second) + : slider_->setTimeRange(can->minSeconds(), can->maxSeconds()); +} + +std::string VideoWidget::formatTime(double sec, bool include_milliseconds) { + if (settings.absolute_time) + sec += std::chrono::duration(can->beginDateTime().time_since_epoch()).count(); + return utils::formatSeconds(sec, include_milliseconds, settings.absolute_time); +} + +void VideoWidget::setVisible(bool visible) { + if (cam_widget_) cam_widget_->setVisible(visible); +} + +void VideoWidget::showThumbnail(double seconds) { + if (can->liveStreaming()) return; + thumbnail_display_time_ = seconds; +} + +void VideoWidget::showRouteInfo() { + // dropped from route_info_dlgs_ once draw() returns false + route_info_dlgs_.push_back(std::make_unique()); +} + +void VideoWidget::updateSliderThumbnail() { + if (slider_->underMouse()) { + auto [min_sec, max_sec] = displayedTimeRange(); + showThumbnail(min_sec + (ImGui::GetMousePos().x - slider_->rect().Min.x) * (max_sec - min_sec) / slider_->width()); + } else if (slider_->mouseLeft()) { + showThumbnail(-1); + } +} + +float VideoWidget::sizeHintHeight() const { + // the camera minimum height plus the slider and the toolbar + return MIN_VIDEO_HEIGHT + SLIDER_HEIGHT + toolbarHeight(); +} + +// the video pane opens with the camera at its natural aspect ratio, filling the width of the dock +float VideoWidget::defaultHeight(float width) const { + if (!cam_widget_) return toolbarHeight(); // live streams have no camera or slider + const float cam_height = std::max((float)MIN_VIDEO_HEIGHT, width / cam_widget_->frameAspectRatio()); + const float tab_height = camera_tab_->count() >= 2 ? ImGui::GetFrameHeight() : 0.0f; + return cam_height + tab_height + SLIDER_HEIGHT + toolbarHeight(); +} + +void VideoWidget::draw() { + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); + if (!can->liveStreaming()) + drawCameraWidget(); + + drawPlaybackController(); + ImGui::PopStyleVar(); + + for (auto it = route_info_dlgs_.begin(); it != route_info_dlgs_.end();) { + it = (*it)->draw() ? it + 1 : route_info_dlgs_.erase(it); + } +} + +void Slider::draw(double thumbnail_time) { + ImGui::InvisibleButton("##slider", ImVec2(std::max(1.0f, ImGui::GetContentRegionAvail().x), SLIDER_HEIGHT)); + rect_ = ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + const bool hovered = ImGui::IsItemHovered(); + left_ = hovered_ && !hovered; + hovered_ = hovered; + + if (ImGui::IsItemActivated()) handleMousePress(); + if (slider_down_) { + if (ImGui::IsItemActive()) { + // the handle keeps its grab offset while dragging + setValue(pixelPosToRangeValue(ImGui::GetMousePos().x - click_offset_)); + } else { + slider_down_ = false; + sliderReleased(); + } + } + paint(thumbnail_time); +} + +ImRect Slider::handleRect() const { + const float handle_width = SLIDER_LENGTH; + const float handle_height = std::min(SLIDER_THICKNESS, rect_.GetHeight()); + const int range = std::max(1, maximum() - minimum()); + const float x = rect_.Min.x + (float)(value() - minimum()) / range * std::max(0.0f, width() - handle_width); + const float y = rect_.GetCenter().y - handle_height / 2; + return ImRect(ImVec2(x, y), ImVec2(x + handle_width, y + handle_height)); +} + +// handle left edge (window x) -> value over the groove minus the handle width +int Slider::pixelPosToRangeValue(float x) const { + const float handle_width = SLIDER_LENGTH; + const float span = std::max(1.0f, width() - handle_width); + return minimum() + (int)std::lround((maximum() - minimum()) * std::clamp((x - rect_.Min.x) / span, 0.0f, 1.0f)); +} + +void Slider::paint(double thumbnail_time) { + ImDrawList *p = ImGui::GetWindowDrawList(); + + ImRect handle_rect = handleRect(); + ImRect groove_rect = rect_; + + // adjust the groove height to match the handle height, rounded up to whole pixels + float handle_height = handle_rect.GetHeight(); + const float groove_height = std::ceil(handle_height * 0.5f); + const float center_y = rect_.GetCenter().y; + groove_rect.Min.y = std::floor(center_y - groove_height / 2); + groove_rect.Max.y = groove_rect.Min.y + groove_height; + + p->AddRectFilled(groove_rect.Min, groove_rect.Max, timeline_colors[(int)TimelineType::None]); + + double min = minimum() / factor; + double max = maximum() / factor; + const double span = std::max(max - min, 1e-9); + + auto fillRange = [&](double begin, double end, ImU32 color) { + if (begin > max || end < min) return; + + // the edges truncate to whole pixels and the right edge is inclusive, so even an event shorter than a + // pixel paints one full pixel in its color instead of an anti-aliased smear + ImRect r = groove_rect; + r.Min.x = rect_.Min.x + std::floor(((std::max(min, begin) - min) / span) * width()); + r.Max.x = rect_.Min.x + std::floor(((std::min(max, end) - min) / span) * width()) + 1.0f; + p->AddRectFilled(r.Min, r.Max, color); + }; + + if (auto replay = getReplay()) { + for (const auto &entry : *replay->getTimeline()) { + fillRange(entry.start_time, entry.end_time, timeline_colors[(int)entry.type]); + } + + ImU32 empty_color = ImGui::GetColorU32(ImGuiCol_WindowBg, 160 / 255.0f); + const auto event_data = replay->getEventData(); + for (const auto &[n, _] : replay->route().segments()) { + if (!event_data->isSegmentLoaded(n)) + fillRange(n * 60.0, (n + 1) * 60.0, empty_color); + } + } + + drawSliderHandle(p, handle_rect); + + if (thumbnail_time >= 0) { + float left = rect_.Min.x + (float)((thumbnail_time - min) * width() / span) - 1; + ImRect rc(ImVec2(left, rect_.Min.y + 1), ImVec2(left + 2, rect_.Max.y - 1)); + p->AddRectFilled(rc.Min, rc.Max, ImGui::GetColorU32(ImGuiCol_Header), 1.5f); // ImGuiCol_Header is the theme highlight + } +} + +void Slider::handleMousePress() { + // a press on the handle starts a drag and remembers the grab offset + const ImRect handle_rect = handleRect(); + if (handle_rect.Contains(ImGui::GetMousePos())) { + slider_down_ = true; + click_offset_ = ImGui::GetMousePos().x - handle_rect.Min.x; + return; + } + setValue(minimum() + (int)(((maximum() - minimum()) * (ImGui::GetMousePos().x - rect_.Min.x)) / width())); + sliderReleased(); +} + +StreamCameraView::StreamCameraView(std::string stream_name, VisionStreamType stream_type) + : CameraWidget(stream_name, stream_type) { + big_thumbnail_texture_.mipmap = true; // the hover thumbnail is drawn at a quarter of the stored size +} + +StreamCameraView::~StreamCameraView() { + for (auto &pending : pending_thumbnails_) pending.done.wait(); +} + +void StreamCameraView::parseQLog(std::shared_ptr qlog) { + auto thumbnails = std::make_shared>(); + auto done = ThreadPool::instance().run([qlog, thumbnails]() { + for (const Event &e : qlog->events) { + if (e.which != cereal::Event::Which::THUMBNAIL) continue; + capnp::FlatArrayMessageReader reader(e.data); + auto thumb_data = reader.getRoot().getThumbnail(); + auto image_data = thumb_data.getThumbnail(); + if (RgbImage thumb; decodeJpeg(image_data.begin(), image_data.size(), &thumb)) { + (*thumbnails)[thumb_data.getTimestampEof()] = std::move(thumb); + } + } + }); + pending_thumbnails_.push_back({std::move(done), std::move(thumbnails)}); +} + +void StreamCameraView::collectThumbnails() { + for (auto it = pending_thumbnails_.begin(); it != pending_thumbnails_.end();) { + if (it->done.wait_for(std::chrono::seconds(0)) != std::future_status::ready) { + ++it; + continue; + } + for (auto &[ts, thumb] : *it->thumbnails) big_thumbnails_[ts] = std::move(thumb); + it = pending_thumbnails_.erase(it); + } +} + +void StreamCameraView::draw(const ImVec2 &size, double thumbnail_time) { + collectThumbnails(); + CameraWidget::draw(size); + + ImDrawList *p = ImGui::GetWindowDrawList(); + bool scrubbing = false; + if (thumbnail_time >= 0) { + scrubbing = can->isPaused(); + scrubbing ? drawScrubThumbnail(p, thumbnail_time) : drawThumbnail(p, thumbnail_time); + } + if (auto alert = getReplay()->findAlertAtTime(scrubbing ? thumbnail_time : can->currentSec())) { + drawAlert(p, rect(), *alert, ImGui::GetFontSize()); + } + + if (can->isPaused()) { + ImFont *font = boldFont(); + const char *text = "PAUSED"; + const ImVec2 text_size = font->CalcTextSizeA(POINT_16_FONT_SIZE, FLT_MAX, 0.0f, text); + const ImVec2 center = rect().GetCenter(); + p->AddText(font, POINT_16_FONT_SIZE, ImVec2(center.x - text_size.x / 2, center.y - text_size.y / 2), + IM_COL32(200, 200, 200, static_cast(255 * 0.7f)), text); + } +} + +const RgbImage *StreamCameraView::thumbnailAt(double sec, uint64_t *mono_time) { + auto it = big_thumbnails_.lower_bound(can->toMonoTime(sec)); + if (it == big_thumbnails_.end()) return nullptr; + if (big_thumbnail_texture_.id == 0 || big_thumbnail_texture_.key != it->first) { + big_thumbnail_texture_.upload(it->second); + big_thumbnail_texture_.key = it->first; + } + if (mono_time) *mono_time = it->first; + return &it->second; +} + +void StreamCameraView::drawScrubThumbnail(ImDrawList *p, double sec) { + p->AddRectFilled(rect().Min, rect().Max, IM_COL32(0, 0, 0, 255)); + if (const RgbImage *image = thumbnailAt(sec, nullptr)) { + // scale to the widget size, keeping the aspect ratio + const float scale = std::min(width() / image->width, height() / image->height); + const ImVec2 scaled_size(std::floor(image->width * scale), std::floor(image->height * scale)); + const ImVec2 center = rect().GetCenter(); + const ImVec2 thumb_min(center.x - (int)(scaled_size.x / 2), center.y - (int)(scaled_size.y / 2)); + ImRect thumb_rect(thumb_min, ImVec2(thumb_min.x + scaled_size.x, thumb_min.y + scaled_size.y)); + p->AddImage(big_thumbnail_texture_.ref(), thumb_rect.Min, thumb_rect.Max); + drawTime(p, thumb_rect, sec); + } +} + +void StreamCameraView::drawThumbnail(ImDrawList *p, double sec) { + uint64_t mono_time = 0; + if (const RgbImage *image = thumbnailAt(sec, &mono_time)) { + // AddImage scales the stored image to the thumbnail height, keeping the aspect ratio + const int h = MIN_VIDEO_HEIGHT - THUMBNAIL_MARGIN * 2; + const int w = std::max(1, (int)std::lround((double)image->width * h / image->height)); + auto [min_sec, max_sec] = displayedTimeRange(); + int pos = (sec - min_sec) * width() / (max_sec - min_sec); + const int max_x = (int)width() - w - THUMBNAIL_MARGIN + 1; + int x = std::clamp(pos - w / 2, THUMBNAIL_MARGIN, std::max(THUMBNAIL_MARGIN, max_x)); + int y = height() - h - THUMBNAIL_MARGIN; + + ImRect thumb_rect(ImVec2(rect().Min.x + x, rect().Min.y + y), ImVec2(rect().Min.x + x + w, rect().Min.y + y + h)); + p->AddImage(big_thumbnail_texture_.ref(), thumb_rect.Min, thumb_rect.Max); + p->AddRect(thumb_rect.Min, thumb_rect.Max, paletteBrightText(), 0.0f, 0, 2.0f); + if (auto alert = getReplay()->findAlertAtTime(can->toSeconds(mono_time))) { + drawAlert(p, thumb_rect, *alert, POINT_10_FONT_SIZE); + } + drawTime(p, thumb_rect, sec); + } +} + +void StreamCameraView::drawTime(ImDrawList *p, const ImRect &rect, double seconds) { + char text[32]; + snprintf(text, sizeof(text), "%.3f", seconds); + ImFont *font = ImGui::GetFont(); + const ImVec2 text_size = font->CalcTextSizeA(POINT_10_FONT_SIZE, FLT_MAX, 0.0f, text); + // centered horizontally, above the bottom margin + p->AddText(font, POINT_10_FONT_SIZE, ImVec2(rect.GetCenter().x - text_size.x / 2, rect.Max.y - THUMBNAIL_MARGIN - text_size.y), + paletteBrightText(), text); +} + +void StreamCameraView::drawAlert(ImDrawList *p, const ImRect &rect, const Timeline::Entry &alert, float font_size) { + const ImU32 pen = paletteBrightText(); + ImU32 color = withAlpha(timeline_colors[int(alert.type)], 128); + std::string text = alert.text1; + if (!alert.text2.empty()) text += "\n" + alert.text2; + + ImRect text_rect(ImVec2(rect.Min.x + 1, rect.Min.y + 1), ImVec2(rect.Max.x - 1, rect.Max.y - 1)); + ImFont *font = ImGui::GetFont(); + const float wrap_width = std::max(1.0f, text_rect.GetWidth()); + const ImVec2 r = font->CalcTextSizeA(font_size, FLT_MAX, wrap_width, text.c_str()); + p->AddRectFilled(ImVec2(text_rect.Min.x, text_rect.Min.y), ImVec2(text_rect.Max.x, text_rect.Min.y + r.y), color); + // each line is centered, wrapped continuations stay left aligned + float y = text_rect.Min.y; + for (const auto &line : utils::split(text, '\n')) { + const ImVec2 line_size = font->CalcTextSizeA(font_size, FLT_MAX, wrap_width, line.c_str()); + p->AddText(font, font_size, ImVec2(text_rect.Min.x + (text_rect.GetWidth() - line_size.x) / 2, y), pen, line.c_str(), nullptr, wrap_width); + y += line_size.y; + } +} diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.h b/openpilot/tools/cabana/ui/widgets/videowidget.h new file mode 100644 index 0000000000..44c3cd5312 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/videowidget.h @@ -0,0 +1,121 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" + +#include "tools/cabana/ui/widgets/cameraview.h" +#include "tools/cabana/ui/widgets/tabbar.h" +#include "tools/cabana/ui/tools/routeinfo.h" +#include "tools/replay/logreader.h" +#include "tools/cabana/streams/replaystream.h" +#include "tools/cabana/ui/icons.h" + +class Slider { +public: + Slider() = default; + double currentSecond() const { return value() / factor; } + void setCurrentSecond(double sec) { setValue(sec * factor); } + void setTimeRange(double min, double max) { setRange(min * factor, max * factor); } + int value() const { return value_; } + void setValue(int v) { value_ = std::clamp(v, minimum_, maximum_); } + void setRange(int min, int max) { minimum_ = min; maximum_ = std::max(min, max); setValue(value_); } + int minimum() const { return minimum_; } + int maximum() const { return maximum_; } + bool isSliderDown() const { return slider_down_; } + float width() const { return rect_.GetWidth(); } + const ImRect &rect() const { return rect_; } + bool underMouse() const { return hovered_; } + bool mouseLeft() const { return left_; } // the mouse left the slider in the last draw() + void draw(double thumbnail_time); // thumbnail_time < 0: no thumbnail marker + static constexpr double factor = 1000.0; + + Observable<> sliderReleased; + +private: + void handleMousePress(); + void paint(double thumbnail_time); + ImRect handleRect() const; + int pixelPosToRangeValue(float x) const; + int minimum_ = 0; + int maximum_ = 99; + int value_ = 0; + bool slider_down_ = false; + float click_offset_ = 0; // where inside the handle the drag started + bool hovered_ = false; + bool left_ = false; + ImRect rect_; +}; + +class StreamCameraView : public CameraWidget { +public: + StreamCameraView(std::string stream_name, VisionStreamType stream_type); + ~StreamCameraView(); + void draw(const ImVec2 &size, double thumbnail_time); // thumbnail_time < 0: no thumbnail + void parseQLog(std::shared_ptr qlog); // decodes the thumbnails on the thread pool + +private: + struct PendingThumbnails { + std::future done; + std::shared_ptr> thumbnails; + }; + void collectThumbnails(); // moves the decoded thumbnails in once a parseQLog task is done + // the first thumbnail at or after sec, uploaded to big_thumbnail_texture_; nullptr when there is none + const RgbImage *thumbnailAt(double sec, uint64_t *mono_time); + void drawAlert(ImDrawList *p, const ImRect &rect, const Timeline::Entry &alert, float font_size); + void drawThumbnail(ImDrawList *p, double sec); + void drawScrubThumbnail(ImDrawList *p, double sec); + void drawTime(ImDrawList *p, const ImRect &rect, double seconds); + + std::map big_thumbnails_; + GlTexture big_thumbnail_texture_; // the currently shown thumbnail + std::vector pending_thumbnails_; +}; + +class VideoWidget { +public: + VideoWidget(); + void draw(); // content only; MainWindow puts it in a child region above the charts + float sizeHintHeight() const; + float defaultHeight(float width) const; + // MainWindow calls this every frame with the video dock visibility, so the camera widget gets its + // vipc thread started and stopped + void setVisible(bool visible); + void showThumbnail(double seconds); + std::string whatsThis() const; + +private: + void updateSliderThumbnail(); // the thumbnail follows the mouse over the slider + std::string formatTime(double sec, bool include_milliseconds = false); + void timeRangeChanged(); + void createCameraWidget(); + void drawCameraWidget(); + void drawPlaybackController(); + void skipToEnd(); + void toggleTimeDisplay(); + void createSpeedDropdown(); + void drawSpeedDropdown(float width); + void drawSpeedMenuItems(); + void loopPlaybackClicked(); + void vipcAvailableStreamsUpdated(std::set streams); + void showRouteInfo(); + + std::unique_ptr cam_widget_; + std::string speed_text_; + int speed_index_ = -1; // checked entry of the speed menu + bool skip_to_end_enabled_ = true; + bool msgs_received_ = false; // the time is blank until the live stream delivers its first messages + double thumbnail_display_time_ = -1; + std::unique_ptr slider_; + std::unique_ptr camera_tab_; + std::vector> route_info_dlgs_; + Connections connections_; // last: disconnected before the widgets its handlers dereference are destroyed +}; diff --git a/openpilot/tools/cabana/utils/strings.h b/openpilot/tools/cabana/utils/strings.h index f6581c3164..1432ea9257 100644 --- a/openpilot/tools/cabana/utils/strings.h +++ b/openpilot/tools/cabana/utils/strings.h @@ -1,7 +1,11 @@ #pragma once +#include +#include +#include #include #include +#include #include #include @@ -12,6 +16,78 @@ namespace utils { std::string formatSeconds(double sec, bool include_milliseconds = false, bool absolute_time = false); std::string signalToolTip(const cabana::Signal *sig); +inline std::string trimmed(const std::string &s) { + const char *ws = " \t\n\r\f\v"; + size_t b = s.find_first_not_of(ws); + if (b == std::string::npos) return ""; + return s.substr(b, s.find_last_not_of(ws) - b + 1); +} + +inline bool containsCI(const std::string &s, const std::string &txt) { + auto it = std::search(s.begin(), s.end(), txt.begin(), txt.end(), + [](unsigned char a, unsigned char b) { return std::tolower(a) == std::tolower(b); }); + return it != s.end(); +} + +// drop the tags of a rich text tooltip for the plain text imgui tooltip +inline std::string stripHtml(const std::string &s) { + std::string out; + bool in_tag = false; + for (char c : s) { + if (c == '<') in_tag = true; + else if (c == '>') in_tag = false; + else if (!in_tag) out += c; + } + return trimmed(out); +} + +inline std::vector split(const std::string &s, char sep) { + std::vector parts; + size_t start = 0; + for (size_t pos; (pos = s.find(sep, start)) != std::string::npos; start = pos + 1) { + parts.push_back(s.substr(start, pos - start)); + } + parts.push_back(s.substr(start)); + return parts; +} + +inline std::string toString(double v) { + char buf[32]; + snprintf(buf, sizeof(buf), "%g", v); + return buf; +} + +// 0 when the text is not a valid number +inline double toDouble(const std::string &s) { + char *end = nullptr; + double v = std::strtod(s.c_str(), &end); + return (end != s.c_str() && *end == '\0') ? v : 0.0; +} + +// 0 when the text is not fully consumed +inline int toInt(const std::string &s) { + char *end = nullptr; + long v = std::strtol(s.c_str(), &end, 10); + return (end != s.c_str() && *end == '\0') ? (int)v : 0; +} + +// 0 when the text is not fully consumed +inline unsigned long toULong(const std::string &s, int base = 10) { + char *end = nullptr; + unsigned long v = std::strtoul(s.c_str(), &end, base); + return (end != s.c_str() && *end == '\0') ? v : 0; +} + +// "00".."FF" +inline const char *hexByte(uint8_t value) { + static const auto table = [] { + std::array t; + for (int i = 0; i < 256; ++i) snprintf(t[i], sizeof(t[i]), "%02X", i); + return t; + }(); + return table[value]; +} + inline std::string toHex(const std::vector &dat, char separator = '\0') { static const char digits[] = "0123456789ABCDEF"; std::string hex; diff --git a/openpilot/tools/cabana/utils/util.h b/openpilot/tools/cabana/utils/util.h index 324c966b5a..b2d53c415f 100644 --- a/openpilot/tools/cabana/utils/util.h +++ b/openpilot/tools/cabana/utils/util.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -90,6 +91,14 @@ std::string bootstrapSvg(const std::string &id); // empty if unknown template std::vector toBytes(const T &dat) { return {dat.begin(), dat.end()}; } +// a callback that is skipped once `alive` expired: the owner resets its token when it goes away +template +auto guarded(const std::shared_ptr &alive, F fn) { + return [alive = std::weak_ptr(alive), fn = std::move(fn)](auto &&...args) { + if (!alive.expired()) fn(std::forward(args)...); + }; +} + } // Watches SIGINT/SIGTERM via a self-pipe and a dedicated waiter thread. diff --git a/tools/op.sh b/tools/op.sh index 3d7d17a76b..43b28e34c1 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -445,7 +445,7 @@ function op_default() { echo -e "${BOLD}${UNDERLINE}Commands [Tooling]:${NC}" echo -e " ${BOLD}juggle${NC} Run PlotJuggler" echo -e " ${BOLD}replay${NC} Run Replay" - echo -e " ${BOLD}cabana${NC} Run Cabana" + echo -e " ${BOLD}cabana${NC} Run Cabana (--legacy for the old Qt version)" echo -e " ${BOLD}clip${NC} Run clip (linux only)" echo -e " ${BOLD}adb${NC} Run adb shell" echo -e " ${BOLD}ssh${NC} comma prime SSH helper" From c163743e32c8ed6408c6cad28f8f41fcbbf1b14d Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:20:23 -0700 Subject: [PATCH 003/122] cabana: better colors (#38758) --- openpilot/tools/cabana/ui/chart/sparkline.cc | 9 +-- openpilot/tools/cabana/ui/chart/sparkline.h | 2 +- .../tools/cabana/ui/dialogs/streamselector.cc | 8 ++- openpilot/tools/cabana/ui/style.cc | 72 ++++++++++--------- openpilot/tools/cabana/ui/util.h | 1 + .../tools/cabana/ui/widgets/binaryview.cc | 11 +-- .../tools/cabana/ui/widgets/signalview.cc | 4 +- openpilot/tools/cabana/utils/util.cc | 1 - openpilot/tools/cabana/utils/util.h | 21 +++--- 9 files changed, 69 insertions(+), 60 deletions(-) diff --git a/openpilot/tools/cabana/ui/chart/sparkline.cc b/openpilot/tools/cabana/ui/chart/sparkline.cc index 226483fd04..e5fe87182d 100644 --- a/openpilot/tools/cabana/ui/chart/sparkline.cc +++ b/openpilot/tools/cabana/ui/chart/sparkline.cc @@ -108,8 +108,9 @@ void Sparkline::render(const CabanaColor &color, int range, ImVec2 sz, double wi xscale_ = xscale; } -void Sparkline::draw(ImDrawList *draw_list, ImVec2 pos) const { +void Sparkline::draw(ImDrawList *draw_list, ImVec2 pos, ImU32 color) const { if (render_points_.empty()) return; + if (color == 0) color = color_; // update() only runs when a message of this id arrives, so a slow message would hold the sparkline // still for many frames and then move it in one step. scroll the rendered polyline by the time that @@ -132,14 +133,14 @@ void Sparkline::draw(ImDrawList *draw_list, ImVec2 pos) const { draw_list->PushClipRect(pos, ImVec2(pos.x + size.x, pos.y + size.y), true); // a point is a 3x3 square - auto draw_point = [&](const ImVec2 &p) { draw_list->AddRectFilled(ImVec2(p.x - 1.5f, p.y - 1.5f), ImVec2(p.x + 1.5f, p.y + 1.5f), color_); }; + auto draw_point = [&](const ImVec2 &p) { draw_list->AddRectFilled(ImVec2(p.x - 1.5f, p.y - 1.5f), ImVec2(p.x + 1.5f, p.y + 1.5f), color); }; if (draw_individual_points_) { for (const auto &p : render_points_) { draw_list->PathLineTo(point_at(p)); draw_point(point_at(p)); } - draw_list->PathStroke(color_, ImDrawFlags_None, 1.5f); + draw_list->PathStroke(color, ImDrawFlags_None, 1.5f); } else { // one sample per pixel column: several strokes in a column overlap into a blur, and a dense // high-contrast texture scrolling by is hard on the eyes @@ -167,7 +168,7 @@ void Sparkline::draw(ImDrawList *draw_list, ImVec2 pos) const { while (j + 1 < pts.size() && steep(j) == is_steep) ++j; draw_list->Flags = is_steep ? (saved & ~ImDrawListFlags_AntiAliasedLines) : saved; for (size_t n = i; n <= j; ++n) draw_list->PathLineTo(pts[n]); - draw_list->PathStroke(color_, ImDrawFlags_None, 1.0f); + draw_list->PathStroke(color, ImDrawFlags_None, 1.0f); i = j; } draw_list->Flags = saved; diff --git a/openpilot/tools/cabana/ui/chart/sparkline.h b/openpilot/tools/cabana/ui/chart/sparkline.h index bfee34c9ee..991ecb8d4c 100644 --- a/openpilot/tools/cabana/ui/chart/sparkline.h +++ b/openpilot/tools/cabana/ui/chart/sparkline.h @@ -12,7 +12,7 @@ public: inline double freq() const { return freq_; } bool isEmpty() const { return render_points_.empty(); } // emits the rendered polyline at pos (top-left, screen coordinates) - void draw(ImDrawList *draw_list, ImVec2 pos) const; + void draw(ImDrawList *draw_list, ImVec2 pos, ImU32 color = 0) const; ImVec2 size = {}; // empty when isEmpty() double min_val = 0; diff --git a/openpilot/tools/cabana/ui/dialogs/streamselector.cc b/openpilot/tools/cabana/ui/dialogs/streamselector.cc index 44336447c6..4c724c6fce 100644 --- a/openpilot/tools/cabana/ui/dialogs/streamselector.cc +++ b/openpilot/tools/cabana/ui/dialogs/streamselector.cc @@ -263,16 +263,19 @@ void StreamSelector::open(Callback on_done) { void StreamSelector::draw() { if (!open_) return; - if (!beginDialog("Open stream", &popup_, ImVec2(640.0f, 0.0f))) return; + if (!beginDialog("Open stream", &popup_, ImVec2(768.0f, 0.0f))) return; AbstractOpenStreamWidget *current = nullptr; + const ImVec4 pane = ImGui::GetStyleColorVec4(ImGuiCol_WindowBg); + ImGui::PushStyleColor(ImGuiCol_ChildBg, pane); + ImGui::PushStyleColor(ImGuiCol_TabSelected, pane); if (ImGui::BeginTabBar("streams")) { for (auto &w : widgets_) { // a fresh dialog every time, so the first tab is always the current one ImGuiTabItemFlags tab_flags = (first_frame_ && w == widgets_.front()) ? ImGuiTabItemFlags_SetSelected : 0; if (ImGui::BeginTabItem(w->title(), nullptr, tab_flags)) { current = w.get(); - ImGui::BeginChild("tab", ImVec2(0, 130.0f)); + ImGui::BeginChild("tab", ImVec2(0, 130.0f), ImGuiChildFlags_Borders); w->draw(); ImGui::EndChild(); ImGui::EndTabItem(); @@ -280,6 +283,7 @@ void StreamSelector::draw() { } ImGui::EndTabBar(); } + ImGui::PopStyleColor(2); first_frame_ = false; ImGui::AlignTextToFramePadding(); diff --git a/openpilot/tools/cabana/ui/style.cc b/openpilot/tools/cabana/ui/style.cc index b1b5c6cee6..8cbedad837 100644 --- a/openpilot/tools/cabana/ui/style.cc +++ b/openpilot/tools/cabana/ui/style.cc @@ -86,46 +86,44 @@ void applyTheme(int theme) { auto c = [](const CabanaColor &col, float a = 1.0f) { return colorRgb(col.r, col.g, col.b, a); }; ImVec4 *colors = style.Colors; if (dark) { - // the low contrast Darcula grays are opened up: text and outlines sit further from the window and - // base grays const ImVec4 highlight = c(DarkTheme::highlight); - const ImVec4 outline = colorRgb(0x5a, 0x5d, 0x60); + const ImVec4 outline = colorRgb(0x26, 0x26, 0x26); colors[ImGuiCol_WindowBg] = c(DarkTheme::window); colors[ImGuiCol_ChildBg] = c(DarkTheme::base); - colors[ImGuiCol_PopupBg] = c(DarkTheme::base); + colors[ImGuiCol_PopupBg] = c(DarkTheme::window); colors[ImGuiCol_MenuBarBg] = c(DarkTheme::window); colors[ImGuiCol_DockingEmptyBg] = c(DarkTheme::window); - colors[ImGuiCol_Text] = colorRgb(0xdc, 0xdc, 0xdc); - colors[ImGuiCol_TextDisabled] = colorRgb(0x8c, 0x8c, 0x8c); + colors[ImGuiCol_Text] = c(DarkTheme::text); + colors[ImGuiCol_TextDisabled] = c(DarkTheme::disabled_text); colors[ImGuiCol_Border] = outline; colors[ImGuiCol_BorderShadow] = colorRgb(0, 0, 0, 0.0f); - colors[ImGuiCol_FrameBg] = colorRgb(0x2e, 0x30, 0x32); // darker than the base so fields read as sunken - colors[ImGuiCol_FrameBgHovered] = colorRgb(0x3a, 0x3d, 0x40); - colors[ImGuiCol_FrameBgActive] = colorRgb(0x45, 0x48, 0x4b); - colors[ImGuiCol_Button] = c(DarkTheme::button); - colors[ImGuiCol_ButtonHovered] = colorRgb(0x52, 0x56, 0x59); - colors[ImGuiCol_ButtonActive] = colorRgb(0x2b, 0x2d, 0x30); + colors[ImGuiCol_FrameBg] = c(DarkTheme::base); + colors[ImGuiCol_FrameBgHovered] = colorRgb(0x1f, 0x1f, 0x1f); + colors[ImGuiCol_FrameBgActive] = colorRgb(0x24, 0x24, 0x24); + colors[ImGuiCol_Button] = colorRgb(0x3a, 0x3a, 0x3a); + colors[ImGuiCol_ButtonHovered] = colorRgb(0x42, 0x42, 0x42); + colors[ImGuiCol_ButtonActive] = colorRgb(0x30, 0x30, 0x30); colors[ImGuiCol_Header] = highlight; colors[ImGuiCol_HeaderHovered] = c(DarkTheme::highlight, 0.8f); colors[ImGuiCol_HeaderActive] = highlight; colors[ImGuiCol_CheckMark] = c(DarkTheme::bright_text); - colors[ImGuiCol_SliderGrab] = colorRgb(0x8f, 0x92, 0x95); - colors[ImGuiCol_SliderGrabActive] = colorRgb(0xa8, 0xab, 0xae); + colors[ImGuiCol_SliderGrab] = colorRgb(0x6a, 0x6a, 0x6a); + colors[ImGuiCol_SliderGrabActive] = colorRgb(0x80, 0x80, 0x80); colors[ImGuiCol_ScrollbarBg] = c(DarkTheme::window); - colors[ImGuiCol_ScrollbarGrab] = colorRgb(0x70, 0x73, 0x76); - colors[ImGuiCol_ScrollbarGrabHovered] = colorRgb(0x85, 0x88, 0x8b); - colors[ImGuiCol_ScrollbarGrabActive] = c(DarkTheme::light); + colors[ImGuiCol_ScrollbarGrab] = colorRgb(0x5a, 0x5a, 0x5a); + colors[ImGuiCol_ScrollbarGrabHovered] = colorRgb(0x6a, 0x6a, 0x6a); + colors[ImGuiCol_ScrollbarGrabActive] = colorRgb(0x7a, 0x7a, 0x7a); colors[ImGuiCol_Separator] = outline; colors[ImGuiCol_SeparatorHovered] = c(DarkTheme::highlight, 0.6f); colors[ImGuiCol_SeparatorActive] = highlight; colors[ImGuiCol_ResizeGrip] = colorRgb(0, 0, 0, 0.0f); colors[ImGuiCol_ResizeGripHovered] = c(DarkTheme::highlight, 0.6f); colors[ImGuiCol_ResizeGripActive] = highlight; - colors[ImGuiCol_Tab] = c(DarkTheme::window); - colors[ImGuiCol_TabHovered] = colorRgb(0x4b, 0x4e, 0x52); + colors[ImGuiCol_Tab] = colorRgb(0x2c, 0x2c, 0x2c); + colors[ImGuiCol_TabHovered] = colorRgb(0x3a, 0x3a, 0x3a); colors[ImGuiCol_TabSelected] = c(DarkTheme::base); colors[ImGuiCol_TabSelectedOverline] = highlight; - colors[ImGuiCol_TabDimmed] = c(DarkTheme::window); + colors[ImGuiCol_TabDimmed] = colorRgb(0x2c, 0x2c, 0x2c); colors[ImGuiCol_TabDimmedSelected] = c(DarkTheme::base); colors[ImGuiCol_TabDimmedSelectedOverline] = colorRgb(0, 0, 0, 0.0f); colors[ImGuiCol_TitleBg] = c(DarkTheme::window); @@ -133,7 +131,7 @@ void applyTheme(int theme) { colors[ImGuiCol_TitleBgCollapsed] = c(DarkTheme::window); colors[ImGuiCol_TableHeaderBg] = c(DarkTheme::window); colors[ImGuiCol_TableBorderStrong] = outline; - colors[ImGuiCol_TableBorderLight] = colorRgb(0x23, 0x26, 0x28); // darker than the cells, like the qt grid + colors[ImGuiCol_TableBorderLight] = colorRgb(0x2c, 0x2c, 0x2c); colors[ImGuiCol_TableRowBg] = colorRgb(0, 0, 0, 0.0f); colors[ImGuiCol_TableRowBgAlt] = colorRgb(0xff, 0xff, 0xff, 0.06f); colors[ImGuiCol_TextSelectedBg] = c(DarkTheme::highlight, 0.6f); @@ -145,11 +143,11 @@ void applyTheme(int theme) { } else { const ImVec4 window = colorRgb(0xef, 0xef, 0xef); const ImVec4 base = colorRgb(0xff, 0xff, 0xff); - const ImVec4 outline = colorRgb(0xb9, 0xb9, 0xb9); + const ImVec4 outline = colorRgb(0xab, 0xab, 0xab); const ImVec4 highlight = colorRgb(0x30, 0x8c, 0xc6); colors[ImGuiCol_WindowBg] = window; colors[ImGuiCol_ChildBg] = base; - colors[ImGuiCol_PopupBg] = colorRgb(0xfb, 0xfb, 0xfb); + colors[ImGuiCol_PopupBg] = colorRgb(0xf8, 0xf8, 0xf8); colors[ImGuiCol_MenuBarBg] = window; colors[ImGuiCol_DockingEmptyBg] = window; colors[ImGuiCol_Text] = colorRgb(0x00, 0x00, 0x00); @@ -159,9 +157,9 @@ void applyTheme(int theme) { colors[ImGuiCol_FrameBg] = base; colors[ImGuiCol_FrameBgHovered] = colorRgb(0xf7, 0xf7, 0xf7); colors[ImGuiCol_FrameBgActive] = colorRgb(0xef, 0xef, 0xef); - colors[ImGuiCol_Button] = colorRgb(0xf3, 0xf3, 0xf3); - colors[ImGuiCol_ButtonHovered] = colorRgb(0xf9, 0xf9, 0xf9); - colors[ImGuiCol_ButtonActive] = colorRgb(0xdc, 0xdc, 0xdc); + colors[ImGuiCol_Button] = colorRgb(0xf5, 0xf5, 0xf5); + colors[ImGuiCol_ButtonHovered] = colorRgb(0xfa, 0xfa, 0xfa); + colors[ImGuiCol_ButtonActive] = colorRgb(0xd9, 0xd9, 0xd9); colors[ImGuiCol_Header] = highlight; colors[ImGuiCol_HeaderHovered] = colorRgb(0x30, 0x8c, 0xc6, 0.8f); colors[ImGuiCol_HeaderActive] = highlight; @@ -178,11 +176,11 @@ void applyTheme(int theme) { colors[ImGuiCol_ResizeGrip] = colorRgb(0, 0, 0, 0.0f); colors[ImGuiCol_ResizeGripHovered] = colorRgb(0x30, 0x8c, 0xc6, 0.6f); colors[ImGuiCol_ResizeGripActive] = highlight; - colors[ImGuiCol_Tab] = colorRgb(0xe2, 0xe2, 0xe2); + colors[ImGuiCol_Tab] = colorRgb(0xdc, 0xdc, 0xdc); colors[ImGuiCol_TabHovered] = colorRgb(0xf5, 0xf5, 0xf5); colors[ImGuiCol_TabSelected] = base; colors[ImGuiCol_TabSelectedOverline] = highlight; - colors[ImGuiCol_TabDimmed] = colorRgb(0xe2, 0xe2, 0xe2); + colors[ImGuiCol_TabDimmed] = colorRgb(0xdc, 0xdc, 0xdc); colors[ImGuiCol_TabDimmedSelected] = base; colors[ImGuiCol_TabDimmedSelectedOverline] = colorRgb(0, 0, 0, 0.0f); colors[ImGuiCol_TitleBg] = window; @@ -190,7 +188,7 @@ void applyTheme(int theme) { colors[ImGuiCol_TitleBgCollapsed] = window; colors[ImGuiCol_TableHeaderBg] = colorRgb(0xf2, 0xf2, 0xf2); colors[ImGuiCol_TableBorderStrong] = outline; - colors[ImGuiCol_TableBorderLight] = colorRgb(0xd8, 0xd8, 0xd8); + colors[ImGuiCol_TableBorderLight] = colorRgb(0xc7, 0xc7, 0xc7); colors[ImGuiCol_TableRowBg] = colorRgb(0, 0, 0, 0.0f); colors[ImGuiCol_TableRowBgAlt] = colorRgb(0, 0, 0, 0.03f); colors[ImGuiCol_TextSelectedBg] = colorRgb(0x30, 0x8c, 0xc6, 0.35f); @@ -207,6 +205,12 @@ void applyTheme(int theme) { bool isDarkTheme() { return g_dark; } +CabanaColor signalFillColor(const CabanaColor &c) { + if (!g_dark) return c; + auto [h, s, v] = c.hsv(); + return CabanaColor::fromHsv(h, std::min(1.0f, s * 1.4f), v * 0.8f, c.a / 255.0f); +} + ImU32 highlightedTextColor() { return g_dark ? IM_COL32(DarkTheme::window_text.r, DarkTheme::window_text.g, DarkTheme::window_text.b, 255) : IM_COL32(255, 255, 255, 255); @@ -219,11 +223,11 @@ ImU32 paletteBrightText() { void drawSliderHandle(ImDrawList *p, const ImRect &r) { const bool dark = isDarkTheme(); - const ImU32 top = dark ? IM_COL32(0x3e, 0x41, 0x43, 255) : IM_COL32(255, 255, 255, 255); - const ImU32 bottom = dark ? IM_COL32(0x39, 0x3c, 0x3e, 255) : IM_COL32(0xf0, 0xf0, 0xf0, 255); + const ImU32 top = dark ? IM_COL32(0x41, 0x41, 0x41, 255) : IM_COL32(255, 255, 255, 255); + const ImU32 bottom = dark ? IM_COL32(0x36, 0x36, 0x36, 255) : IM_COL32(0xf0, 0xf0, 0xf0, 255); // the top/left edge is one step lighter than the bottom/right edge - const ImU32 outline_top = dark ? IM_COL32(0xa3, 0xa3, 0xa3, 255) : IM_COL32(0xab, 0xab, 0xab, 255); - const ImU32 outline_bottom = dark ? IM_COL32(0x9c, 0x9c, 0x9c, 255) : IM_COL32(0xa4, 0xa4, 0xa4, 255); + const ImU32 outline_top = dark ? IM_COL32(0x5c, 0x5c, 0x5c, 255) : IM_COL32(0xab, 0xab, 0xab, 255); + const ImU32 outline_bottom = dark ? IM_COL32(0x26, 0x26, 0x26, 255) : IM_COL32(0xa4, 0xa4, 0xa4, 255); p->AddRectFilled(r.Min, r.Max, top, 2.0f); p->AddRectFilled(ImVec2(r.Min.x, r.GetCenter().y), r.Max, bottom, 2.0f, ImDrawFlags_RoundCornersBottom); p->AddRect(r.Min, r.Max, outline_bottom, 2.0f, 0, 1.0f); @@ -237,7 +241,7 @@ void drawSliderHandle(ImDrawList *p, const ImRect &r) { bool fusionSliderInt(const char *label, int *v, int min, int max, float width) { // a grey groove over the full width with the part left of the handle filled, and a 13x13 handle on top - const ImU32 groove_col = isDarkTheme() ? IM_COL32(0x2a, 0x2c, 0x2e, 255) : IM_COL32(0xc4, 0xc4, 0xc4, 255); + const ImU32 groove_col = isDarkTheme() ? IM_COL32(0x2b, 0x2b, 0x2b, 255) : IM_COL32(0xc4, 0xc4, 0xc4, 255); const ImU32 fill_col = ImGui::GetColorU32(ImGuiCol_Header); ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32_BLACK_TRANS); ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, IM_COL32_BLACK_TRANS); diff --git a/openpilot/tools/cabana/ui/util.h b/openpilot/tools/cabana/ui/util.h index 99de047913..5739cfa3bc 100644 --- a/openpilot/tools/cabana/ui/util.h +++ b/openpilot/tools/cabana/ui/util.h @@ -137,6 +137,7 @@ void drawColorMarker(ImDrawList *dl, const ImVec2 &pos, ImU32 col); void loadFonts(); void applyTheme(int theme); // safe to call at runtime bool isDarkTheme(); // the theme applyTheme() resolved +CabanaColor signalFillColor(const CabanaColor &c); ImU32 highlightedTextColor(); ImU32 paletteBrightText(); diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.cc b/openpilot/tools/cabana/ui/widgets/binaryview.cc index 87e7213ea8..7f9ac8b7b2 100644 --- a/openpilot/tools/cabana/ui/widgets/binaryview.cc +++ b/openpilot/tools/cabana/ui/widgets/binaryview.cc @@ -455,13 +455,14 @@ void BinaryView::paintCell(ImDrawList *painter, const ImRect &rect, const Binary if (item->sigs.size() > 0) { for (auto &s : item->sigs) { if (s == hovered_sig_) { - painter->AddRectFilled(rect.Min, rect.Max, toImU32(s->color.darker(125))); // 4/5x brightness + painter->AddRectFilled(rect.Min, rect.Max, toImU32(signalFillColor(s->color).darker(125))); // 4/5x brightness } else { drawSignalCell(painter, rect, index, s); } } - } else if (item->valid && item->bg_color.alpha() > 0) { - painter->AddRectFilled(rect.Min, rect.Max, toImU32(item->bg_color)); + } else if (item->valid) { + if (isDarkTheme()) painter->AddRectFilled(rect.Min, rect.Max, IM_COL32(255, 255, 255, 20)); + if (item->bg_color.alpha() > 0) painter->AddRectFilled(rect.Min, rect.Max, toImU32(item->bg_color)); } bool bright = std::find(item->sigs.begin(), item->sigs.end(), hovered_sig_) != item->sigs.end(); pen = bright ? paletteBrightText() : paletteText(is_message_active_); @@ -524,9 +525,9 @@ void BinaryView::drawSignalCell(ImDrawList *painter, const ImRect &rect, const B if (bottom_notch) band(bottom_notch, rc.Max.y - spacing, rc.Max.y); auto item = &cellAt(index); - CabanaColor color = sig->color; + CabanaColor color = signalFillColor(sig->color); color.a = item->bg_color.alpha(); - const ImU32 edge = toImU32(sig->color.darker(125)); + const ImU32 edge = toImU32(signalFillColor(sig->color).darker(125)); for (const ImRect &clip : region) { painter->PushClipRect(clip.Min, clip.Max, true); diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index af926b5bb0..1eff979b02 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -267,7 +267,7 @@ void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const if (item->type == SignalModel::Item::Sig) { // color label ImRect icon_rect(rect.Min.x, rect.Min.y, rect.Min.x + COLOR_LABEL_WIDTH, rect.Max.y); - painter->AddRectFilled(icon_rect.Min, icon_rect.Max, toImU32(item->sig->color.darker(item->highlight ? 125 : 0)), 3.0f); + painter->AddRectFilled(icon_rect.Min, icon_rect.Max, toImU32(signalFillColor(item->sig->color).darker(item->highlight ? 125 : 0)), 3.0f); drawText(painter, icon_rect, std::to_string(item->row() + 1).c_str(), item->highlight ? IM_COL32_WHITE : IM_COL32_BLACK, nullptr, LABEL_FONT); @@ -289,7 +289,7 @@ void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const } else if (column == 1) { if (!item->sparkline.isEmpty()) { const ImVec2 sparkline_size = item->sparkline.size; - item->sparkline.draw(painter, rect.Min); + item->sparkline.draw(painter, rect.Min, selected ? text_color : 0); // min-max value rect.Min.x += sparkline_size.x + 1; float value_adjust = 10; diff --git a/openpilot/tools/cabana/utils/util.cc b/openpilot/tools/cabana/utils/util.cc index 7bdf225975..1d1c8c75e4 100644 --- a/openpilot/tools/cabana/utils/util.cc +++ b/openpilot/tools/cabana/utils/util.cc @@ -242,7 +242,6 @@ static std::unordered_map load_bootstrap_icons() { } namespace utils { - std::string homePath() { const char *home = ::getenv("HOME"); return home ? home : ""; diff --git a/openpilot/tools/cabana/utils/util.h b/openpilot/tools/cabana/utils/util.h index b2d53c415f..203c504c44 100644 --- a/openpilot/tools/cabana/utils/util.h +++ b/openpilot/tools/cabana/utils/util.h @@ -60,19 +60,18 @@ ValidState validateIpAddress(const std::string &input); // C-locale floating-point ValidState validateDouble(const std::string &input); -// "Darcula" like dark theme struct DarkTheme { static constexpr CabanaColor window{0x35, 0x35, 0x35}; - static constexpr CabanaColor window_text{0xbb, 0xbb, 0xbb}; - static constexpr CabanaColor base{0x3c, 0x3f, 0x41}; - static constexpr CabanaColor tooltip_text{0xbb, 0xbb, 0xbb}; - static constexpr CabanaColor text{0xbb, 0xbb, 0xbb}; - static constexpr CabanaColor button{0x3c, 0x3f, 0x41}; - static constexpr CabanaColor highlight{0x2f, 0x65, 0xca}; - static constexpr CabanaColor bright_text{0xf0, 0xf0, 0xf0}; - static constexpr CabanaColor disabled_text{0x77, 0x77, 0x77}; - static constexpr CabanaColor light{0x77, 0x77, 0x77}; - static constexpr CabanaColor dark{0x35, 0x35, 0x35}; + static constexpr CabanaColor window_text{0xff, 0xff, 0xff}; + static constexpr CabanaColor base{0x19, 0x19, 0x19}; + static constexpr CabanaColor tooltip_text{0xff, 0xff, 0xff}; + static constexpr CabanaColor text{0xff, 0xff, 0xff}; + static constexpr CabanaColor button{0x35, 0x35, 0x35}; + static constexpr CabanaColor highlight{0x2a, 0x82, 0xda}; + static constexpr CabanaColor bright_text{0xff, 0xff, 0xff}; + static constexpr CabanaColor disabled_text{0x7f, 0x7f, 0x7f}; + static constexpr CabanaColor light{0x50, 0x50, 0x50}; + static constexpr CabanaColor dark{0x23, 0x23, 0x23}; }; namespace utils { From 7bade9a67a09b38c136a417a4197f1dfaa872588 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:28:53 -0700 Subject: [PATCH 004/122] cabana: halve dock panel min width (#38759) --- openpilot/tools/cabana/ui/mainwin.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index 15b6ff8df8..4a0aef7c79 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -787,8 +787,8 @@ void MainWindow::drawDockspace() { ImGui::DockBuilderFinish(dock_id); reset_layout_ = false; } - // a panel never shrinks past the width where the signal view's tool bar squishes - const float min_panel_width = SignalView::minimumWidth() + (ImGui::GetStyle().WindowPadding.x + ImGui::GetStyle().WindowBorderSize) * 2; + // a panel never shrinks past half the width where the signal view's tool bar squishes + const float min_panel_width = (SignalView::minimumWidth() + (ImGui::GetStyle().WindowPadding.x + ImGui::GetStyle().WindowBorderSize) * 2) * 0.5f; ImGui::PushStyleVar(ImGuiStyleVar_WindowMinSize, ImVec2(min_panel_width, ImGui::GetStyle().WindowMinSize.y)); ImGui::DockSpace(dock_id, dock_size); ImGui::PopStyleVar(); From 3a13b67b6f92c0716616342f285165a215ab7900 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:44:11 -0700 Subject: [PATCH 005/122] bye bye Qt! (#38753) * cabana: remove Qt implementation --- SConstruct | 1 - openpilot/tools/cabana/.gitignore | 7 - openpilot/tools/cabana/README.md | 40 +- openpilot/tools/cabana/SConscript | 123 +-- openpilot/tools/cabana/assets/assets.qrc | 5 - openpilot/tools/cabana/assets/cabana-icon.png | 3 - openpilot/tools/cabana/binaryview.cc | 510 ------------ openpilot/tools/cabana/binaryview.h | 106 --- openpilot/tools/cabana/cabana | 47 +- openpilot/tools/cabana/cabana.cc | 196 ----- openpilot/tools/cabana/cameraview.cc | 121 --- openpilot/tools/cabana/cameraview.h | 49 -- openpilot/tools/cabana/chart/chart.cc | 769 ------------------ openpilot/tools/cabana/chart/chart.h | 132 --- openpilot/tools/cabana/chart/chartswidget.cc | 660 --------------- openpilot/tools/cabana/chart/chartswidget.h | 140 ---- .../tools/cabana/chart/signalselector.cc | 107 --- openpilot/tools/cabana/chart/signalselector.h | 30 - openpilot/tools/cabana/chart/sparkline.cc | 101 --- openpilot/tools/cabana/chart/sparkline.h | 26 - openpilot/tools/cabana/chart/tiplabel.cc | 58 -- openpilot/tools/cabana/chart/tiplabel.h | 12 - openpilot/tools/cabana/detailwidget.cc | 323 -------- openpilot/tools/cabana/detailwidget.h | 78 -- openpilot/tools/cabana/historylog.cc | 251 ------ openpilot/tools/cabana/historylog.h | 83 -- openpilot/tools/cabana/mainwin.cc | 752 ----------------- openpilot/tools/cabana/mainwin.h | 122 --- openpilot/tools/cabana/messageswidget.cc | 467 ----------- openpilot/tools/cabana/messageswidget.h | 131 --- openpilot/tools/cabana/routesdialog.cc | 110 --- openpilot/tools/cabana/routesdialog.h | 31 - openpilot/tools/cabana/settingsdialog.cc | 88 -- openpilot/tools/cabana/settingsdialog.h | 20 - openpilot/tools/cabana/signalview.cc | 719 ---------------- openpilot/tools/cabana/signalview.h | 155 ---- openpilot/tools/cabana/streamselector.cc | 330 -------- openpilot/tools/cabana/streamselector.h | 99 --- openpilot/tools/cabana/tools/findsignal.cc | 286 ------- openpilot/tools/cabana/tools/findsignal.h | 71 -- .../tools/cabana/tools/findsimilarbits.cc | 161 ---- .../tools/cabana/tools/findsimilarbits.h | 36 - openpilot/tools/cabana/tools/routeinfo.cc | 40 - openpilot/tools/cabana/tools/routeinfo.h | 8 - openpilot/tools/cabana/utils/elidedlabel.cc | 29 - openpilot/tools/cabana/utils/elidedlabel.h | 25 - openpilot/tools/cabana/utils/qtutil.cc | 236 ------ openpilot/tools/cabana/utils/qtutil.h | 139 ---- openpilot/tools/cabana/videowidget.cc | 434 ---------- openpilot/tools/cabana/videowidget.h | 82 -- pyproject.toml | 2 +- tools/op.sh | 2 +- uv.lock | 16 +- 53 files changed, 50 insertions(+), 8519 deletions(-) delete mode 100644 openpilot/tools/cabana/assets/assets.qrc delete mode 100644 openpilot/tools/cabana/assets/cabana-icon.png delete mode 100644 openpilot/tools/cabana/binaryview.cc delete mode 100644 openpilot/tools/cabana/binaryview.h delete mode 100644 openpilot/tools/cabana/cabana.cc delete mode 100644 openpilot/tools/cabana/cameraview.cc delete mode 100644 openpilot/tools/cabana/cameraview.h delete mode 100644 openpilot/tools/cabana/chart/chart.cc delete mode 100644 openpilot/tools/cabana/chart/chart.h delete mode 100644 openpilot/tools/cabana/chart/chartswidget.cc delete mode 100644 openpilot/tools/cabana/chart/chartswidget.h delete mode 100644 openpilot/tools/cabana/chart/signalselector.cc delete mode 100644 openpilot/tools/cabana/chart/signalselector.h delete mode 100644 openpilot/tools/cabana/chart/sparkline.cc delete mode 100644 openpilot/tools/cabana/chart/sparkline.h delete mode 100644 openpilot/tools/cabana/chart/tiplabel.cc delete mode 100644 openpilot/tools/cabana/chart/tiplabel.h delete mode 100644 openpilot/tools/cabana/detailwidget.cc delete mode 100644 openpilot/tools/cabana/detailwidget.h delete mode 100644 openpilot/tools/cabana/historylog.cc delete mode 100644 openpilot/tools/cabana/historylog.h delete mode 100644 openpilot/tools/cabana/mainwin.cc delete mode 100644 openpilot/tools/cabana/mainwin.h delete mode 100644 openpilot/tools/cabana/messageswidget.cc delete mode 100644 openpilot/tools/cabana/messageswidget.h delete mode 100644 openpilot/tools/cabana/routesdialog.cc delete mode 100644 openpilot/tools/cabana/routesdialog.h delete mode 100644 openpilot/tools/cabana/settingsdialog.cc delete mode 100644 openpilot/tools/cabana/settingsdialog.h delete mode 100644 openpilot/tools/cabana/signalview.cc delete mode 100644 openpilot/tools/cabana/signalview.h delete mode 100644 openpilot/tools/cabana/streamselector.cc delete mode 100644 openpilot/tools/cabana/streamselector.h delete mode 100644 openpilot/tools/cabana/tools/findsignal.cc delete mode 100644 openpilot/tools/cabana/tools/findsignal.h delete mode 100644 openpilot/tools/cabana/tools/findsimilarbits.cc delete mode 100644 openpilot/tools/cabana/tools/findsimilarbits.h delete mode 100644 openpilot/tools/cabana/tools/routeinfo.cc delete mode 100644 openpilot/tools/cabana/tools/routeinfo.h delete mode 100644 openpilot/tools/cabana/utils/elidedlabel.cc delete mode 100644 openpilot/tools/cabana/utils/elidedlabel.h delete mode 100644 openpilot/tools/cabana/utils/qtutil.cc delete mode 100644 openpilot/tools/cabana/utils/qtutil.h delete mode 100644 openpilot/tools/cabana/videowidget.cc delete mode 100644 openpilot/tools/cabana/videowidget.h diff --git a/SConstruct b/SConstruct index bb5c35feb7..c6d758b318 100644 --- a/SConstruct +++ b/SConstruct @@ -87,7 +87,6 @@ acados_include_dirs = [ # vendored in commaai/dependencies. allowed_system_libs = { "EGL", "GLESv2", "GL", - "Qt5Charts", "Qt5Core", "Qt5Gui", "Qt5Widgets", "dl", "drm", "gbm", "m", "pthread", } diff --git a/openpilot/tools/cabana/.gitignore b/openpilot/tools/cabana/.gitignore index c5c752ba9c..788b286372 100644 --- a/openpilot/tools/cabana/.gitignore +++ b/openpilot/tools/cabana/.gitignore @@ -1,12 +1,5 @@ -moc_* -*.moc -*.generated.qrc - -assets.cc bootstrap_icons.cc -_cabana _cabana_ui dbc/car_fingerprint_to_dbc.json tests/test_cabana -tests/test_dbc_core diff --git a/openpilot/tools/cabana/README.md b/openpilot/tools/cabana/README.md index fbdfbb40e5..6fcce59eb0 100644 --- a/openpilot/tools/cabana/README.md +++ b/openpilot/tools/cabana/README.md @@ -6,29 +6,27 @@ Cabana is a tool developed to view raw CAN data. One use for this is creating an ```bash $ ./cabana -h -Usage: ./cabana [options] route +Usage: ./cabana [options] [route] + + route the drive to replay. find your drives at connect.comma.ai Options: - -h, --help Displays help on commandline options. - --help-all Displays help including Qt specific options. - --demo use a demo route instead of providing your own - --auto Auto load the route from the best available source (no video): - internal, openpilotci, comma_api, car_segments, testing_closet - --qcam load qcamera - --wide-road load wide road camera - --msgq read can messages from msgq - --panda read can messages from panda - --panda-serial read can messages from panda with given serial - --socketcan read can messages from given SocketCAN device - --zmq read can messages from zmq at the specified ip-address - messages - --data_dir local directory with routes - --no-vipc do not output video - --dbc dbc file to open - -Arguments: - route the drive to replay. find your drives at - connect.comma.ai + --help show this help + --demo use a demo route instead of providing your own + --auto Auto load the route from the best available source (no video): + internal, openpilotci, comma_api, car_segments, testing_closet + --qcam load qcamera + --wide-road load wide road camera (alias: --ecam) + --cabin load cabin camera (alias: --dcam) + --msgq read can messages from the msgq + --panda read can messages from panda + --panda-serial read can messages from panda with given serial + --socketcan read can messages from given SocketCAN device + --zmq read can messages from zmq at the specified ip-address + --data_dir local directory with routes + --no-vipc do not output video + --no-cache turn off the local route file cache + --dbc dbc file to open ``` ## Examples diff --git a/openpilot/tools/cabana/SConscript b/openpilot/tools/cabana/SConscript index a324e09d67..81fd8877c4 100644 --- a/openpilot/tools/cabana/SConscript +++ b/openpilot/tools/cabana/SConscript @@ -1,6 +1,4 @@ -import subprocess import os -import shutil import bootstrap_icons import imgui @@ -27,8 +25,7 @@ def build_bootstrap_icons_src(target, source, env): bootstrap_icons_src = env.Command('assets/bootstrap_icons.cc', str(bootstrap_icons.SVG_PATH), build_bootstrap_icons_src) -# Qt-free sources shared by the imgui frontend and the core test. Compiled in the base env, -# so the object names must not collide with the Qt build of the same sources. +# sources shared by the imgui frontend and the core test core_srcs = ['streams/pandastream.cc', 'streams/devicestream.cc', 'streams/livestream.cc', 'streams/abstractstream.cc', 'streams/replaystream.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', 'utils/export.cc', 'utils/util.cc', 'utils/strings.cc', 'commands.cc', 'settings.cc', 'routes.cc', 'panda.cc'] @@ -56,109 +53,25 @@ else: ui_libs += ['GL', 'dl'] cabana_ui = ui_env.Program('_cabana_ui', ui_objs, LIBS=ui_libs) -# Detect Qt - skip build if not available -if arch == "Darwin": - try: - brew_prefix = subprocess.check_output(['brew', '--prefix'], encoding='utf8').strip() - has_qt = os.path.isdir(os.path.join(brew_prefix, "opt/qt@5")) - except (FileNotFoundError, subprocess.CalledProcessError): - has_qt = False -else: - has_qt = shutil.which('qmake') is not None -if not has_qt: - Return() - -qt_env = env.Clone() -qt_modules = ["Widgets", "Gui", "Core"] - -qt_libs = [] -if arch == "Darwin": - qt_env['QTDIR'] = f"{brew_prefix}/opt/qt@5" - qt_dirs = [ - os.path.join(qt_env['QTDIR'], "include"), - ] - qt_dirs += [f"{qt_env['QTDIR']}/include/Qt{m}" for m in qt_modules] - qt_env["LINKFLAGS"] += ["-F" + os.path.join(qt_env['QTDIR'], "lib")] - qt_env["FRAMEWORKS"] += [f"Qt{m}" for m in qt_modules] - qt_env.AppendENVPath('PATH', os.path.join(qt_env['QTDIR'], "bin")) -else: - qt_install_prefix = subprocess.check_output(['qmake', '-query', 'QT_INSTALL_PREFIX'], encoding='utf8').strip() - qt_install_headers = subprocess.check_output(['qmake', '-query', 'QT_INSTALL_HEADERS'], encoding='utf8').strip() - - qt_env['QTDIR'] = qt_install_prefix - qt_dirs = [ - f"{qt_install_headers}", - ] - - qt_gui_path = os.path.join(qt_install_headers, "QtGui") - qt_gui_dirs = [d for d in os.listdir(qt_gui_path) if os.path.isdir(os.path.join(qt_gui_path, d))] - qt_dirs += [f"{qt_install_headers}/QtGui/{qt_gui_dirs[0]}/QtGui", ] if qt_gui_dirs else [] - qt_dirs += [f"{qt_install_headers}/Qt{m}" for m in qt_modules] - - qt_libs = [f"Qt5{m}" for m in qt_modules] -qt_env['QT3DIR'] = qt_env['QTDIR'] -qt_env.Tool('qt3') - -qt_env['CPPPATH'] += qt_dirs -qt_flags = [ - "-D_REENTRANT", - "-DQT_NO_DEBUG", - "-DQT_WIDGETS_LIB", - "-DQT_GUI_LIB", - "-DQT_CORE_LIB", - "-DQT_MESSAGELOGCONTEXT", -] -qt_env['CXXFLAGS'] += qt_flags -qt_env['LIBPATH'] += ['#openpilot/selfdrive/ui', ] -qt_env['LIBS'] = qt_libs - -base_frameworks = qt_env['FRAMEWORKS'] -base_libs = [common, messaging, cereal, visionipc, 'm', 'pthread'] + qt_env["LIBS"] - -if arch == "Darwin": - base_frameworks += ['CoreFoundation', 'CoreVideo', 'CoreMedia', 'IOKit', 'Security', 'VideoToolbox'] - -cabana_env = qt_env.Clone() -cabana_env['CPPPATH'] += [libusb.INCLUDE_DIR] -cabana_env['LIBPATH'] += [libusb.LIB_DIR] - -cabana_libs = [cereal, messaging, visionipc, replay_lib] + ffmpeg_libs + ['usb-1.0'] + base_libs -cabana_env['CXXFLAGS'] += [opendbc_path] - -# build assets -assets = "assets/assets.cc" -cabana_env.Command(assets, "assets/assets.qrc", f"rcc $SOURCES -o $TARGET") -cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, "assets/assets.o"])) - -cabana_srcs = ['mainwin.cc', 'binaryview.cc', 'historylog.cc', 'videowidget.cc', 'signalview.cc', 'routesdialog.cc', - 'utils/qtutil.cc', 'utils/elidedlabel.cc', - 'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc', - 'messageswidget.cc', 'streamselector.cc', 'settingsdialog.cc', - 'cameraview.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc', 'tools/routeinfo.cc'] + core_srcs -cabana_lib = cabana_env.Library("cabana_lib", cabana_srcs + [bootstrap_icons_src], LIBS=cabana_libs, FRAMEWORKS=base_frameworks) -cabana_env.Program('_cabana', ['cabana.cc', cabana_lib, assets], LIBS=cabana_libs, FRAMEWORKS=base_frameworks) - if GetOption('extras'): - # This target deliberately uses the base environment and links no Qt libraries. - # It prevents Qt dependencies from creeping back into the DBC core. - dbc_core_test_env = env.Clone() - dbc_core_test_env['CXXFLAGS'] += [opendbc_path] - dbc_core_test_objects = [ - dbc_core_test_env.Object('tests/dbc_core_tests', 'tests/test_cabana.cc'), - dbc_core_test_env.Object('tests/dbc_core_model', 'dbc/dbc.cc'), - dbc_core_test_env.Object('tests/dbc_core_file', 'dbc/dbcfile.cc'), - dbc_core_test_env.Object('tests/dbc_core_manager', 'dbc/dbcmanager.cc'), - dbc_core_test_env.Object('tests/dbc_core_strings', 'utils/strings.cc'), - dbc_core_test_env.Object('tests/dbc_core_util', 'utils/util.cc'), - dbc_core_test_env.Object('tests/dbc_core_icons', bootstrap_icons_src), - dbc_core_test_env.Object('tests/dbc_core_routes', 'routes.cc'), - dbc_core_test_env.Object('tests/dbc_core_qtstate', 'ui/qtstate.cc'), + cabana_core_test_env = env.Clone() + cabana_core_test_env['CXXFLAGS'] += [opendbc_path] + cabana_core_test_objects = [ + cabana_core_test_env.Object('tests/cabana_core_tests', 'tests/test_cabana.cc'), + cabana_core_test_env.Object('tests/cabana_core_model', 'dbc/dbc.cc'), + cabana_core_test_env.Object('tests/cabana_core_file', 'dbc/dbcfile.cc'), + cabana_core_test_env.Object('tests/cabana_core_manager', 'dbc/dbcmanager.cc'), + cabana_core_test_env.Object('tests/cabana_core_strings', 'utils/strings.cc'), + cabana_core_test_env.Object('tests/cabana_core_util', 'utils/util.cc'), + cabana_core_test_env.Object('tests/cabana_core_icons', bootstrap_icons_src), + cabana_core_test_env.Object('tests/cabana_core_routes', 'routes.cc'), + cabana_core_test_env.Object('tests/cabana_core_qtstate', 'ui/qtstate.cc'), ] - dbc_core_test_env.Program('tests/test_dbc_core', dbc_core_test_objects, LIBS=[replay_lib, common]) + cabana_core_test_env.Program('tests/test_cabana', cabana_core_test_objects, LIBS=[replay_lib, common]) output_json_file = 'openpilot/tools/cabana/dbc/car_fingerprint_to_dbc.json' -generate_dbc = cabana_env.Command('#' + output_json_file, - ['dbc/generate_dbc_json.py'], - "python3 openpilot/tools/cabana/dbc/generate_dbc_json.py --out " + output_json_file) -cabana_env.Depends(generate_dbc, ["#openpilot/common", '#opendbc_repo', "#openpilot/cereal", "#msgq_repo"]) +generate_dbc = ui_env.Command('#' + output_json_file, + ['dbc/generate_dbc_json.py'], + "python3 openpilot/tools/cabana/dbc/generate_dbc_json.py --out " + output_json_file) +ui_env.Depends(generate_dbc, ["#openpilot/common", '#opendbc_repo', "#openpilot/cereal", "#msgq_repo"]) ui_env.Depends(cabana_ui, generate_dbc) diff --git a/openpilot/tools/cabana/assets/assets.qrc b/openpilot/tools/cabana/assets/assets.qrc deleted file mode 100644 index 009d63f008..0000000000 --- a/openpilot/tools/cabana/assets/assets.qrc +++ /dev/null @@ -1,5 +0,0 @@ - - - cabana-icon.png - - diff --git a/openpilot/tools/cabana/assets/cabana-icon.png b/openpilot/tools/cabana/assets/cabana-icon.png deleted file mode 100644 index 2c3cfcd7d1..0000000000 --- a/openpilot/tools/cabana/assets/cabana-icon.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:015cea9d425f747bce856fd2f5a6a57fca322159dde54554de11eda076273595 -size 18008 diff --git a/openpilot/tools/cabana/binaryview.cc b/openpilot/tools/cabana/binaryview.cc deleted file mode 100644 index 20fd2ec1ec..0000000000 --- a/openpilot/tools/cabana/binaryview.cc +++ /dev/null @@ -1,510 +0,0 @@ -#include "tools/cabana/binaryview.h" - -#include - -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/commands.h" -#include "tools/cabana/utils/qtutil.h" - -// BinaryView - -const int CELL_HEIGHT = 36; -const int VERTICAL_HEADER_WIDTH = 30; -inline int get_bit_pos(const QModelIndex &index) { return flipBitPos(index.row() * 8 + index.column()); } - -BinaryView::BinaryView(QWidget *parent) : QTableView(parent) { - model = new BinaryViewModel(this); - setModel(model); - delegate = new BinaryItemDelegate(this); - setItemDelegate(delegate); - horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); - horizontalHeader()->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); - verticalHeader()->setSectionsClickable(false); - verticalHeader()->setSectionResizeMode(QHeaderView::Fixed); - verticalHeader()->setDefaultSectionSize(CELL_HEIGHT); - horizontalHeader()->hide(); - setShowGrid(false); - setMouseTracking(true); - setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - - connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); - connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { refresh(); })); - - addShortcuts(); - setWhatsThis(R"( - Binary View
- - Shortcuts
- Delete Signal: -  x , -  Backspace , -  Delete 
- Change endianness:  e 
- Change signedness:  s 
- Open chart: -  c , -  p , -  g  - )"); -} - -void BinaryView::addShortcuts() { - // Delete (x, backspace, delete) - QShortcut *shortcut_delete_x = new QShortcut(QKeySequence(Qt::Key_X), this); - QShortcut *shortcut_delete_backspace = new QShortcut(QKeySequence(Qt::Key_Backspace), this); - QShortcut *shortcut_delete_delete = new QShortcut(QKeySequence(Qt::Key_Delete), this); - QObject::connect(shortcut_delete_delete, &QShortcut::activated, shortcut_delete_x, &QShortcut::activated); - QObject::connect(shortcut_delete_backspace, &QShortcut::activated, shortcut_delete_x, &QShortcut::activated); - QObject::connect(shortcut_delete_x, &QShortcut::activated, [=]{ - if (hovered_sig != nullptr) { - UndoStack::instance()->push(new RemoveSigCommand(model->msg_id, hovered_sig)); - hovered_sig = nullptr; - } - }); - - // Change endianness (e) - QShortcut *shortcut_endian = new QShortcut(QKeySequence(Qt::Key_E), this); - QObject::connect(shortcut_endian, &QShortcut::activated, [=]{ - if (hovered_sig != nullptr) { - cabana::Signal s = *hovered_sig; - s.is_little_endian = !s.is_little_endian; - emit editSignal(hovered_sig, s); - } - }); - - // Change signedness (s) - QShortcut *shortcut_sign = new QShortcut(QKeySequence(Qt::Key_S), this); - QObject::connect(shortcut_sign, &QShortcut::activated, [=]{ - if (hovered_sig != nullptr) { - cabana::Signal s = *hovered_sig; - s.is_signed = !s.is_signed; - emit editSignal(hovered_sig, s); - } - }); - - // Open chart (c, p, g) - QShortcut *shortcut_plot = new QShortcut(QKeySequence(Qt::Key_P), this); - QShortcut *shortcut_plot_g = new QShortcut(QKeySequence(Qt::Key_G), this); - QShortcut *shortcut_plot_c = new QShortcut(QKeySequence(Qt::Key_C), this); - QObject::connect(shortcut_plot_g, &QShortcut::activated, shortcut_plot, &QShortcut::activated); - QObject::connect(shortcut_plot_c, &QShortcut::activated, shortcut_plot, &QShortcut::activated); - QObject::connect(shortcut_plot, &QShortcut::activated, [=]{ - if (hovered_sig != nullptr) { - emit showChart(model->msg_id, hovered_sig, true, false); - } - }); -} - -QSize BinaryView::minimumSizeHint() const { - return {(horizontalHeader()->minimumSectionSize() + 1) * 9 + VERTICAL_HEADER_WIDTH + 2, - CELL_HEIGHT * std::min(model->rowCount(), 10) + 2}; -} - -void BinaryView::highlight(const cabana::Signal *sig) { - if (sig != hovered_sig) { - for (int i = 0; i < model->items.size(); ++i) { - auto &item_sigs = model->items[i].sigs; - auto has = [](const auto &v, auto p) { return std::find(v.begin(), v.end(), p) != v.end(); }; - if ((sig && has(item_sigs, sig)) || (hovered_sig && has(item_sigs, hovered_sig))) { - auto index = model->index(i / model->columnCount(), i % model->columnCount()); - emit model->dataChanged(index, index, {Qt::DisplayRole}); - } - } - - hovered_sig = sig; - emit signalHovered(hovered_sig); - } -} - -void BinaryView::setSelection(const QRect &rect, QItemSelectionModel::SelectionFlags flags) { - auto index = indexAt(last_mouse_pos); - if (!anchor_index.isValid() || !index.isValid()) - return; - - QItemSelection selection; - auto [start, size, is_lb] = getSelection(index); - for (int i = 0; i < size; ++i) { - int pos = is_lb ? flipBitPos(start + i) : flipBitPos(start) + i; - selection << QItemSelectionRange{model->index(pos / 8, pos % 8)}; - } - selectionModel()->select(selection, flags); -} - -void BinaryView::mousePressEvent(QMouseEvent *event) { - resize_sig = nullptr; - if (auto index = indexAt(last_mouse_pos = event->pos()); index.isValid() && index.column() != 8) { - anchor_index = index; - auto item = (const BinaryViewModel::Item *)anchor_index.internalPointer(); - int bit_pos = get_bit_pos(anchor_index); - for (auto s : item->sigs) { - if (bit_pos == s->lsb || bit_pos == s->msb) { - int idx = flipBitPos(bit_pos == s->lsb ? s->msb : s->lsb); - anchor_index = model->index(idx / 8, idx % 8); - resize_sig = s; - break; - } - } - } - event->accept(); -} - -void BinaryView::highlightPosition(const QPoint &pos) { - if (auto index = indexAt(pos); index.isValid()) { - auto item = (BinaryViewModel::Item *)index.internalPointer(); - const cabana::Signal *sig = item->sigs.empty() ? nullptr : item->sigs.back(); - highlight(sig); - } -} - -void BinaryView::mouseMoveEvent(QMouseEvent *event) { - highlightPosition(last_mouse_pos = event->pos()); - QTableView::mouseMoveEvent(event); -} - -void BinaryView::mouseReleaseEvent(QMouseEvent *event) { - QTableView::mouseReleaseEvent(event); - - auto release_index = indexAt(event->pos()); - if (release_index.isValid() && anchor_index.isValid()) { - if (selectionModel()->hasSelection()) { - auto sig = resize_sig ? *resize_sig : cabana::Signal{}; - std::tie(sig.start_bit, sig.size, sig.is_little_endian) = getSelection(release_index); - resize_sig ? emit editSignal(resize_sig, sig) - : UndoStack::instance()->push(new AddSigCommand(model->msg_id, sig)); - } else { - auto item = (const BinaryViewModel::Item *)anchor_index.internalPointer(); - if (item && item->sigs.size() > 0) - emit signalClicked(item->sigs.back()); - } - } - clearSelection(); - anchor_index = QModelIndex(); - resize_sig = nullptr; -} - -void BinaryView::leaveEvent(QEvent *event) { - highlight(nullptr); - QTableView::leaveEvent(event); -} - -void BinaryView::setMessage(const MessageId &message_id) { - model->msg_id = message_id; - verticalScrollBar()->setValue(0); - refresh(); -} - -void BinaryView::refresh() { - clearSelection(); - anchor_index = QModelIndex(); - resize_sig = nullptr; - hovered_sig = nullptr; - model->refresh(); - if (underMouse()) highlightPosition(last_mouse_pos); -} - -std::set BinaryView::getOverlappingSignals() const { - std::set overlapping; - for (const auto &item : model->items) { - if (item.sigs.size() > 1) { - for (auto s : item.sigs) { - if (s->type == cabana::Signal::Type::Normal) overlapping.insert(s); - } - } - } - return overlapping; -} - -std::tuple BinaryView::getSelection(QModelIndex index) { - if (index.column() == 8) { - index = model->index(index.row(), 7); - } - bool is_lb = true; - if (resize_sig) { - is_lb = resize_sig->is_little_endian; - } else if (settings.drag_direction == Settings::DragDirection::MsbFirst) { - is_lb = index < anchor_index; - } else if (settings.drag_direction == Settings::DragDirection::LsbFirst) { - is_lb = !(index < anchor_index); - } else if (settings.drag_direction == Settings::DragDirection::AlwaysLE) { - is_lb = true; - } else if (settings.drag_direction == Settings::DragDirection::AlwaysBE) { - is_lb = false; - } - - int cur_bit_pos = get_bit_pos(index); - int anchor_bit_pos = get_bit_pos(anchor_index); - int start_bit = is_lb ? std::min(cur_bit_pos, anchor_bit_pos) : get_bit_pos(std::min(index, anchor_index)); - int size = is_lb ? std::abs(cur_bit_pos - anchor_bit_pos) + 1 : std::abs(flipBitPos(cur_bit_pos) - flipBitPos(anchor_bit_pos)) + 1; - return {start_bit, size, is_lb}; -} - -// BinaryViewModel - -void BinaryViewModel::refresh() { - beginResetModel(); - bit_flip_tracker = {}; - items.clear(); - if (auto dbc_msg = dbc()->msg(msg_id)) { - row_count = dbc_msg->size; - items.resize(row_count * column_count); - for (auto sig : dbc_msg->getSignals()) { - for (int j = 0; j < sig->size; ++j) { - int pos = sig->is_little_endian ? flipBitPos(sig->start_bit + j) : flipBitPos(sig->start_bit) + j; - int idx = column_count * (pos / 8) + pos % 8; - if (idx >= items.size()) { - fprintf(stderr, "signal %s out of bounds.start_bit: %d size: %d\n", - sig->name.c_str(), sig->start_bit, sig->size); - break; - } - if (j == 0) sig->is_little_endian ? items[idx].is_lsb = true : items[idx].is_msb = true; - if (j == sig->size - 1) sig->is_little_endian ? items[idx].is_msb = true : items[idx].is_lsb = true; - - auto &sigs = items[idx].sigs; - sigs.push_back(sig); - if (sigs.size() > 1) { - std::sort(sigs.begin(), sigs.end(), [](auto l, auto r) { return l->size > r->size; }); - } - } - } - } else { - row_count = can->lastMessage(msg_id).dat.size(); - items.resize(row_count * column_count); - } - endResetModel(); - updateState(); -} - -void BinaryViewModel::updateItem(int row, int col, uint8_t val, const QColor &color) { - auto &item = items[row * column_count + col]; - item.valid = true; - if (item.val != val || item.bg_color != color) { - item.val = val; - item.bg_color = color; - auto idx = index(row, col); - emit dataChanged(idx, idx, {Qt::DisplayRole}); - } -} - -void BinaryViewModel::updateState() { - const auto &last_msg = can->lastMessage(msg_id); - const auto &binary = last_msg.dat; - // Handle size changes in binary data - if (binary.size() > row_count) { - beginInsertRows({}, row_count, binary.size() - 1); - row_count = binary.size(); - items.resize(row_count * column_count); - endInsertRows(); - } - - auto &bit_flips = heatmap_live_mode ? last_msg.bit_flip_counts : getBitFlipChanges(binary.size()); - // Find the maximum bit flip count across the message - uint32_t max_bit_flip_count = 1; // Default to 1 to avoid division by zero - for (const auto &row : bit_flips) { - for (uint32_t count : row) { - max_bit_flip_count = std::max(max_bit_flip_count, count); - } - } - - const double max_alpha = 255.0; - const double min_alpha_with_signal = 25.0; // Base alpha for small flip counts - const double min_alpha_no_signal = 10.0; // Base alpha for small flip counts for no signal bits - const double log_factor = 1.0 + 0.2; // Factor for logarithmic scaling - const double log_scaler = max_alpha / log2(log_factor * max_bit_flip_count); - - for (size_t i = 0; i < binary.size(); ++i) { - for (int j = 0; j < 8; ++j) { - auto &item = items[i * column_count + j]; - int bit_val = (binary[i] >> (7 - j)) & 1; - - double alpha = item.sigs.empty() ? 0 : min_alpha_with_signal; - uint32_t flip_count = bit_flips[i][j]; - if (flip_count > 0) { - double normalized_alpha = log2(1.0 + flip_count * log_factor) * log_scaler; - double min_alpha = item.sigs.empty() ? min_alpha_no_signal : min_alpha_with_signal; - alpha = std::clamp(normalized_alpha, min_alpha, max_alpha); - } - - auto color = item.bg_color; - color.setAlpha(alpha); - updateItem(i, j, bit_val, color); - } - updateItem(i, 8, binary[i], toQColor(last_msg.colors[i])); - } -} - -const std::vector> &BinaryViewModel::getBitFlipChanges(size_t msg_size) { - // Return cached results if time range and data are unchanged - auto time_range = can->timeRange(); - if (bit_flip_tracker.time_range == time_range && !bit_flip_tracker.flip_counts.empty()) - return bit_flip_tracker.flip_counts; - - bit_flip_tracker.time_range = time_range; - bit_flip_tracker.flip_counts.assign(msg_size, std::array{}); - - // Iterate over events within the specified time range and calculate bit flips - auto [first, last] = can->eventsInRange(msg_id, time_range); - if (std::distance(first, last) <= 1) return bit_flip_tracker.flip_counts; - - std::vector prev_values((*first)->dat, (*first)->dat + (*first)->size); - for (auto it = std::next(first); it != last; ++it) { - const CanEvent *event = *it; - int size = std::min(msg_size, event->size); - for (int i = 0; i < size; ++i) { - const uint8_t diff = event->dat[i] ^ prev_values[i]; - if (!diff) continue; - - auto &bit_flips = bit_flip_tracker.flip_counts[i]; - for (int bit = 0; bit < 8; ++bit) { - if (diff & (1u << bit)) ++bit_flips[7 - bit]; - } - prev_values[i] = event->dat[i]; - } - } - - return bit_flip_tracker.flip_counts; -} - -QVariant BinaryViewModel::headerData(int section, Qt::Orientation orientation, int role) const { - if (orientation == Qt::Vertical) { - switch (role) { - case Qt::DisplayRole: return section; - case Qt::SizeHintRole: return QSize(VERTICAL_HEADER_WIDTH, 0); - case Qt::TextAlignmentRole: return Qt::AlignCenter; - } - } - return {}; -} - -QVariant BinaryViewModel::data(const QModelIndex &index, int role) const { - auto item = (const BinaryViewModel::Item *)index.internalPointer(); - return role == Qt::ToolTipRole && item && !item->sigs.empty() ? QString::fromStdString(utils::signalToolTip(item->sigs.back())) : QVariant(); -} - -// BinaryItemDelegate - -BinaryItemDelegate::BinaryItemDelegate(QObject *parent) : QStyledItemDelegate(parent) { - small_font.setPixelSize(8); - hex_font = QFontDatabase::systemFont(QFontDatabase::FixedFont); - hex_font.setBold(true); - - bin_text_table[0].setText("0"); - bin_text_table[1].setText("1"); - for (int i = 0; i < 256; ++i) { - hex_text_table[i].setText(QStringLiteral("%1").arg(i, 2, 16, QLatin1Char('0')).toUpper()); - hex_text_table[i].prepare({}, hex_font); - } -} - -bool BinaryItemDelegate::hasSignal(const QModelIndex &index, int dx, int dy, const cabana::Signal *sig) const { - if (!index.isValid()) return false; - auto model = (const BinaryViewModel*)(index.model()); - int idx = (index.row() + dy) * model->columnCount() + index.column() + dx; - if (idx < 0 || idx >= (int)model->items.size()) return false; - auto &s = model->items[idx].sigs; - return std::find(s.begin(), s.end(), sig) != s.end(); -} - -void BinaryItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { - auto item = (const BinaryViewModel::Item *)index.internalPointer(); - BinaryView *bin_view = (BinaryView *)parent(); - painter->save(); - - if (index.column() == 8) { - if (item->valid) { - painter->setFont(hex_font); - painter->fillRect(option.rect, item->bg_color); - } - } else if (option.state & QStyle::State_Selected) { - auto color = bin_view->resize_sig ? toQColor(bin_view->resize_sig->color) : option.palette.color(QPalette::Active, QPalette::Highlight); - painter->fillRect(option.rect, color); - painter->setPen(option.palette.color(QPalette::BrightText)); - } else if (!bin_view->selectionModel()->hasSelection() || std::find(item->sigs.begin(), item->sigs.end(), bin_view->resize_sig) == item->sigs.end()) { // not resizing - if (item->sigs.size() > 0) { - for (auto &s : item->sigs) { - if (s == bin_view->hovered_sig) { - painter->fillRect(option.rect, toQColor(s->color.darker(125))); // 4/5x brightness - } else { - drawSignalCell(painter, option, index, s); - } - } - } else if (item->valid && item->bg_color.alpha() > 0) { - painter->fillRect(option.rect, item->bg_color); - } - auto color_role = (std::find(item->sigs.begin(), item->sigs.end(), bin_view->hovered_sig) != item->sigs.end()) ? QPalette::BrightText : QPalette::Text; - painter->setPen(option.palette.color(bin_view->is_message_active ? QPalette::Normal : QPalette::Disabled, color_role)); - } - - if (item->sigs.size() > 1) { - painter->fillRect(option.rect, QBrush(Qt::darkGray, Qt::Dense7Pattern)); - } else if (!item->valid) { - painter->fillRect(option.rect, QBrush(Qt::darkGray, Qt::BDiagPattern)); - } - if (item->valid) { - utils::drawStaticText(painter, option.rect, index.column() == 8 ? hex_text_table[item->val] : bin_text_table[item->val]); - } - if (item->is_msb || item->is_lsb) { - painter->setFont(small_font); - painter->drawText(option.rect.adjusted(8, 0, -8, -3), Qt::AlignRight | Qt::AlignBottom, item->is_msb ? "M" : "L"); - } - painter->restore(); -} - -// Draw border on edge of signal -void BinaryItemDelegate::drawSignalCell(QPainter *painter, const QStyleOptionViewItem &option, - const QModelIndex &index, const cabana::Signal *sig) const { - bool draw_left = !hasSignal(index, -1, 0, sig); - bool draw_top = !hasSignal(index, 0, -1, sig); - bool draw_right = !hasSignal(index, 1, 0, sig); - bool draw_bottom = !hasSignal(index, 0, 1, sig); - - const int spacing = 2; - QRect rc = option.rect.adjusted(draw_left * 3, draw_top * spacing, draw_right * -3, draw_bottom * -spacing); - QRegion subtract; - if (!draw_top) { - if (!draw_left && !hasSignal(index, -1, -1, sig)) { - subtract += QRect{rc.left(), rc.top(), 3, spacing}; - } else if (!draw_right && !hasSignal(index, 1, -1, sig)) { - subtract += QRect{rc.right() - 2, rc.top(), 3, spacing}; - } - } - if (!draw_bottom) { - if (!draw_left && !hasSignal(index, -1, 1, sig)) { - subtract += QRect{rc.left(), rc.bottom() - (spacing - 1), 3, spacing}; - } else if (!draw_right && !hasSignal(index, 1, 1, sig)) { - subtract += QRect{rc.right() - 2, rc.bottom() - (spacing - 1), 3, spacing}; - } - } - painter->setClipRegion(QRegion(rc).subtracted(subtract)); - - auto item = (const BinaryViewModel::Item *)index.internalPointer(); - QColor color = toQColor(sig->color); - color.setAlpha(item->bg_color.alpha()); - // Mixing the signal color with the Base background color to fade it - painter->fillRect(rc, option.palette.color(QPalette::Base)); - painter->fillRect(rc, color); - - // Draw edges - color = toQColor(sig->color.darker(125)); - painter->setPen(QPen(color, 1)); - if (draw_left) painter->drawLine(rc.topLeft(), rc.bottomLeft()); - if (draw_right) painter->drawLine(rc.topRight(), rc.bottomRight()); - if (draw_bottom) painter->drawLine(rc.bottomLeft(), rc.bottomRight()); - if (draw_top) painter->drawLine(rc.topLeft(), rc.topRight()); - - if (!subtract.isEmpty()) { - // fill gaps inside corners. - painter->setPen(QPen(color, 2, Qt::SolidLine, Qt::SquareCap, Qt::MiterJoin)); - for (auto &r : subtract) { - painter->drawRect(r); - } - } -} diff --git a/openpilot/tools/cabana/binaryview.h b/openpilot/tools/cabana/binaryview.h deleted file mode 100644 index c918ee01e7..0000000000 --- a/openpilot/tools/cabana/binaryview.h +++ /dev/null @@ -1,106 +0,0 @@ -#pragma once - -#include -#include -#include - -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/streams/abstractstream.h" - -class BinaryItemDelegate : public QStyledItemDelegate { -public: - BinaryItemDelegate(QObject *parent); - void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - bool hasSignal(const QModelIndex &index, int dx, int dy, const cabana::Signal *sig) const; - void drawSignalCell(QPainter* painter, const QStyleOptionViewItem &option, const QModelIndex &index, const cabana::Signal *sig) const; - - QFont small_font, hex_font; - std::array hex_text_table; - std::array bin_text_table; -}; - -class BinaryViewModel : public QAbstractTableModel { -public: - BinaryViewModel(QObject *parent) : QAbstractTableModel(parent) {} - void refresh(); - void updateState(); - void updateItem(int row, int col, uint8_t val, const QColor &color); - QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - int rowCount(const QModelIndex &parent = QModelIndex()) const override { return row_count; } - int columnCount(const QModelIndex &parent = QModelIndex()) const override { return column_count; } - QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override { - return createIndex(row, column, (void *)&items[row * column_count + column]); - } - Qt::ItemFlags flags(const QModelIndex &index) const override { - return (index.column() == column_count - 1) ? Qt::ItemIsEnabled : Qt::ItemIsEnabled | Qt::ItemIsSelectable; - } - const std::vector> &getBitFlipChanges(size_t msg_size); - - struct BitFlipTracker { - std::optional> time_range; - std::vector> flip_counts; - } bit_flip_tracker; - - struct Item { - QColor bg_color = QColor(102, 86, 169, 255); - bool is_msb = false; - bool is_lsb = false; - uint8_t val; - std::vector sigs; - bool valid = false; - }; - std::vector items; - bool heatmap_live_mode = true; - MessageId msg_id; - int row_count = 0; - const int column_count = 9; -}; - -class BinaryView : public QTableView { - Q_OBJECT - -public: - BinaryView(QWidget *parent = nullptr); - void setMessage(const MessageId &message_id); - void highlight(const cabana::Signal *sig); - std::set getOverlappingSignals() const; - void updateState() { model->updateState(); } - void paintEvent(QPaintEvent *event) override { - is_message_active = can->isMessageActive(model->msg_id); - QTableView::paintEvent(event); - } - QSize minimumSizeHint() const override; - void setHeatmapLiveMode(bool live) { model->heatmap_live_mode = live; updateState(); } - -signals: - void signalClicked(const cabana::Signal *sig); - void signalHovered(const cabana::Signal *sig); - void editSignal(const cabana::Signal *origin_s, cabana::Signal &s); - void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge); - -private: - void addShortcuts(); - void refresh(); - std::tuple getSelection(QModelIndex index); - void setSelection(const QRect &rect, QItemSelectionModel::SelectionFlags flags) override; - void mousePressEvent(QMouseEvent *event) override; - void mouseMoveEvent(QMouseEvent *event) override; - void mouseReleaseEvent(QMouseEvent *event) override; - void leaveEvent(QEvent *event) override; - void highlightPosition(const QPoint &pt); - - QModelIndex anchor_index; - QPoint last_mouse_pos{-1, -1}; - BinaryViewModel *model; - BinaryItemDelegate *delegate; - bool is_message_active = false; - const cabana::Signal *resize_sig = nullptr; - const cabana::Signal *hovered_sig = nullptr; - Connections connections_; - friend class BinaryItemDelegate; -}; diff --git a/openpilot/tools/cabana/cabana b/openpilot/tools/cabana/cabana index fb995a05c2..bdec614366 100755 --- a/openpilot/tools/cabana/cabana +++ b/openpilot/tools/cabana/cabana @@ -4,50 +4,7 @@ set -e DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" ROOT="$(cd "$DIR/../../../" && pwd)" -install_qt() { - if [[ "$(uname)" == "Darwin" ]]; then - brew install qt@5 - brew link qt@5 || true - else - SUDO="" - if [[ ! $(id -u) -eq 0 ]]; then - SUDO="sudo" - fi - $SUDO apt-get install -y --no-install-recommends \ - qtbase5-dev \ - qtbase5-dev-tools \ - qttools5-dev-tools \ - libqt5charts5-dev \ - libqt5svg5-dev \ - libqt5serialbus5-dev \ - libqt5x11extras5-dev \ - libqt5opengl5-dev - fi -} - -# --legacy runs the old Qt cabana until it is deprecated -LEGACY=0 -ARGS=() -for arg in "$@"; do - if [[ "$arg" == "--legacy" ]]; then - LEGACY=1 - else - ARGS+=("$arg") - fi -done - -if [[ $LEGACY -eq 1 ]]; then - # Install Qt if not found - if ! command -v qmake &> /dev/null; then - echo "Qt not found, installing dependencies..." - install_qt - fi - TARGET="_cabana" -else - TARGET="_cabana_ui" -fi - cd "$ROOT" -scons -u "openpilot/tools/cabana/$TARGET" openpilot/cereal/messaging/bridge +scons -u "openpilot/tools/cabana/_cabana_ui" openpilot/cereal/messaging/bridge -exec "$DIR/$TARGET" "${ARGS[@]}" +exec "$DIR/_cabana_ui" "$@" diff --git a/openpilot/tools/cabana/cabana.cc b/openpilot/tools/cabana/cabana.cc deleted file mode 100644 index b18484c556..0000000000 --- a/openpilot/tools/cabana/cabana.cc +++ /dev/null @@ -1,196 +0,0 @@ -#include -#include -#include -#include - -#include - -#include "tools/cabana/mainwin.h" -#include "tools/cabana/streams/devicestream.h" -#include "tools/cabana/streams/pandastream.h" -#include "tools/cabana/streams/replaystream.h" -#ifdef __linux__ -#include "tools/cabana/streams/socketcanstream.h" -#endif -#include "tools/cabana/utils/qtutil.h" - -namespace { - -struct CabanaArgs { - bool demo = false; - bool auto_source = false; - bool qcam = false; - bool wide_road = false; - bool cabin = false; - bool msgq = false; - bool panda = false; - bool no_vipc = false; - std::string panda_serial; - std::string socketcan; - std::string zmq; - std::string data_dir; - std::string dbc; - std::string route; -}; - -void printUsage(const char *argv0) { - fprintf(stderr, - "Usage: %s [options] [route]\n" - "\n" - " route the drive to replay. find your drives at connect.comma.ai\n" - "\n" - "Options:\n" - " --help show this help\n" - " --demo use a demo route instead of providing your own\n" - " --auto Auto load the route from the best available source (no video):\n" - " internal, openpilotci, comma_api, car_segments, testing_closet\n" - " --qcam load qcamera\n" - " --wide-road load wide road camera (alias: --ecam)\n" - " --cabin load cabin camera (alias: --dcam)\n" - " --msgq read can messages from the msgq\n" - " --panda read can messages from panda\n" - " --panda-serial read can messages from panda with given serial\n" -#ifdef __linux__ - " --socketcan read can messages from given SocketCAN device\n" -#endif - " --zmq read can messages from zmq at the specified ip-address\n" - " --data_dir local directory with routes\n" - " --no-vipc do not output video\n" - " --dbc dbc file to open\n", - argv0); -} - -// Returns true if value was consumed from argv[i+1]. -bool takeValue(int argc, char *argv[], int &i, std::string &out) { - if (i + 1 >= argc) { - fprintf(stderr, "error: %s requires a value\n", argv[i]); - return false; - } - out = argv[++i]; - return true; -} - -// Returns 0 to continue, or a process exit code (0 for --help, 1 for errors). -int parseArgs(int argc, char *argv[], CabanaArgs &args, bool &ok) { - ok = false; - for (int i = 1; i < argc; ++i) { - const char *a = argv[i]; - if (std::strcmp(a, "--help") == 0 || std::strcmp(a, "-h") == 0) { - printUsage(argv[0]); - return 0; - } else if (std::strcmp(a, "--demo") == 0) { - args.demo = true; - } else if (std::strcmp(a, "--auto") == 0) { - args.auto_source = true; - } else if (std::strcmp(a, "--qcam") == 0) { - args.qcam = true; - } else if (std::strcmp(a, "--wide-road") == 0 || std::strcmp(a, "--ecam") == 0) { - args.wide_road = true; - } else if (std::strcmp(a, "--cabin") == 0 || std::strcmp(a, "--dcam") == 0) { - args.cabin = true; - } else if (std::strcmp(a, "--msgq") == 0) { - args.msgq = true; - } else if (std::strcmp(a, "--panda") == 0) { - args.panda = true; - } else if (std::strcmp(a, "--panda-serial") == 0) { - if (!takeValue(argc, argv, i, args.panda_serial)) return 1; - args.panda = true; - } else if (std::strcmp(a, "--socketcan") == 0) { - if (!takeValue(argc, argv, i, args.socketcan)) return 1; -#ifdef __linux__ -#else - fprintf(stderr, "error: --socketcan is only supported on Linux\n"); - return 1; -#endif - } else if (std::strcmp(a, "--zmq") == 0) { - if (!takeValue(argc, argv, i, args.zmq)) return 1; - } else if (std::strcmp(a, "--data_dir") == 0) { - if (!takeValue(argc, argv, i, args.data_dir)) return 1; - } else if (std::strcmp(a, "--no-vipc") == 0) { - args.no_vipc = true; - } else if (std::strcmp(a, "--dbc") == 0) { - if (!takeValue(argc, argv, i, args.dbc)) return 1; - } else if (a[0] == '-') { - fprintf(stderr, "error: unknown option %s\n", a); - printUsage(argv[0]); - return 1; - } else if (args.route.empty()) { - args.route = a; - } else { - fprintf(stderr, "error: unexpected argument %s\n", a); - printUsage(argv[0]); - return 1; - } - } - ok = true; - return 0; -} - -} // namespace - -int main(int argc, char *argv[]) { - QCoreApplication::setApplicationName("Cabana"); - initApp(argc, argv, false); - QApplication app(argc, argv); - app.setApplicationDisplayName("Cabana"); - //app.setWindowIcon(QIcon(":cabana-icon.png")); // TODO: do this in imgui - - // Marshal exit onto the GUI thread (qApp methods are not thread-safe). - UnixSignalHandler signalHandler([]() { - QMetaObject::invokeMethod(qApp, []() { - printf("\nexiting...\n"); - qApp->closeAllWindows(); - qApp->exit(); - }, Qt::QueuedConnection); - }); - utils::setTheme(settings.theme); - - CabanaArgs args; - bool args_ok = false; - if (const int code = parseArgs(argc, argv, args, args_ok); !args_ok) { - return code; - } - - AbstractStream *stream = nullptr; - - if (args.msgq) { - stream = new DeviceStream(); - } else if (!args.zmq.empty()) { - stream = new DeviceStream(args.zmq); - } else if (args.panda || !args.panda_serial.empty()) { - try { - stream = new PandaStream({.serial = args.panda_serial}); - } catch (std::exception &e) { - fprintf(stderr, "%s\n", e.what()); - return 0; - } -#ifdef __linux__ - } else if (SocketCanStream::available() && !args.socketcan.empty()) { - stream = new SocketCanStream({.device = args.socketcan}); -#endif - } else { - uint32_t replay_flags = REPLAY_FLAG_NONE; - if (args.wide_road) replay_flags |= REPLAY_FLAG_WIDE_ROAD; - if (args.qcam) replay_flags |= REPLAY_FLAG_QCAMERA; - if (args.cabin) replay_flags |= REPLAY_FLAG_CABIN_CAMERA; - if (args.no_vipc) replay_flags |= REPLAY_FLAG_NO_VIPC; - - QString route; - if (!args.route.empty()) { - route = QString::fromStdString(args.route); - } else if (args.demo) { - route = DEMO_ROUTE; - } - if (!route.isEmpty()) { - auto replay_stream = std::make_unique(); - Connection err = replay_stream->error.connect([](const std::string &msg) { fprintf(stderr, "%s\n", msg.c_str()); }); - if (!replay_stream->loadRoute(route.toStdString(), args.data_dir, replay_flags, args.auto_source)) { - return 0; - } - stream = replay_stream.release(); - } - } - - MainWindow w(stream, QString::fromStdString(args.dbc)); - return app.exec(); -} diff --git a/openpilot/tools/cabana/cameraview.cc b/openpilot/tools/cabana/cameraview.cc deleted file mode 100644 index b8c70ab1bf..0000000000 --- a/openpilot/tools/cabana/cameraview.cc +++ /dev/null @@ -1,121 +0,0 @@ -#include "tools/cabana/cameraview.h" - -#include -#include -#include -#include - -#include -#include - -#include "common/yuv.h" -#include "tools/cabana/utils/util.h" - -CameraWidget::CameraWidget(std::string stream_name, VisionStreamType type, QWidget* parent) : - stream_name(stream_name), active_stream_type(type), requested_stream_type(type), QWidget(parent) { - setAttribute(Qt::WA_OpaquePaintEvent); - QObject::connect(QApplication::instance(), &QCoreApplication::aboutToQuit, this, &CameraWidget::stopVipcThread); -} - -CameraWidget::~CameraWidget() { - stopVipcThread(); -} - -void CameraWidget::showEvent(QShowEvent *event) { - if (!vipc_thread.joinable()) { - clearFrames(); - vipc_exit = false; - vipc_thread = std::thread(&CameraWidget::vipcThread, this); - } -} - -void CameraWidget::stopVipcThread() { - vipc_exit = true; - if (vipc_thread.joinable()) { - vipc_thread.join(); - } -} - -void CameraWidget::paintEvent(QPaintEvent *event) { - QPainter p(this); - p.fillRect(rect(), bg); - - std::lock_guard lk(frame_lock); - if (rgb_frame.isNull()) return; - - // Scale for aspect ratio - float widget_ratio = (float)width() / height(); - float frame_ratio = (float)rgb_frame.width() / rgb_frame.height(); - int w = std::lround(width() * std::min(frame_ratio / widget_ratio, 1.0f)); - int h = std::lround(height() * std::min(widget_ratio / frame_ratio, 1.0f)); - QRect video_rect((width() - w) / 2, (height() - h) / 2, w, h); - - p.setRenderHint(QPainter::SmoothPixmapTransform); - if (active_stream_type == VISION_STREAM_CABIN) { - // mirror cabin camera horizontally - const qreal cx = video_rect.x() + video_rect.width() / 2.0; - p.translate(cx, 0); - p.scale(-1, 1); - p.translate(-cx, 0); - } - p.drawImage(video_rect, rgb_frame); -} - -void CameraWidget::vipcThread() { - VisionStreamType cur_stream = requested_stream_type; - std::unique_ptr vipc_client; - VisionIpcBufExtra frame_meta = {}; - - while (!vipc_exit) { - if (!vipc_client || cur_stream != requested_stream_type) { - clearFrames(); - fprintf(stderr, "connecting to stream %d, was connected to %d\n", - (int)requested_stream_type, (int)cur_stream); - cur_stream = requested_stream_type; - vipc_client.reset(new VisionIpcClient(stream_name, cur_stream, false)); - } - active_stream_type = cur_stream; - - if (!vipc_client->connected) { - clearFrames(); - auto streams = VisionIpcClient::getAvailableStreams(stream_name, false); - if (streams.empty()) { - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - continue; - } - utils::runOnMainThread([this, alive = std::weak_ptr(alive_), streams]() { - if (alive.expired()) return; - available_streams = streams; - availableStreamsUpdated(streams); - }); - - if (!vipc_client->connect(false)) { - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - continue; - } - } - - if (VisionBuf *buf = vipc_client->recv(&frame_meta, 100)) { - // NV12 -> RGBA once per frame on the receive thread; paint just draws the image - if (rgb_back.width() != (int)buf->width || rgb_back.height() != (int)buf->height) { - rgb_back = QImage(buf->width, buf->height, QImage::Format_RGBA8888); - } - yuv::nv12_to_rgba(buf->y, buf->stride, buf->uv, buf->stride, - rgb_back.bits(), rgb_back.bytesPerLine(), buf->width, buf->height); - { - std::lock_guard lk(frame_lock); - rgb_frame.swap(rgb_back); - } - utils::runOnMainThread([this, alive = std::weak_ptr(alive_)]() { - if (!alive.expired()) update(); - }); - } - } -} - -void CameraWidget::clearFrames() { - std::lock_guard lk(frame_lock); - rgb_frame = QImage(); - rgb_back = QImage(); - available_streams.clear(); -} diff --git a/openpilot/tools/cabana/cameraview.h b/openpilot/tools/cabana/cameraview.h deleted file mode 100644 index ac292a701c..0000000000 --- a/openpilot/tools/cabana/cameraview.h +++ /dev/null @@ -1,49 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -#include "openpilot/cereal/visionstream.h" -#include "tools/cabana/core/observable.h" -#include "msgq/visionipc/visionipc_client.h" - -class CameraWidget : public QWidget { -public: - explicit CameraWidget(std::string stream_name, VisionStreamType stream_type, QWidget* parent = nullptr); - ~CameraWidget(); - void setStreamType(VisionStreamType type) { requested_stream_type = type; } - VisionStreamType getStreamType() { return active_stream_type; } - void stopVipcThread(); - - Observable<> clicked; - Observable> availableStreamsUpdated; // invoked on the main thread - -protected: - void paintEvent(QPaintEvent *event) override; - void showEvent(QShowEvent *event) override; - void hideEvent(QHideEvent *event) override { stopVipcThread(); } - void mouseReleaseEvent(QMouseEvent *event) override { clicked(); } - void vipcThread(); - void clearFrames(); - - QColor bg = Qt::black; - QImage rgb_frame; // written by vipc thread, drawn by GUI thread; guarded by frame_lock - QImage rgb_back; // vipc thread only - - std::string stream_name; - std::atomic active_stream_type; - std::atomic requested_stream_type; - std::set available_streams; - std::thread vipc_thread; - std::atomic vipc_exit = false; - std::mutex frame_lock; - std::shared_ptr alive_ = std::make_shared(true); -}; diff --git a/openpilot/tools/cabana/chart/chart.cc b/openpilot/tools/cabana/chart/chart.cc deleted file mode 100644 index 478ff3ce9d..0000000000 --- a/openpilot/tools/cabana/chart/chart.cc +++ /dev/null @@ -1,769 +0,0 @@ -#include "tools/cabana/chart/chart.h" - -#include -#include -#include - -#include -#include -#include -#include - -#include "tools/cabana/chart/chartswidget.h" - -const int AXIS_X_TOP_MARGIN = 4; -const int X_TICK_COUNT = 5; -const double MIN_ZOOM_SECONDS = 0.01; // 10ms -// Define a small value of epsilon to compare double values -const float EPSILON = 0.000001; -static inline bool xLessThan(const QPointF &p, float x) { return p.x() < (x - EPSILON); } - -static QMargins layoutMargins(const QStyle *style) { - return { - style->pixelMetric(QStyle::PM_LayoutLeftMargin), - style->pixelMetric(QStyle::PM_LayoutTopMargin), - style->pixelMetric(QStyle::PM_LayoutRightMargin), - style->pixelMetric(QStyle::PM_LayoutBottomMargin), - }; -} - -ChartView::ChartView(const std::pair &x_range, ChartsWidget *parent) - : x_min(x_range.first), x_max(x_range.second), charts_widget(parent), QWidget(parent) { - series_type = (SeriesType)settings.chart_series_type; - align_to = 50; - setMouseTracking(true); - tip_label = new TipLabel(this); - createToolButtons(); - signal_value_font.setPointSize(9); - - connections_.push_back(dbc()->signalRemoved.connect([this](const cabana::Signal *sig) { signalRemoved(sig); })); - connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { signalUpdated(sig); })); - connections_.push_back(dbc()->msgRemoved.connect([this](MessageId id) { msgRemoved(id); })); - connections_.push_back(dbc()->msgUpdated.connect([this](MessageId id) { msgUpdated(id); })); -} - -void ChartView::createToolButtons() { - close_btn = new ToolButton("x", tr("Remove Chart"), this); - - menu = new QMenu(this); - // series types - auto change_series_group = new QActionGroup(menu); - change_series_group->setExclusive(true); - QStringList types{tr("Line"), tr("Step Line"), tr("Scatter")}; - for (int i = 0; i < types.size(); ++i) { - QAction *act = new QAction(types[i], change_series_group); - act->setData(i); - act->setCheckable(true); - act->setChecked(i == (int)series_type); - menu->addAction(act); - } - menu->addSeparator(); - menu->addAction(tr("Manage Signals"), this, &ChartView::manageSignals); - split_chart_act = menu->addAction(tr("Split Chart"), [this]() { charts_widget->splitChart(this); }); - - manage_btn = new ToolButton("list", "", this); - manage_btn->setMenu(menu); - manage_btn->setPopupMode(QToolButton::InstantPopup); - manage_btn->setStyleSheet("QToolButton::menu-indicator { image: none; }"); - - close_act = new QAction(tr("Close"), this); - QObject::connect(close_act, &QAction::triggered, [this] () { charts_widget->removeChart(this); }); - QObject::connect(close_btn, &QToolButton::clicked, close_act, &QAction::triggered); - QObject::connect(change_series_group, &QActionGroup::triggered, [this](QAction *action) { - setSeriesType((SeriesType)action->data().toInt()); - }); -} - -QSize ChartView::sizeHint() const { - return {CHART_MIN_WIDTH, settings.chart_height}; -} - -void ChartView::addSignal(const MessageId &msg_id, const cabana::Signal *sig) { - if (hasSignal(msg_id, sig)) return; - - sigs.push_back({.msg_id = msg_id, .sig = sig, .color = uniqueColor(toQColor(sig->color))}); - updateSeries(sig); - updateTitle(); - emit charts_widget->seriesChanged(); -} - -bool ChartView::hasSignal(const MessageId &msg_id, const cabana::Signal *sig) const { - return std::any_of(sigs.cbegin(), sigs.cend(), [&](auto &s) { return s.msg_id == msg_id && s.sig == sig; }); -} - -void ChartView::removeIf(std::function predicate) { - int prev_size = sigs.size(); - sigs.erase(std::remove_if(sigs.begin(), sigs.end(), predicate), sigs.end()); - if (sigs.empty()) { - charts_widget->removeChart(this); - } else if (sigs.size() != prev_size) { - emit charts_widget->seriesChanged(); - updateAxisY(); - updateTitle(); - } -} - -void ChartView::signalUpdated(const cabana::Signal *sig) { - auto it = std::find_if(sigs.begin(), sigs.end(), [sig](auto &s) { return s.sig == sig; }); - if (it != sigs.end()) { - if (it->color != toQColor(sig->color)) { - it->color = uniqueColor(toQColor(sig->color), sig); - } - updateTitle(); - updateSeries(sig); - } -} - -void ChartView::msgUpdated(MessageId id) { - if (std::any_of(sigs.cbegin(), sigs.cend(), [=](auto &s) { return s.msg_id.address == id.address; })) { - updateTitle(); - } -} - -void ChartView::manageSignals() { - SignalSelector dlg(tr("Manage Chart"), this); - for (auto &s : sigs) { - dlg.addSelected(s.msg_id, s.sig); - } - if (dlg.exec() == QDialog::Accepted) { - auto items = dlg.seletedItems(); - for (auto s : items) { - addSignal(s->msg_id, s->sig); - } - removeIf([&](auto &s) { - return std::none_of(items.cbegin(), items.cend(), [&](auto &it) { return s.msg_id == it->msg_id && s.sig == it->sig; }); - }); - } -} - -void ChartView::resizeEvent(QResizeEvent *event) { - QWidget::resizeEvent(event); - const auto margins = layoutMargins(style()); - QPixmap grip = utils::icon("grip-horizontal"); - move_icon_rect = QRect(QPoint(margins.left(), margins.top()), grip.size() / grip.devicePixelRatio()); - close_btn->resize(close_btn->sizeHint()); - manage_btn->resize(manage_btn->sizeHint()); - close_btn->move(rect().right() - margins.right() - close_btn->width(), margins.top()); - manage_btn->move(close_btn->x() - manage_btn->width() - style()->pixelMetric(QStyle::PM_LayoutHorizontalSpacing), margins.top()); - updatePlotArea(align_to, true); -} - -void ChartView::updatePlotArea(int left_pos, bool force) { - if (align_to != left_pos || force) { - align_to = left_pos; - - const auto margins = layoutMargins(style()); - QFont bold_font = font(); - bold_font.setBold(true); - QFontMetrics fm(font()), bfm(bold_font); - const int marker_size = fm.height() - 4; - const int row_height = std::max(marker_size, fm.height()) + QFontMetrics(signal_value_font).height() + 3; - const int legend_left = move_icon_rect.right() + margins.left(); - const int legend_right = std::max(manage_btn->x() - margins.right(), legend_left + 10); - - // layout legend entries left-to-right, wrapping between the move icon and the buttons - legend_rects.clear(); - int x = legend_left, y = margins.top(); - for (auto &s : sigs) { - int w = marker_size + 5 + bfm.horizontalAdvance(QString::fromStdString(s.sig->name)) + - fm.horizontalAdvance(QString::fromStdString(" " + msgName(s.msg_id) + " " + s.msg_id.toString())); - w = std::min(w, legend_right - legend_left); // keep oversized entries clear of the header buttons - if (x + w > legend_right && x > legend_left) { - x = legend_left; - y += row_height; - } - legend_rects.emplace_back(x, y, w, std::max(marker_size, fm.height())); - x += w + 12; - } - - // add top space for the legend and signal values - int adjust_top = (y + row_height) - margins.top(); - adjust_top = std::max(adjust_top, manage_btn->geometry().bottom() + style()->pixelMetric(QStyle::PM_LayoutTopMargin)); - // add right space for x-axis label - QSizeF x_label_size = fm.size(Qt::TextSingleLine, QString::number(x_max, 'f', xAxisPrecision())) + QSizeF{5, 5}; - plot_area = rect().adjusted(align_to + margins.left(), adjust_top + margins.top(), - -x_label_size.width() / 2 - margins.right(), - -x_label_size.height() - margins.bottom()); - resetChartCache(); - } -} - -void ChartView::updateTitle() { - split_chart_act->setEnabled(sigs.size() > 1); - updatePlotArea(align_to, true); -} - -void ChartView::updatePlot(double cur, double min, double max) { - cur_sec = cur; - if (min != x_min || max != x_max) { - x_min = min; - x_max = max; - updateAxisY(); - // update tooltip - if (tooltip_x >= 0) { - showTip(secondsAtPoint({tooltip_x, 0})); - } - resetChartCache(); - } - update(); -} - -void ChartView::appendCanEvents(const cabana::Signal *sig, const std::vector &events, - std::vector &vals, std::vector &step_vals) { - vals.reserve(vals.size() + events.capacity()); - step_vals.reserve(step_vals.size() + events.capacity() * 2); - - double value = 0; - for (const CanEvent *e : events) { - if (sig->getValue(e->dat, e->size, &value)) { - const double ts = can->toSeconds(e->mono_time); - vals.emplace_back(ts, value); - if (!step_vals.empty()) - step_vals.emplace_back(ts, step_vals.back().y()); - step_vals.emplace_back(ts, value); - } - } -} - -void ChartView::updateSeries(const cabana::Signal *sig, const MessageEventsMap *msg_new_events) { - for (auto &s : sigs) { - if (!sig || s.sig == sig) { - if (!msg_new_events) { - s.vals.clear(); - s.step_vals.clear(); - } - auto events = msg_new_events ? msg_new_events : &can->eventsMap(); - auto it = events->find(s.msg_id); - if (it == events->end() || it->second.empty()) continue; - - if (s.vals.empty() || can->toSeconds(it->second.back()->mono_time) > s.vals.back().x()) { - appendCanEvents(s.sig, it->second, s.vals, s.step_vals); - } else { - std::vector vals, step_vals; - appendCanEvents(s.sig, it->second, vals, step_vals); - s.vals.insert(std::lower_bound(s.vals.begin(), s.vals.end(), vals.front().x(), xLessThan), - vals.begin(), vals.end()); - s.step_vals.insert(std::lower_bound(s.step_vals.begin(), s.step_vals.end(), step_vals.front().x(), xLessThan), - step_vals.begin(), step_vals.end()); - } - - if (!can->liveStreaming()) { - s.segment_tree.build(s.vals.size(), [&vals = s.vals](int i) { return vals[i].y(); }); - } - } - } - updateAxisY(); - // invoke resetChartCache in ui thread - QMetaObject::invokeMethod(this, &ChartView::resetChartCache, Qt::QueuedConnection); -} - -// auto zoom on yaxis -void ChartView::updateAxisY() { - if (sigs.empty()) return; - - double min = std::numeric_limits::max(); - double max = std::numeric_limits::lowest(); - QString unit = QString::fromStdString(sigs[0].sig->unit); - - for (auto &s : sigs) { - if (!s.visible) continue; - - // Only show unit when all signals have the same unit - if (unit != QString::fromStdString(s.sig->unit)) { - unit.clear(); - } - - auto first = std::lower_bound(s.vals.cbegin(), s.vals.cend(), x_min, xLessThan); - auto last = std::lower_bound(first, s.vals.cend(), x_max, xLessThan); - s.min = std::numeric_limits::max(); - s.max = std::numeric_limits::lowest(); - if (can->liveStreaming()) { - for (auto it = first; it != last; ++it) { - if (it->y() < s.min) s.min = it->y(); - if (it->y() > s.max) s.max = it->y(); - } - } else { - std::tie(s.min, s.max) = s.segment_tree.minmax(std::distance(s.vals.cbegin(), first), std::distance(s.vals.cbegin(), last)); - } - min = std::min(min, s.min); - max = std::max(max, s.max); - } - if (min == std::numeric_limits::max()) min = 0; - if (max == std::numeric_limits::lowest()) max = 0; - - if (y_unit != unit) { - y_unit = unit; - y_label_width = 0; // recalc width - } - - double delta = std::abs(max - min) < 1e-3 ? 1 : (max - min) * 0.05; - auto [min_y, max_y, tick_count] = getNiceAxisNumbers(min - delta, max + delta, 3); - if (min_y != y_min || max_y != y_max || y_label_width == 0) { - y_min = min_y; - y_max = max_y; - y_tick_count = tick_count; - y_precision = std::max(int(-std::floor(std::log10((max_y - min_y) / (tick_count - 1)))), 0); - - QFontMetrics fm(font()); - int max_label_width = 0; - for (int i = 0; i < tick_count; i++) { - qreal value = min_y + (i * (max_y - min_y) / (tick_count - 1)); - max_label_width = std::max(max_label_width, fm.horizontalAdvance(QString::number(value, 'f', y_precision))); - } - - int title_spacing = y_unit.isEmpty() ? 0 : fm.size(Qt::TextSingleLine, y_unit).height(); - y_label_width = title_spacing + max_label_width + 15; - emit axisYLabelWidthChanged(y_label_width); - } -} - -std::tuple ChartView::getNiceAxisNumbers(qreal min, qreal max, int tick_count) { - qreal range = niceNumber((max - min), true); // range with ceiling - qreal step = niceNumber(range / (tick_count - 1), false); - min = std::floor(min / step); - max = std::ceil(max / step); - tick_count = int(max - min) + 1; - return {min * step, max * step, tick_count}; -} - -int ChartView::xAxisPrecision() const { - return std::max(int(-std::floor(std::log10((x_max - x_min) / (X_TICK_COUNT - 1)))), 2); -} - -// nice numbers can be expressed as form of 1*10^n, 2* 10^n or 5*10^n -qreal ChartView::niceNumber(qreal x, bool ceiling) { - qreal z = std::pow(10, std::floor(std::log10(x))); //find corresponding number of the form of 10^n than is smaller than x - qreal q = x / z; //q<10 && q>=1; - if (ceiling) { - if (q <= 1.0) q = 1; - else if (q <= 2.0) q = 2; - else if (q <= 5.0) q = 5; - else q = 10; - } else { - if (q < 1.5) q = 1; - else if (q < 3.0) q = 2; - else if (q < 7.0) q = 5; - else q = 10; - } - return q * z; -} - -void ChartView::contextMenuEvent(QContextMenuEvent *event) { - QMenu context_menu(this); - context_menu.addActions(menu->actions()); - context_menu.addSeparator(); - context_menu.addAction(charts_widget->undo_zoom_action); - context_menu.addAction(charts_widget->redo_zoom_action); - context_menu.addSeparator(); - context_menu.addAction(close_act); - context_menu.exec(event->globalPos()); -} - -void ChartView::mousePressEvent(QMouseEvent *event) { - press_pos = event->pos(); - if (event->button() == Qt::LeftButton && move_icon_rect.contains(event->pos())) { - charts_widget->startChartDrag(this, event->globalPos()); - } else if (event->button() == Qt::LeftButton && event->modifiers().testFlag(Qt::ShiftModifier)) { - // Save current playback state when scrubbing - resume_after_scrub = !can->isPaused(); - if (resume_after_scrub) { - can->pause(true); - } - mouse_mode = MouseMode::Scrub; - } else if (event->button() == Qt::LeftButton && plot_area.contains(event->pos())) { - mouse_mode = MouseMode::Rubber; - rubber_rect = QRect(); - } else { - QWidget::mousePressEvent(event); - } -} - -void ChartView::mouseMoveEvent(QMouseEvent *ev) { - // Scrubbing - if (mouse_mode == MouseMode::Scrub && ev->modifiers().testFlag(Qt::ShiftModifier)) { - if (plot_area.contains(ev->pos())) { - can->seekTo(std::clamp(secondsAtPoint(ev->pos()), can->minSeconds(), can->maxSeconds())); - } - } - - if (mouse_mode == MouseMode::Rubber) { - // horizontal selection, clamped to the plot area - int left = std::clamp(std::min(press_pos.x(), ev->pos().x()), plot_area.left(), plot_area.right()); - int right = std::clamp(std::max(press_pos.x(), ev->pos().x()), plot_area.left(), plot_area.right()); - rubber_rect = QRect(left, plot_area.top(), right - left, plot_area.height()); - update(); - } - - clearTrackPoints(); - if (mouse_mode != MouseMode::Rubber && plot_area.contains(ev->pos()) && isActiveWindow()) { - charts_widget->showValueTip(secondsAtPoint(ev->pos())); - } else if (tip_label->isVisible()) { - charts_widget->showValueTip(-1); - } - QWidget::mouseMoveEvent(ev); -} - -void ChartView::mouseReleaseEvent(QMouseEvent *event) { - if (event->button() == Qt::LeftButton && mouse_mode == MouseMode::Rubber) { - mouse_mode = MouseMode::None; - // Prevent zooming/seeking past the end of the route - double min = std::clamp(secondsAtPoint(rubber_rect.topLeft()), can->minSeconds(), can->maxSeconds()); - double max = std::clamp(secondsAtPoint(rubber_rect.bottomRight()), can->minSeconds(), can->maxSeconds()); - if (rubber_rect.width() <= 0) { - // no rubber dragged, seek to mouse position - can->seekTo(std::clamp(secondsAtPoint(press_pos), can->minSeconds(), can->maxSeconds())); - } else if (rubber_rect.width() > 10 && (max - min) > MIN_ZOOM_SECONDS) { - charts_widget->zoom_undo_stack.push(new ZoomCommand({min, max})); - } - rubber_rect = QRect(); - update(); - } else if (event->button() == Qt::LeftButton && mouse_mode == MouseMode::None && sigs.size() > 1) { - // toggle series visibility by clicking its legend entry - for (int i = 0; i < sigs.size() && i < legend_rects.size(); ++i) { - if (legend_rects[i].contains(press_pos) && legend_rects[i].contains(event->pos())) { - sigs[i].visible = !sigs[i].visible; - updateAxisY(); - updateTitle(); - break; - } - } - } else if (event->button() == Qt::RightButton) { - charts_widget->zoom_undo_stack.undo(); - } else { - QWidget::mouseReleaseEvent(event); - } - - // Resume playback if we were scrubbing - if (mouse_mode == MouseMode::Scrub) { - mouse_mode = MouseMode::None; - if (resume_after_scrub) { - can->pause(false); - resume_after_scrub = false; - } - } -} - -void ChartView::takeSignalsFrom(ChartView *source) { - for (auto &s : source->sigs) { - sigs.push_back(std::move(s)); - sigs.back().color = uniqueColor(sigs.back().color, sigs.back().sig); - } - source->sigs.clear(); - updateAxisY(); - updateTitle(); - charts_widget->removeChart(source); -} - -void ChartView::showTip(double sec) { - QRect tip_area(0, plot_area.top(), rect().width(), plot_area.height()); - QRect visible_rect = charts_widget->chartVisibleRect(this).intersected(tip_area); - if (visible_rect.isEmpty()) { - tip_label->hide(); - return; - } - - tooltip_x = xPos(sec); - qreal x = -1; - QStringList text_list; - for (auto &s : sigs) { - if (s.visible) { - QString value = "--"; - // use reverse iterator to find last item <= sec. - auto it = std::lower_bound(s.vals.crbegin(), s.vals.crend(), sec, [](auto &p, double v) { return p.x() > v; }); - if (it != s.vals.crend() && it->x() >= x_min) { - value = QString::fromStdString(s.sig->formatValue(it->y(), false)); - s.track_pt = *it; - x = std::max(x, xPos(it->x())); - } - QString name = sigs.size() > 1 ? QString::fromStdString(s.sig->name) + ": " : ""; - QString min = s.min == std::numeric_limits::max() ? "--" : QString::number(s.min); - QString max = s.max == std::numeric_limits::lowest() ? "--" : QString::number(s.max); - text_list << QString("%2%3 (%4, %5)") - .arg(s.color.name(), name, value, min, max); - } - } - if (x < 0) { - x = tooltip_x; - } - QPoint pt(x, plot_area.top()); - text_list.push_front(QString::number(secondsAtPoint({x, 0}), 'f', 3)); - QString text = "

" % text_list.join("
") % "

"; - tip_label->showText(pt, text, this, visible_rect); - update(); -} - -void ChartView::hideTip() { - clearTrackPoints(); - tooltip_x = -1; - tip_label->hide(); - update(); -} - -void ChartView::resetChartCache() { - chart_pixmap = QPixmap(); - update(); -} - -void ChartView::paintEvent(QPaintEvent *event) { - QPainter painter(this); - painter.setRenderHints(QPainter::Antialiasing); - - // the static layer is invalidated on x-range change and data merge, so cache it in live mode too - const qreal dpr = devicePixelRatioF(); - if (chart_pixmap.isNull() || chart_pixmap.size() != size() * dpr) { - chart_pixmap = QPixmap(size() * dpr); - chart_pixmap.setDevicePixelRatio(dpr); - QPainter p(&chart_pixmap); - p.setRenderHints(QPainter::Antialiasing); - p.setFont(font()); - drawStaticLayer(&p); - } - painter.drawPixmap(QPoint(), chart_pixmap); - - if (can_drop) { - painter.setPen(QPen(palette().color(QPalette::Highlight), 4)); - painter.drawRect(rect()); - } - drawForeground(&painter); -} - -void ChartView::drawStaticLayer(QPainter *painter) { - painter->fillRect(rect(), palette().color(QPalette::Base)); - painter->drawPixmap(move_icon_rect.topLeft(), utils::icon("grip-horizontal")); - drawAxes(painter); - drawLegend(painter); - drawSeries(painter); -} - -void ChartView::drawAxes(QPainter *painter) { - const QColor text_color = palette().color(QPalette::Text); - QColor grid_color = text_color; - grid_color.setAlpha(50); - QFontMetrics fm(font()); - painter->setFont(font()); - - // y grid lines and tick labels - for (int i = 0; i < y_tick_count; ++i) { - double value = y_min + i * (y_max - y_min) / (y_tick_count - 1); - qreal y = yPos(value); - painter->setPen(grid_color); - painter->drawLine(QPointF(plot_area.left(), y), QPointF(plot_area.right(), y)); - painter->setPen(text_color); - QRectF label_rect(0, y - fm.height() / 2.0, plot_area.left() - 6, fm.height()); - painter->drawText(label_rect, Qt::AlignRight | Qt::AlignVCenter, QString::number(value, 'f', y_precision)); - } - - // rotated y axis title (unit) - if (!y_unit.isEmpty()) { - painter->save(); - painter->translate(plot_area.left() - y_label_width + fm.height() / 2.0, plot_area.center().y()); - painter->rotate(-90); - painter->drawText(QRectF(-plot_area.height() / 2.0, -fm.height() / 2.0, plot_area.height(), fm.height()), - Qt::AlignCenter, y_unit); - painter->restore(); - } - - // x grid lines and tick labels - const int x_precision = xAxisPrecision(); - for (int i = 0; i < X_TICK_COUNT; ++i) { - double sec = x_min + i * (x_max - x_min) / (X_TICK_COUNT - 1); - qreal x = xPos(sec); - painter->setPen(grid_color); - painter->drawLine(QPointF(x, plot_area.top()), QPointF(x, plot_area.bottom())); - painter->setPen(text_color); - QString label = QString::number(sec, 'f', x_precision); - QRectF label_rect(x - 100, plot_area.bottom() + AXIS_X_TOP_MARGIN, 200, fm.height()); - painter->drawText(label_rect, Qt::AlignHCenter | Qt::AlignTop, label); - } -} - -void ChartView::drawLegend(QPainter *painter) { - QColor title_color = palette().color(QPalette::WindowText); - // Draw message details in similar color, but slightly fade it to the background - QColor msg_color = title_color; - msg_color.setAlpha(180); - QFont bold_font = font(); - bold_font.setBold(true); - const int marker_size = QFontMetrics(font()).height() - 4; - - for (int i = 0; i < sigs.size() && i < legend_rects.size(); ++i) { - const auto &s = sigs[i]; - const QRect &r = legend_rects[i]; - painter->setPen(Qt::NoPen); - painter->setBrush(s.color); - QRectF marker_rect(r.left(), r.center().y() - marker_size / 2.0, marker_size, marker_size); - series_type == SeriesType::Scatter ? painter->drawEllipse(marker_rect) : painter->drawRect(marker_rect); - - bold_font.setStrikeOut(!s.visible); - QFont normal_font = font(); - normal_font.setStrikeOut(!s.visible); - - qreal x = r.left() + marker_size + 5; - painter->setFont(bold_font); - painter->setPen(title_color); - QString name = QFontMetrics(bold_font).elidedText(QString::fromStdString(s.sig->name), Qt::ElideRight, r.right() - x); - painter->drawText(QRectF(x, r.top(), r.right() - x, r.height()), Qt::AlignLeft | Qt::AlignVCenter, name); - x += QFontMetrics(bold_font).horizontalAdvance(name); - painter->setFont(normal_font); - painter->setPen(msg_color); - QString msg = QFontMetrics(normal_font).elidedText(QString::fromStdString(" " + msgName(s.msg_id) + " " + s.msg_id.toString()), - Qt::ElideRight, r.right() - x); - painter->drawText(QRectF(x, r.top(), r.right() - x, r.height()), Qt::AlignLeft | Qt::AlignVCenter, msg); - } -} - -void ChartView::drawSeries(QPainter *painter) { - painter->save(); - painter->setClipRect(plot_area); - for (auto &s : sigs) { - if (!s.visible) continue; - - // visible points in vals to compute point density - auto first = std::lower_bound(s.vals.cbegin(), s.vals.cend(), x_min, xLessThan); - auto last = std::lower_bound(first, s.vals.cend(), x_max, xLessThan); - int num_points = std::max(last - first, 1); - double pixels_per_point = 0; - if (first != last) { - const QPointF &right_pt = last == s.vals.cend() ? s.vals.back() : *last; - pixels_per_point = (xPos(right_pt.x()) - xPos(first->x())) / num_points; - } - - if (series_type == SeriesType::Scatter) { - qreal radius = std::clamp(pixels_per_point / 2.0, 2.0, 8.0) / 2.0; - painter->setPen(Qt::NoPen); - painter->setBrush(s.color); - for (auto it = first; it != last; ++it) { - painter->drawEllipse(QPointF(xPos(it->x()), yPos(it->y())), radius, radius); - } - } else { - const auto &points = series_type == SeriesType::StepLine ? s.step_vals : s.vals; - auto begin = std::lower_bound(points.cbegin(), points.cend(), x_min, xLessThan); - if (begin != points.cbegin()) --begin; - auto end = std::lower_bound(begin, points.cend(), x_max, xLessThan); - if (end != points.cend()) ++end; - if (begin == end) continue; - - std::vector polyline; - polyline.reserve(end - begin); - for (auto it = begin; it != end; ++it) { - polyline.emplace_back(xPos(it->x()), yPos(it->y())); - } - painter->setPen(QPen(s.color, 2)); - painter->setBrush(Qt::NoBrush); - painter->drawPolyline(polyline.data(), polyline.size()); - - // show points when zoomed in enough - if (num_points == 1 || pixels_per_point > 20) { - painter->setPen(Qt::NoPen); - painter->setBrush(s.color); - for (auto it = first; it != last; ++it) { - painter->drawEllipse(QPointF(xPos(it->x()), yPos(it->y())), 4, 4); - } - } - } - } - painter->restore(); -} - -void ChartView::drawForeground(QPainter *painter) { - drawTimeline(painter); - drawSignalValue(painter); - // draw track points - painter->setPen(Qt::NoPen); - qreal track_line_x = -1; - for (auto &s : sigs) { - if (!s.track_pt.isNull() && s.visible) { - painter->setBrush(s.color.darker(125)); - QPointF pos(xPos(s.track_pt.x()), yPos(s.track_pt.y())); - painter->drawEllipse(pos, 5.5, 5.5); - track_line_x = std::max(track_line_x, pos.x()); - } - } - if (track_line_x > 0) { - painter->setPen(QPen(Qt::darkGray, 1, Qt::DashLine)); - painter->drawLine(QPointF{track_line_x, (qreal)plot_area.top()}, QPointF{track_line_x, (qreal)plot_area.bottom()}); - } - - drawRubberBandTimeRange(painter); -} - -void ChartView::drawRubberBandTimeRange(QPainter *painter) { - if (rubber_rect.width() <= 1) return; - - // selection rect - QColor highlight = palette().color(QPalette::Highlight); - QColor fill = highlight; - fill.setAlpha(50); - painter->fillRect(rubber_rect, fill); - painter->setPen(highlight); - painter->setBrush(Qt::NoBrush); - painter->drawRect(rubber_rect); - - // time labels at the bottom corners - painter->setPen(Qt::white); - painter->setFont(font()); - for (const auto &pt : {rubber_rect.bottomLeft(), rubber_rect.bottomRight()}) { - QString sec = QString::number(secondsAtPoint(pt), 'f', 2); - auto r = painter->fontMetrics().boundingRect(sec).adjusted(-6, -AXIS_X_TOP_MARGIN, 6, AXIS_X_TOP_MARGIN); - pt == rubber_rect.bottomLeft() ? r.moveTopRight(pt + QPoint{0, 2}) : r.moveTopLeft(pt + QPoint{0, 2}); - painter->fillRect(r, Qt::gray); - painter->drawText(r, Qt::AlignCenter, sec); - } -} - -void ChartView::drawTimeline(QPainter *painter) { - // draw vertical time line - qreal x = std::clamp(xPos(cur_sec), (qreal)plot_area.left(), (qreal)plot_area.right()); - painter->setPen(QPen(palette().color(QPalette::Text), 1)); - painter->drawLine(QPointF{x, plot_area.top() - 1.0}, QPointF{x, plot_area.bottom() + 1.0}); - - // draw current time under the axis-x - QString time_str = QString::number(cur_sec, 'f', 2); - QSize time_str_size = QFontMetrics(font()).size(Qt::TextSingleLine, time_str) + QSize(8, 2); - QRectF time_str_rect(QPointF(x - time_str_size.width() / 2.0, plot_area.bottom() + AXIS_X_TOP_MARGIN), time_str_size); - QPainterPath path; - path.addRoundedRect(time_str_rect, 3, 3); - painter->fillPath(path, utils::isDarkTheme() ? Qt::darkGray : Qt::gray); - painter->setPen(palette().color(QPalette::BrightText)); - painter->setFont(font()); - painter->drawText(time_str_rect, Qt::AlignCenter, time_str); -} - -void ChartView::drawSignalValue(QPainter *painter) { - painter->setFont(signal_value_font); - painter->setPen(palette().color(QPalette::Text)); - for (int i = 0; i < sigs.size() && i < legend_rects.size(); ++i) { - const auto &s = sigs[i]; - auto it = std::lower_bound(s.vals.crbegin(), s.vals.crend(), cur_sec, - [](auto &p, double x) { return p.x() > x + EPSILON; }); - QString value = (it != s.vals.crend() && it->x() >= x_min) ? QString::fromStdString(s.sig->formatValue(it->y())) : "--"; - QRectF value_rect(legend_rects[i].bottomLeft() - QPoint(0, 1), legend_rects[i].size()); - QString elided_val = painter->fontMetrics().elidedText(value, Qt::ElideRight, value_rect.width()); - painter->drawText(value_rect, Qt::AlignHCenter | Qt::AlignTop, elided_val); - } -} - -QColor ChartView::uniqueColor(QColor color, const cabana::Signal *exclude) const { - for (auto &s : sigs) { - if (s.sig != exclude && std::abs(color.hueF() - s.color.hueF()) < 0.1) { - // use different color to distinguish it from others. - auto last_color = sigs.back().color; - static thread_local std::mt19937 rng{std::random_device{}()}; - std::uniform_int_distribution sat(35, 99); - std::uniform_int_distribution val(85, 99); - color.setHsvF(std::fmod(last_color.hueF() + 60 / 360.0, 1.0), - sat(rng) / 100.0, - val(rng) / 100.0); - break; - } - } - return color; -} - -void ChartView::setSeriesType(SeriesType type) { - if (type != series_type) { - series_type = type; - menu->actions()[(int)type]->setChecked(true); - updateTitle(); - } -} diff --git a/openpilot/tools/cabana/chart/chart.h b/openpilot/tools/cabana/chart/chart.h deleted file mode 100644 index 3188e1f401..0000000000 --- a/openpilot/tools/cabana/chart/chart.h +++ /dev/null @@ -1,132 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include - -#include "tools/cabana/chart/tiplabel.h" -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/streams/abstractstream.h" -#include "tools/cabana/utils/qtutil.h" - -enum class SeriesType { - Line = 0, - StepLine, - Scatter -}; - -class ChartsWidget; -class ChartView : public QWidget { - Q_OBJECT - -public: - ChartView(const std::pair &x_range, ChartsWidget *parent = nullptr); - void addSignal(const MessageId &msg_id, const cabana::Signal *sig); - bool hasSignal(const MessageId &msg_id, const cabana::Signal *sig) const; - void updateSeries(const cabana::Signal *sig = nullptr, const MessageEventsMap *msg_new_events = nullptr); - void updatePlot(double cur, double min, double max); - void setSeriesType(SeriesType type); - void updatePlotArea(int left, bool force = false); - void showTip(double sec); - void hideTip(); - double secondsAtPoint(const QPointF &pt) const { - return x_min + (pt.x() - plot_area.left()) * (x_max - x_min) / std::max(plot_area.width(), 1); - } - - struct SigItem { - MessageId msg_id; - const cabana::Signal *sig = nullptr; - QColor color; - bool visible = true; - std::vector vals; - std::vector step_vals; - QPointF track_pt{}; - SegmentTree segment_tree; - double min = 0; - double max = 0; - }; - -signals: - void axisYLabelWidthChanged(int w); - -private slots: - void signalUpdated(const cabana::Signal *sig); - void manageSignals(); - void msgUpdated(MessageId id); - void msgRemoved(MessageId id) { removeIf([=](auto &s) { return s.msg_id.address == id.address && !dbc()->msg(id); }); } - void signalRemoved(const cabana::Signal *sig) { removeIf([=](auto &s) { return s.sig == sig; }); } - -private: - void appendCanEvents(const cabana::Signal *sig, const std::vector &events, - std::vector &vals, std::vector &step_vals); - void createToolButtons(); - void contextMenuEvent(QContextMenuEvent *event) override; - void mousePressEvent(QMouseEvent *event) override; - void mouseMoveEvent(QMouseEvent *event) override; - void mouseReleaseEvent(QMouseEvent *event) override; - void resizeEvent(QResizeEvent *event) override; - QSize sizeHint() const override; - void updateAxisY(); - void updateTitle(); - void resetChartCache(); - void paintEvent(QPaintEvent *event) override; - void drawStaticLayer(QPainter *painter); - void drawAxes(QPainter *painter); - void drawLegend(QPainter *painter); - void drawSeries(QPainter *painter); - void drawForeground(QPainter *painter); - void drawSignalValue(QPainter *painter); - void drawTimeline(QPainter *painter); - void drawRubberBandTimeRange(QPainter *painter); - int xAxisPrecision() const; - std::tuple getNiceAxisNumbers(qreal min, qreal max, int tick_count); - qreal niceNumber(qreal x, bool ceiling); - QColor uniqueColor(QColor color, const cabana::Signal *exclude = nullptr) const; - void removeIf(std::function predicate); - void takeSignalsFrom(ChartView *source); - void setDropHighlight(bool highlight) { if (std::exchange(can_drop, highlight) != highlight) update(); } - inline void clearTrackPoints() { for (auto &s : sigs) s.track_pt = {}; } - inline qreal xPos(double sec) const { return plot_area.left() + (sec - x_min) / (x_max - x_min) * plot_area.width(); } - inline qreal yPos(double val) const { return plot_area.bottom() - (val - y_min) / (y_max - y_min) * plot_area.height(); } - - // layout - QRect plot_area; - QRect move_icon_rect; - std::vector legend_rects; - // axes - double x_min; - double x_max; - double y_min = 0; - double y_max = 1; - int y_tick_count = 3; - int y_precision = 0; - QString y_unit; - int y_label_width = 0; - int align_to = 0; - // interaction - enum class MouseMode { None, Rubber, Scrub }; - MouseMode mouse_mode = MouseMode::None; - QPoint press_pos; - QRect rubber_rect; - bool resume_after_scrub = false; - - QMenu *menu; - QAction *split_chart_act; - QAction *close_act; - ToolButton *manage_btn; - ToolButton *close_btn; - TipLabel *tip_label; - std::vector sigs; - double cur_sec = 0; - SeriesType series_type = SeriesType::Line; - QPixmap chart_pixmap; - bool can_drop = false; - double tooltip_x = -1; - QFont signal_value_font; - ChartsWidget *charts_widget; - Connections connections_; - friend class ChartsWidget; -}; diff --git a/openpilot/tools/cabana/chart/chartswidget.cc b/openpilot/tools/cabana/chart/chartswidget.cc deleted file mode 100644 index 2e3e86bab0..0000000000 --- a/openpilot/tools/cabana/chart/chartswidget.cc +++ /dev/null @@ -1,660 +0,0 @@ -#include "tools/cabana/chart/chartswidget.h" - -#include -#include - -#include -#include -#include -#include -#include - -#include "tools/cabana/chart/chart.h" - -const int MAX_COLUMN_COUNT = 4; -const int CHART_SPACING = 4; - -ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) { - align_timer = new QTimer(this); - auto_scroll_timer = new QTimer(this); - setFrameStyle(QFrame::StyledPanel | QFrame::Plain); - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - main_layout->setSpacing(0); - - // toolbar - toolbar = new QToolBar(tr("Charts"), this); - int icon_size = style()->pixelMetric(QStyle::PM_SmallIconSize); - toolbar->setIconSize({icon_size, icon_size}); - - auto new_plot_btn = new ToolButton("file-plus", tr("New Chart")); - auto new_tab_btn = new ToolButton("window-stack", tr("New Tab")); - toolbar->addWidget(new_plot_btn); - toolbar->addWidget(new_tab_btn); - toolbar->addWidget(title_label = new QLabel()); - title_label->setContentsMargins(0, 0, style()->pixelMetric(QStyle::PM_LayoutHorizontalSpacing), 0); - - auto chart_type_action = toolbar->addAction(""); - QMenu *chart_type_menu = new QMenu(this); - auto types = std::array{tr("Line"), tr("Step"), tr("Scatter")}; - for (int i = 0; i < types.size(); ++i) { - QString type_text = types[i]; - chart_type_menu->addAction(type_text, this, [=]() { - settings.chart_series_type = i; - chart_type_action->setText("Type: " + type_text); - settingChanged(); - }); - } - chart_type_action->setText("Type: " + types[settings.chart_series_type]); - chart_type_action->setMenu(chart_type_menu); - qobject_cast(toolbar->widgetForAction(chart_type_action))->setPopupMode(QToolButton::InstantPopup); - - QMenu *menu = new QMenu(this); - for (int i = 0; i < MAX_COLUMN_COUNT; ++i) { - menu->addAction(tr("%1").arg(i + 1), [=]() { setColumnCount(i + 1); }); - } - columns_action = toolbar->addAction(""); - columns_action->setMenu(menu); - qobject_cast(toolbar->widgetForAction(columns_action))->setPopupMode(QToolButton::InstantPopup); - - QWidget *spacer = new QWidget(this); - spacer->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred); - toolbar->addWidget(spacer); - - range_lb_action = toolbar->addWidget(range_lb = new QLabel(this)); - range_slider = new LogSlider(1000, Qt::Horizontal, this); - range_slider->setFixedWidth(150 * qApp->devicePixelRatio()); - range_slider->setToolTip(tr("Set the chart range")); - range_slider->setRange(1, settings.max_cached_minutes * 60); - range_slider->setSingleStep(1); - range_slider->setPageStep(60); // 1 min - range_slider_action = toolbar->addWidget(range_slider); - - // zoom controls - undo_zoom_action = toolbar->addAction(utils::icon("arrow-counterclockwise"), tr("Undo Zoom"), [this]() { zoom_undo_stack.undo(); }); - redo_zoom_action = toolbar->addAction(utils::icon("arrow-clockwise"), tr("Redo Zoom"), [this]() { zoom_undo_stack.redo(); }); - undo_zoom_action->setEnabled(false); - redo_zoom_action->setEnabled(false); - connections_.push_back(zoom_undo_stack.indexChanged.connect([this]() { - undo_zoom_action->setEnabled(zoom_undo_stack.canUndo()); - redo_zoom_action->setEnabled(zoom_undo_stack.canRedo()); - })); - reset_zoom_action = toolbar->addWidget(reset_zoom_btn = new ToolButton("zoom-out", tr("Reset Zoom"))); - reset_zoom_btn->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); - - toolbar->addWidget(remove_all_btn = new ToolButton("x-square", tr("Remove all charts"))); - toolbar->addWidget(dock_btn = new ToolButton("")); - main_layout->addWidget(toolbar); - - // tabbar - tabbar = new TabBar(this); - tabbar->setAutoHide(true); - tabbar->setExpanding(false); - tabbar->setDrawBase(true); - tabbar->setUsesScrollButtons(true); - main_layout->addWidget(tabbar); - - // charts - charts_container = new ChartsContainer(this); - charts_container->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); - charts_scroll = new QScrollArea(this); - charts_scroll->viewport()->setBackgroundRole(QPalette::Base); - charts_scroll->setFrameStyle(QFrame::NoFrame); - charts_scroll->setWidgetResizable(true); - charts_scroll->setWidget(charts_container); - charts_scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - main_layout->addWidget(charts_scroll); - - // chart drag preview - drag_preview = new QLabel(this); - drag_preview->setAttribute(Qt::WA_TransparentForMouseEvents); - drag_preview->hide(); - - // init settings - current_theme = settings.theme; - column_count = std::clamp(settings.chart_column_count, 1, MAX_COLUMN_COUNT); - max_chart_range = std::clamp(settings.chart_range, 1, settings.max_cached_minutes * 60); - display_range = std::make_pair(can->minSeconds(), can->minSeconds() + max_chart_range); - range_slider->setValue(max_chart_range); - updateToolBar(); - - align_timer->setSingleShot(true); - QObject::connect(align_timer, &QTimer::timeout, this, &ChartsWidget::alignCharts); - QObject::connect(auto_scroll_timer, &QTimer::timeout, this, &ChartsWidget::doAutoScroll); - connections_.push_back(dbc()->fileChanged.connect([this]() { removeAll(); })); - connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &events) { eventsMerged(events); })); - connections_.push_back(can->msgsReceived.connect([this](const std::set *, bool) { updateState(); })); - connections_.push_back(can->seeking.connect([this](double) { updateState(); })); - connections_.push_back(can->timeRangeChanged.connect([this](const auto &range) { timeRangeChanged(range); })); - QObject::connect(range_slider, &QSlider::valueChanged, this, &ChartsWidget::setMaxChartRange); - QObject::connect(new_plot_btn, &QToolButton::clicked, this, &ChartsWidget::newChart); - QObject::connect(remove_all_btn, &QToolButton::clicked, this, &ChartsWidget::removeAll); - QObject::connect(reset_zoom_btn, &QToolButton::clicked, this, &ChartsWidget::zoomReset); - connections_.push_back(settings.changed.connect([this]() { settingChanged(); })); - QObject::connect(new_tab_btn, &QToolButton::clicked, this, &ChartsWidget::newTab); - QObject::connect(this, &ChartsWidget::seriesChanged, this, &ChartsWidget::updateTabBar); - QObject::connect(tabbar, &QTabBar::tabCloseRequested, this, &ChartsWidget::removeTab); - QObject::connect(tabbar, &QTabBar::currentChanged, [this](int index) { - if (index != -1) updateLayout(true); - }); - QObject::connect(dock_btn, &QToolButton::clicked, this, &ChartsWidget::toggleChartsDocking); - - setIsDocked(true); - newTab(); - qApp->installEventFilter(this); - setWhatsThis(tr(R"( - Chart View
- Click: Click to seek to a corresponding time.
- Drag: Zoom into the chart.
- Shift + Drag: Scrub through the chart to view values.
- Right Mouse: Open the context menu.
- )")); -} - -void ChartsWidget::newTab() { - static int tab_unique_id = 0; - int idx = tabbar->addTab(""); - tabbar->setTabData(idx, tab_unique_id++); - tabbar->setCurrentIndex(idx); - updateTabBar(); -} - -void ChartsWidget::removeTab(int index) { - int id = tabbar->tabData(index).toInt(); - for (auto &c : tab_charts[id]) { - removeChart(c); - } - tab_charts.erase(id); - tabbar->removeTab(index); - updateTabBar(); -} - -void ChartsWidget::updateTabBar() { - for (int i = 0; i < tabbar->count(); ++i) { - const auto &charts_in_tab = tab_charts[tabbar->tabData(i).toInt()]; - tabbar->setTabText(i, QString("Tab %1 (%2)").arg(i + 1).arg((int)charts_in_tab.size())); - } -} - -void ChartsWidget::eventsMerged(const MessageEventsMap &new_events) { - std::vector> futures; - for (auto c : charts) { - futures.push_back(std::async(std::launch::async, &ChartView::updateSeries, c, nullptr, &new_events)); - } - for (auto &f : futures) f.get(); -} - -void ChartsWidget::timeRangeChanged(const std::optional> &time_range) { - updateToolBar(); - updateState(); -} - -void ChartsWidget::zoomReset() { - can->setTimeRange(std::nullopt); - zoom_undo_stack.clear(); -} - -QRect ChartsWidget::chartVisibleRect(ChartView *chart) { - const QRect visible_rect(-charts_container->pos(), charts_scroll->viewport()->size()); - return chart->rect().intersected(QRect(chart->mapFrom(charts_container, visible_rect.topLeft()), visible_rect.size())); -} - -void ChartsWidget::showValueTip(double sec) { - emit showTip(sec); - if (sec < 0 && !value_tip_visible_) return; - - value_tip_visible_ = sec >= 0; - for (auto c : currentCharts()) { - value_tip_visible_ ? c->showTip(sec) : c->hideTip(); - } -} - -void ChartsWidget::updateState() { - if (charts.empty()) return; - - const auto &time_range = can->timeRange(); - const double cur_sec = can->currentSec(); - if (!time_range.has_value()) { - double pos = (cur_sec - display_range.first) / std::max(1.0, max_chart_range); - if (pos < 0 || pos > 0.8) { - display_range.first = std::max(can->minSeconds(), cur_sec - max_chart_range * 0.1); - } - double max_sec = std::min(display_range.first + max_chart_range, can->maxSeconds()); - display_range.first = std::max(can->minSeconds(), max_sec - max_chart_range); - display_range.second = display_range.first + max_chart_range; - } - - const auto &range = time_range ? *time_range : display_range; - for (auto c : charts) { - c->updatePlot(cur_sec, range.first, range.second); - } -} - -void ChartsWidget::setMaxChartRange(int value) { - max_chart_range = settings.chart_range = range_slider->value(); - updateToolBar(); - updateState(); -} - -void ChartsWidget::setIsDocked(bool docked) { - is_docked = docked; - dock_btn->setIcon(is_docked ? "arrow-up-right-square" : "arrow-down-left-square"); - dock_btn->setToolTip(is_docked ? tr("Float the charts window") : tr("Dock the charts window")); -} - -void ChartsWidget::updateToolBar() { - title_label->setText(tr("Charts: %1").arg(charts.size())); - columns_action->setText(tr("Columns: %1").arg(column_count)); - range_lb->setText(QString::fromStdString(utils::formatSeconds(max_chart_range))); - - bool is_zoomed = can->timeRange().has_value(); - range_lb_action->setVisible(!is_zoomed); - range_slider_action->setVisible(!is_zoomed); - undo_zoom_action->setVisible(is_zoomed); - redo_zoom_action->setVisible(is_zoomed); - reset_zoom_action->setVisible(is_zoomed); - reset_zoom_btn->setText(is_zoomed ? tr("%1-%2").arg(can->timeRange()->first, 0, 'f', 2).arg(can->timeRange()->second, 0, 'f', 2) : ""); - remove_all_btn->setEnabled(!charts.empty()); -} - -void ChartsWidget::settingChanged() { - if (std::exchange(current_theme, settings.theme) != current_theme) { - undo_zoom_action->setIcon(utils::icon("arrow-counterclockwise")); - redo_zoom_action->setIcon(utils::icon("arrow-clockwise")); - } - if (range_slider->maximum() != settings.max_cached_minutes * 60) { - range_slider->setRange(1, settings.max_cached_minutes * 60); - } - for (auto c : charts) { - c->setFixedHeight(settings.chart_height); - c->setSeriesType((SeriesType)settings.chart_series_type); - c->resetChartCache(); - } -} - -ChartView *ChartsWidget::findChart(const MessageId &id, const cabana::Signal *sig) { - for (auto c : charts) - if (c->hasSignal(id, sig)) return c; - return nullptr; -} - -ChartView *ChartsWidget::createChart(int pos) { - auto chart = new ChartView(can->timeRange().value_or(display_range), this); - chart->setFixedHeight(settings.chart_height); - chart->setMinimumWidth(CHART_MIN_WIDTH); - chart->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Fixed); - QObject::connect(chart, &ChartView::axisYLabelWidthChanged, align_timer, qOverload<>(&QTimer::start)); - pos = std::clamp(pos, 0, (int)charts.size()); - charts.insert(charts.begin() + pos, chart); - currentCharts().insert(currentCharts().begin() + pos, chart); - updateLayout(true); - updateToolBar(); - return chart; -} - -void ChartsWidget::showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge) { - ChartView *chart = findChart(id, sig); - if (show && !chart) { - chart = merge && currentCharts().size() > 0 ? currentCharts().front() : createChart(); - chart->addSignal(id, sig); - updateState(); - } else if (!show && chart) { - chart->removeIf([&](auto &s) { return s.msg_id == id && s.sig == sig; }); - } -} - -void ChartsWidget::splitChart(ChartView *src_chart) { - if (src_chart->sigs.size() > 1) { - int pos = std::find(charts.begin(), charts.end(), src_chart) - charts.begin() + 1; - for (auto it = src_chart->sigs.begin() + 1; it != src_chart->sigs.end(); /**/) { - auto c = createChart(pos); - // Restore to the original color - it->color = toQColor(it->sig->color); - c->sigs.emplace_back(std::move(*it)); - c->updateAxisY(); - c->updateTitle(); - it = src_chart->sigs.erase(it); - } - src_chart->updateAxisY(); - src_chart->updateTitle(); - updateState(); - QTimer::singleShot(0, src_chart, &ChartView::resetChartCache); - } -} - -QStringList ChartsWidget::serializeChartIds() const { - QStringList chart_ids; - for (auto c : charts) { - QStringList ids; - for (const auto& s : c->sigs) - ids += QString("%1|%2").arg(QString::fromStdString(s.msg_id.toString()), QString::fromStdString(s.sig->name)); - chart_ids += ids.join(','); - } - std::reverse(chart_ids.begin(), chart_ids.end()); - return chart_ids; -} - -void ChartsWidget::restoreChartsFromIds(const QStringList& chart_ids) { - for (const auto& chart_id : chart_ids) { - int index = 0; - for (const auto& part : chart_id.split(',')) { - const auto sig_parts = part.split('|'); - if (sig_parts.size() != 2) continue; - MessageId msg_id = MessageId::fromString(sig_parts[0].toStdString()); - if (auto* msg = dbc()->msg(msg_id)) - if (auto* sig = msg->sig(sig_parts[1].toStdString())) - showChart(msg_id, sig, true, index++ > 0); - } - } -} - -void ChartsWidget::setColumnCount(int n) { - n = std::clamp(n, 1, MAX_COLUMN_COUNT); - if (column_count != n) { - column_count = settings.chart_column_count = n; - updateToolBar(); - updateLayout(); - } -} - -void ChartsWidget::updateLayout(bool force) { - auto charts_layout = charts_container->charts_layout; - int n = MAX_COLUMN_COUNT; - for (; n > 1; --n) { - if ((n * CHART_MIN_WIDTH + (n - 1) * charts_layout->horizontalSpacing()) < charts_layout->geometry().width()) break; - } - - bool show_column_cb = n > 1; - columns_action->setVisible(show_column_cb); - - n = std::min(column_count, n); - auto ¤t_charts = currentCharts(); - if ((current_charts.size() != charts_layout->count() || n != current_column_count) || force) { - current_column_count = n; - charts_container->setUpdatesEnabled(false); - for (auto c : charts) { - c->setVisible(false); - } - for (int i = 0; i < current_charts.size(); ++i) { - charts_layout->addWidget(current_charts[i], i / n, i % n); - if (current_charts[i]->sigs.empty()) { - // the chart will be resized after add signal. delay setVisible to reduce flicker. - QTimer::singleShot(0, current_charts[i], [c = current_charts[i]]() { c->setVisible(true); }); - } else { - current_charts[i]->setVisible(true); - } - } - charts_container->setUpdatesEnabled(true); - } -} - -void ChartsWidget::startChartDrag(ChartView *chart, const QPoint &global_pos) { - stopAutoScroll(); - drag = {.source = chart, .press_pos = global_pos}; - QPixmap px = chart->grab().scaledToWidth(CHART_MIN_WIDTH * chart->devicePixelRatio(), Qt::SmoothTransformation); - drag_preview->setPixmap(px); - drag_preview->resize(px.size() / px.devicePixelRatio()); -} - -void ChartsWidget::dragChartMove(const QPoint &global_pos) { - if (!drag.active) { - if ((global_pos - drag.press_pos).manhattanLength() < QApplication::startDragDistance()) return; - drag.active = true; - drag_preview->show(); - drag_preview->raise(); - } - drag_preview->move(mapFromGlobal(global_pos) + QPoint(5, 5)); - - // hovering a tab switches to it so the chart can be dropped into another tab - int tab = tabbar->tabAt(tabbar->mapFromGlobal(global_pos)); - if (tab >= 0 && tab != tabbar->currentIndex()) { - tabbar->setCurrentIndex(tab); - } - - const QPoint container_pos = charts_container->mapFromGlobal(global_pos); - ChartView *target = nullptr; - for (auto c : currentCharts()) { - if (c != drag.source && c->isVisible() && c->geometry().contains(container_pos)) { - target = c; - break; - } - } - if (std::exchange(drop_target, target) != target) { - for (auto c : charts) c->setDropHighlight(c == target); - } - bool in_viewport = charts_scroll->viewport()->rect().contains(charts_scroll->viewport()->mapFromGlobal(global_pos)); - bool on_background = !target && in_viewport && !charts_container->childAt(container_pos); - charts_container->drawDropIndicator(on_background ? container_pos : QPoint()); - - if (in_viewport) { - startAutoScroll(global_pos); - } -} - -void ChartsWidget::cancelChartDrag() { - drag = {}; - stopAutoScroll(); - drag_preview->hide(); - charts_container->drawDropIndicator({}); - if (auto target = std::exchange(drop_target, nullptr)) target->setDropHighlight(false); -} - -void ChartsWidget::dragChartRelease(const QPoint &global_pos) { - ChartView *source = drag.source; - bool active = drag.active; - ChartView *target = drop_target; - cancelChartDrag(); - if (!active) return; - - const QPoint container_pos = charts_container->mapFromGlobal(global_pos); - bool in_viewport = charts_scroll->viewport()->rect().contains(charts_scroll->viewport()->mapFromGlobal(global_pos)); - if (target) { - // merge source into target - target->takeSignalsFrom(source); - } else if (in_viewport && !charts_container->childAt(container_pos)) { - // reorder within the current tab - auto w = charts_container->getDropAfter(container_pos); - if (w != source) { - for (auto &[_, list] : tab_charts) { - list.erase(std::remove(list.begin(), list.end(), source), list.end()); - } - auto &cur = currentCharts(); - int to = w ? std::find(cur.begin(), cur.end(), w) - cur.begin() + 1 : 0; - cur.insert(cur.begin() + to, source); - updateLayout(true); - updateTabBar(); - } - } -} - -void ChartsWidget::startAutoScroll(const QPoint &global_pos) { - auto_scroll_pos = global_pos; - auto_scroll_timer->start(50); -} - -void ChartsWidget::stopAutoScroll() { - auto_scroll_timer->stop(); - auto_scroll_count = 0; -} - -void ChartsWidget::doAutoScroll() { - QScrollBar *scroll = charts_scroll->verticalScrollBar(); - if (auto_scroll_count < scroll->pageStep()) { - ++auto_scroll_count; - } - - int value = scroll->value(); - QPoint pos = charts_scroll->viewport()->mapFromGlobal(auto_scroll_pos); - QRect area = charts_scroll->viewport()->rect(); - - if (pos.y() - area.top() < settings.chart_height / 2) { - scroll->setValue(value - auto_scroll_count); - } else if (area.bottom() - pos.y() < settings.chart_height / 2) { - scroll->setValue(value + auto_scroll_count); - } - if (value == scroll->value()) { - stopAutoScroll(); - } else if (chartDragActive()) { - // refresh the drop indicator/target at the new scroll position - dragChartMove(auto_scroll_pos); - } -} - -QSize ChartsWidget::minimumSizeHint() const { - return QSize(CHART_MIN_WIDTH * 1.5, QWidget::minimumSizeHint().height()); -} - -void ChartsWidget::newChart() { - SignalSelector dlg(tr("New Chart"), this); - if (dlg.exec() == QDialog::Accepted) { - auto items = dlg.seletedItems(); - if (!items.empty()) { - auto c = createChart(); - for (auto it : items) { - c->addSignal(it->msg_id, it->sig); - } - updateState(); - } - } -} - -void ChartsWidget::removeChart(ChartView *chart) { - if (drag.source == chart) cancelChartDrag(); - if (drop_target == chart) drop_target = nullptr; - charts.erase(std::remove(charts.begin(), charts.end(), chart), charts.end()); - chart->deleteLater(); - for (auto &[_, list] : tab_charts) { - list.erase(std::remove(list.begin(), list.end(), chart), list.end()); - } - updateToolBar(); - updateLayout(true); - alignCharts(); - emit seriesChanged(); -} - -void ChartsWidget::removeAll() { - while (tabbar->count() > 1) { - tabbar->removeTab(1); - } - tab_charts.clear(); - - if (!charts.empty()) { - for (auto c : charts) { - delete c; - } - charts.clear(); - emit seriesChanged(); - } - zoomReset(); -} - -void ChartsWidget::alignCharts() { - int plot_left = 0; - for (auto c : charts) { - plot_left = std::max(plot_left, c->y_label_width); - } - plot_left = std::max((plot_left / 10) * 10 + 10, 50); - for (auto c : charts) { - c->updatePlotArea(plot_left); - } -} - -bool ChartsWidget::eventFilter(QObject *o, QEvent *e) { - // route all mouse events to the chart drag, even when the source chart is hidden by a tab switch - if (chartDragActive()) { - if (e->type() == QEvent::MouseMove) { - dragChartMove(static_cast(e)->globalPos()); - return true; - } else if (e->type() == QEvent::MouseButtonRelease && static_cast(e)->button() == Qt::LeftButton) { - dragChartRelease(static_cast(e)->globalPos()); - return false; // let the release through so Qt clears the implicit mouse grab - } else if (e->type() == QEvent::MouseButtonPress || e->type() == QEvent::MouseButtonRelease) { - return true; // swallow other buttons during the drag - } - } - - if (!value_tip_visible_) return false; - - if (e->type() == QEvent::MouseMove) { - bool on_tip = qobject_cast(o) != nullptr; - auto global_pos = static_cast(e)->globalPos(); - - for (const auto &c : charts) { - auto local_pos = c->mapFromGlobal(global_pos); - if (c->plot_area.contains(local_pos)) { - if (on_tip) { - showValueTip(c->secondsAtPoint(local_pos)); - } - return false; - } - } - - showValueTip(-1); - } else if (e->type() == QEvent::Wheel) { - if (auto tip = qobject_cast(o)) { - // Forward the event to the parent widget - QCoreApplication::sendEvent(tip->parentWidget(), e); - } - } - return false; -} - -bool ChartsWidget::event(QEvent *event) { - bool back_button = false; - switch (event->type()) { - case QEvent::Resize: - updateLayout(); - break; - case QEvent::MouseButtonPress: - back_button = static_cast(event)->button() == Qt::BackButton; - break; - case QEvent::NativeGesture: - back_button = (static_cast(event)->value() == 180); - break; - case QEvent::WindowDeactivate: - case QEvent::FocusOut: - if (chartDragActive()) cancelChartDrag(); - showValueTip(-1); - default: - break; - } - - if (back_button) { - zoom_undo_stack.undo(); - return true; // Return true since the event has been handled - } - return QFrame::event(event); -} - -// ChartsContainer - -ChartsContainer::ChartsContainer(ChartsWidget *parent) : charts_widget(parent), QWidget(parent) { - setBackgroundRole(QPalette::Window); - QVBoxLayout *charts_main_layout = new QVBoxLayout(this); - charts_main_layout->setContentsMargins(0, CHART_SPACING, 0, CHART_SPACING); - charts_layout = new QGridLayout(); - charts_layout->setSpacing(CHART_SPACING); - charts_main_layout->addLayout(charts_layout); - charts_main_layout->addStretch(0); -} - -void ChartsContainer::paintEvent(QPaintEvent *ev) { - if (!drop_indictor_pos.isNull() && !childAt(drop_indictor_pos)) { - QRect r = geometry(); - r.setHeight(CHART_SPACING); - if (auto insert_after = getDropAfter(drop_indictor_pos)) { - r.moveTop(insert_after->geometry().bottom()); - } - - QPainter p(this); - p.fillRect(r, palette().highlight()); - } -} - -ChartView *ChartsContainer::getDropAfter(const QPoint &pos) const { - auto it = std::find_if(charts_widget->currentCharts().crbegin(), charts_widget->currentCharts().crend(), [&pos](auto c) { - auto area = c->geometry(); - return pos.x() >= area.left() && pos.x() <= area.right() && pos.y() >= area.bottom(); - }); - return it == charts_widget->currentCharts().crend() ? nullptr : *it; -} diff --git a/openpilot/tools/cabana/chart/chartswidget.h b/openpilot/tools/cabana/chart/chartswidget.h deleted file mode 100644 index 17e18a90d2..0000000000 --- a/openpilot/tools/cabana/chart/chartswidget.h +++ /dev/null @@ -1,140 +0,0 @@ -#pragma once - -#include -#include - -#include -#include -#include -#include -#include - -#include "tools/cabana/chart/signalselector.h" -#include "tools/cabana/commands.h" -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/streams/abstractstream.h" -#include "tools/cabana/utils/qtutil.h" - -const int CHART_MIN_WIDTH = 300; - -class ChartView; -class ChartsWidget; - -class ChartsContainer : public QWidget { -public: - ChartsContainer(ChartsWidget *parent); - void drawDropIndicator(const QPoint &pt) { drop_indictor_pos = pt; update(); } - void paintEvent(QPaintEvent *ev) override; - ChartView *getDropAfter(const QPoint &pos) const; - - QGridLayout *charts_layout; - ChartsWidget *charts_widget; - QPoint drop_indictor_pos; -}; - -class ChartsWidget : public QFrame { - Q_OBJECT - -public: - ChartsWidget(QWidget *parent = nullptr); - void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge); - inline bool hasSignal(const MessageId &id, const cabana::Signal *sig) { return findChart(id, sig) != nullptr; } - QStringList serializeChartIds() const; - void restoreChartsFromIds(const QStringList &chart_ids); - -public slots: - void setColumnCount(int n); - void removeAll(); - void timeRangeChanged(const std::optional> &time_range); - void setIsDocked(bool dock); - -signals: - void toggleChartsDocking(); - void seriesChanged(); - void showTip(double seconds); - -private: - QSize minimumSizeHint() const override; - bool event(QEvent *event) override; - void alignCharts(); - void newChart(); - ChartView *createChart(int pos = 0); - void removeChart(ChartView *chart); - void splitChart(ChartView *chart); - QRect chartVisibleRect(ChartView *chart); - void eventsMerged(const MessageEventsMap &new_events); - void updateState(); - void zoomReset(); - void startChartDrag(ChartView *chart, const QPoint &global_pos); - void dragChartMove(const QPoint &global_pos); - void dragChartRelease(const QPoint &global_pos); - void cancelChartDrag(); - bool chartDragActive() const { return drag.source != nullptr; } - void startAutoScroll(const QPoint &global_pos); - void stopAutoScroll(); - void doAutoScroll(); - void updateToolBar(); - void updateTabBar(); - void setMaxChartRange(int value); - void updateLayout(bool force = false); - void settingChanged(); - void showValueTip(double sec); - bool eventFilter(QObject *obj, QEvent *event) override; - void newTab(); - void removeTab(int index); - inline std::vector ¤tCharts() { return tab_charts[tabbar->tabData(tabbar->currentIndex()).toInt()]; } - ChartView *findChart(const MessageId &id, const cabana::Signal *sig); - - QLabel *title_label; - QLabel *range_lb; - LogSlider *range_slider; - QAction *range_lb_action; - QAction *range_slider_action; - bool is_docked = true; - ToolButton *dock_btn; - - QToolBar *toolbar; - QAction *undo_zoom_action; - QAction *redo_zoom_action; - QAction *reset_zoom_action; - ToolButton *reset_zoom_btn; - UndoStack zoom_undo_stack; - - ToolButton *remove_all_btn; - std::vector charts; - std::unordered_map> tab_charts; - TabBar *tabbar; - ChartsContainer *charts_container; - QScrollArea *charts_scroll; - uint32_t max_chart_range = 0; - std::pair display_range; - QAction *columns_action; - int column_count = 1; - int current_column_count = 0; - struct ChartDrag { - ChartView *source = nullptr; - QPoint press_pos; // global - bool active = false; - } drag; - QLabel *drag_preview; - ChartView *drop_target = nullptr; - int auto_scroll_count = 0; - QPoint auto_scroll_pos; - QTimer *auto_scroll_timer; - QTimer *align_timer; - int current_theme = 0; - bool value_tip_visible_ = false; - Connections connections_; - friend class ChartView; - friend class ChartsContainer; -}; - -class ZoomCommand : public UndoCommand { -public: - ZoomCommand(std::pair range) : range(range) { - prev_range = can->timeRange(); - } - void undo() override { can->setTimeRange(prev_range); } - void redo() override { can->setTimeRange(range); } - std::optional> prev_range, range; -}; diff --git a/openpilot/tools/cabana/chart/signalselector.cc b/openpilot/tools/cabana/chart/signalselector.cc deleted file mode 100644 index 90825dc402..0000000000 --- a/openpilot/tools/cabana/chart/signalselector.cc +++ /dev/null @@ -1,107 +0,0 @@ -#include "tools/cabana/chart/signalselector.h" - -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/streams/abstractstream.h" -#include "tools/cabana/utils/qtutil.h" - -SignalSelector::SignalSelector(QString title, QWidget *parent) : QDialog(parent) { - setWindowTitle(title); - QGridLayout *main_layout = new QGridLayout(this); - - // left column - main_layout->addWidget(new QLabel(tr("Available Signals")), 0, 0); - main_layout->addWidget(msgs_combo = new QComboBox(this), 1, 0); - msgs_combo->setEditable(true); - msgs_combo->lineEdit()->setPlaceholderText(tr("Select a msg...")); - msgs_combo->setInsertPolicy(QComboBox::NoInsert); - - main_layout->addWidget(available_list = new QListWidget(this), 2, 0); - - // buttons - QVBoxLayout *btn_layout = new QVBoxLayout(); - QPushButton *add_btn = new QPushButton(utils::icon("chevron-right"), "", this); - add_btn->setEnabled(false); - QPushButton *remove_btn = new QPushButton(utils::icon("chevron-left"), "", this); - remove_btn->setEnabled(false); - btn_layout->addStretch(0); - btn_layout->addWidget(add_btn); - btn_layout->addWidget(remove_btn); - btn_layout->addStretch(0); - main_layout->addLayout(btn_layout, 0, 1, 3, 1); - - // right column - main_layout->addWidget(new QLabel(tr("Selected Signals")), 0, 2); - main_layout->addWidget(selected_list = new QListWidget(this), 1, 2, 2, 1); - - auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - main_layout->addWidget(buttonBox, 3, 2); - - for (const auto &[id, _] : can->lastMessages()) { - if (auto m = dbc()->msg(id)) { - msgs_combo->addItem(QString("%1 (%2)").arg(QString::fromStdString(m->name)).arg(QString::fromStdString(id.toString())), QVariant::fromValue(id)); - } - } - msgs_combo->model()->sort(0); - msgs_combo->setCurrentIndex(-1); - - QObject::connect(msgs_combo, qOverload(&QComboBox::currentIndexChanged), this, &SignalSelector::updateAvailableList); - QObject::connect(available_list, &QListWidget::currentRowChanged, [=](int row) { add_btn->setEnabled(row != -1); }); - QObject::connect(selected_list, &QListWidget::currentRowChanged, [=](int row) { remove_btn->setEnabled(row != -1); }); - QObject::connect(available_list, &QListWidget::itemDoubleClicked, this, &SignalSelector::add); - QObject::connect(selected_list, &QListWidget::itemDoubleClicked, this, &SignalSelector::remove); - QObject::connect(add_btn, &QPushButton::clicked, [this]() { if (auto item = available_list->currentItem()) add(item); }); - QObject::connect(remove_btn, &QPushButton::clicked, [this]() { if (auto item = selected_list->currentItem()) remove(item); }); - QObject::connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); - QObject::connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); -} - -void SignalSelector::add(QListWidgetItem *item) { - auto it = (ListItem *)item; - addItemToList(selected_list, it->msg_id, it->sig, true); - delete item; -} - -void SignalSelector::remove(QListWidgetItem *item) { - auto it = (ListItem *)item; - if (it->msg_id == msgs_combo->currentData().value()) { - addItemToList(available_list, it->msg_id, it->sig); - } - delete item; -} - -void SignalSelector::updateAvailableList(int index) { - if (index == -1) return; - available_list->clear(); - MessageId msg_id = msgs_combo->itemData(index).value(); - auto selected_items = seletedItems(); - for (auto s : dbc()->msg(msg_id)->getSignals()) { - bool is_selected = std::any_of(selected_items.begin(), selected_items.end(), - [sig = s, &msg_id](auto it) { return it->msg_id == msg_id && it->sig == sig; }); - if (!is_selected) { - addItemToList(available_list, msg_id, s); - } - } -} - -void SignalSelector::addItemToList(QListWidget *parent, const MessageId id, const cabana::Signal *sig, bool show_msg_name) { - QString text = QString(" %1").arg(toQColor(sig->color).name(), QString::fromStdString(sig->name)); - if (show_msg_name) text += QString(" %0 %1").arg(QString::fromStdString(msgName(id)), QString::fromStdString(id.toString())); - - QLabel *label = new QLabel(text); - label->setContentsMargins(5, 0, 5, 0); - auto new_item = new ListItem(id, sig, parent); - new_item->setSizeHint(label->sizeHint()); - parent->setItemWidget(new_item, label); -} - -std::vector SignalSelector::seletedItems() { - std::vector ret; - for (int i = 0; i < selected_list->count(); ++i) ret.push_back((ListItem *)selected_list->item(i)); - return ret; -} diff --git a/openpilot/tools/cabana/chart/signalselector.h b/openpilot/tools/cabana/chart/signalselector.h deleted file mode 100644 index 5b6e37e56a..0000000000 --- a/openpilot/tools/cabana/chart/signalselector.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" - -class SignalSelector : public QDialog { -public: - struct ListItem : public QListWidgetItem { - ListItem(const MessageId &msg_id, const cabana::Signal *sig, QListWidget *parent) : msg_id(msg_id), sig(sig), QListWidgetItem(parent) {} - MessageId msg_id; - const cabana::Signal *sig; - }; - - SignalSelector(QString title, QWidget *parent); - std::vector seletedItems(); - inline void addSelected(const MessageId &id, const cabana::Signal *sig) { addItemToList(selected_list, id, sig, true); } - -private: - void updateAvailableList(int index); - void addItemToList(QListWidget *parent, const MessageId id, const cabana::Signal *sig, bool show_msg_name = false); - void add(QListWidgetItem *item); - void remove(QListWidgetItem *item); - - QComboBox *msgs_combo; - QListWidget *available_list; - QListWidget *selected_list; -}; diff --git a/openpilot/tools/cabana/chart/sparkline.cc b/openpilot/tools/cabana/chart/sparkline.cc deleted file mode 100644 index 587a35956d..0000000000 --- a/openpilot/tools/cabana/chart/sparkline.cc +++ /dev/null @@ -1,101 +0,0 @@ -#include "tools/cabana/chart/sparkline.h" - -#include -#include -#include -#include "tools/cabana/utils/qtutil.h" - -void Sparkline::update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, int range, QSize size) { - if (first == last || size.isEmpty()) { - pixmap = QPixmap(); - return; - } - - points_.clear(); - min_val = std::numeric_limits::max(); - max_val = std::numeric_limits::lowest(); - points_.reserve(std::distance(first, last)); - - uint64_t start_time = (*first)->mono_time; - double value = 0.0; - for (auto it = first; it != last; ++it) { - if (sig->getValue((*it)->dat, (*it)->size, &value)) { - min_val = std::min(min_val, value); - max_val = std::max(max_val, value); - points_.emplace_back(((*it)->mono_time - start_time) / 1e9, value); - } - } - - if (points_.empty()) { - pixmap = QPixmap(); - return; - } - - freq_ = points_.size() / std::max(points_.back().x() - points_.front().x(), 1.0); - render(toQColor(sig->color), range, size); -} - -void Sparkline::render(const QColor &color, int range, QSize size) { - // Adjust for flat lines - bool is_flat_line = min_val == max_val; - if (is_flat_line) { - min_val -= 1.0; - max_val += 1.0; - } - - // Calculate scaling - const double xscale = (size.width() - 1) / (double)range; - const double yscale = (size.height() - 3) / (max_val - min_val); - bool draw_individual_points = (points_.back().x() * xscale / points_.size()) > 8.0; - - // Transform or downsample points - render_points_.reserve(points_.size()); - render_points_.clear(); - if (draw_individual_points) { - for (const auto &p : points_) { - render_points_.emplace_back(p.x() * xscale, 1.0 + (max_val - p.y()) * yscale); - } - } else if (is_flat_line) { - double y = size.height() / 2.0; - render_points_.emplace_back(0.0, y); - render_points_.emplace_back(points_.back().x() * xscale, y); - } else { - double prev_y = points_.front().y(); - render_points_.emplace_back(points_.front().x() * xscale, 1.0 + (max_val - prev_y) * yscale); - bool in_flat = false; - - for (size_t i = 1; i < points_.size(); ++i) { - const auto &p = points_[i]; - double y = p.y(); - if (std::abs(y - prev_y) < 1e-6) { - in_flat = true; - } else { - if (in_flat) render_points_.emplace_back(points_[i - 1].x() * xscale, 1.0 + (max_val - prev_y) * yscale); - render_points_.emplace_back(p.x() * xscale, 1.0 + (max_val - y) * yscale); - in_flat = false; - } - prev_y = y; - } - if (in_flat) render_points_.emplace_back(points_.back().x() * xscale, 1.0 + (max_val - prev_y) * yscale); - } - - // Render to pixmap - qreal dpr = qApp->devicePixelRatio(); - const QSize pixmap_size = size * dpr; - if (pixmap.size() != pixmap_size) { - pixmap = QPixmap(pixmap_size); - } - pixmap.setDevicePixelRatio(dpr); - pixmap.fill(Qt::transparent); - QPainter painter(&pixmap); - painter.setRenderHint(QPainter::Antialiasing, render_points_.size() <= 500); - painter.setPen(color); - painter.drawPolyline(render_points_.data(), render_points_.size()); - - painter.setPen(QPen(color, 3)); - if (draw_individual_points) { - painter.drawPoints(render_points_.data(), render_points_.size()); - } else { - painter.drawPoint(render_points_.back()); - } -} diff --git a/openpilot/tools/cabana/chart/sparkline.h b/openpilot/tools/cabana/chart/sparkline.h deleted file mode 100644 index 7f30047d0d..0000000000 --- a/openpilot/tools/cabana/chart/sparkline.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "tools/cabana/dbc/dbc.h" -#include "tools/cabana/streams/abstractstream.h" - -class Sparkline { -public: - void update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, int range, QSize size); - inline double freq() const { return freq_; } - bool isEmpty() const { return pixmap.isNull(); } - - QPixmap pixmap; - double min_val = 0; - double max_val = 0; - -private: - void render(const QColor &color, int range, QSize size); - - std::vector points_; - std::vector render_points_; - double freq_ = 0; -}; diff --git a/openpilot/tools/cabana/chart/tiplabel.cc b/openpilot/tools/cabana/chart/tiplabel.cc deleted file mode 100644 index c250ebf877..0000000000 --- a/openpilot/tools/cabana/chart/tiplabel.cc +++ /dev/null @@ -1,58 +0,0 @@ -#include "tools/cabana/chart/tiplabel.h" - -#include - -#include -#include -#include - -#include "tools/cabana/settings.h" -#include "tools/cabana/utils/qtutil.h" - -TipLabel::TipLabel(QWidget *parent) : QLabel(parent, Qt::ToolTip | Qt::FramelessWindowHint) { - setAttribute(Qt::WA_ShowWithoutActivating); - setAttribute(Qt::WA_TransparentForMouseEvents); - - setForegroundRole(QPalette::ToolTipText); - setBackgroundRole(QPalette::ToolTipBase); - - QFont font; - font.setPointSizeF(8.34563465); - setFont(font); - auto palette = QToolTip::palette(); - if (!utils::isDarkTheme()) { - palette.setColor(QPalette::ToolTipBase, QApplication::palette().color(QPalette::Base)); - palette.setColor(QPalette::ToolTipText, QRgb(0x404044)); // same color as chart label brush - } - setPalette(palette); - ensurePolished(); - setMargin(1 + style()->pixelMetric(QStyle::PM_ToolTipLabelFrameWidth, nullptr, this)); - setTextFormat(Qt::RichText); -} - -void TipLabel::showText(const QPoint &pt, const QString &text, QWidget *w, const QRect &rect) { - setText(text); - if (!text.isEmpty()) { - QSize extra(1, 1); - resize(sizeHint() + extra); - QPoint tip_pos(pt.x() + 8, rect.top() + 2); - if (tip_pos.x() + size().width() >= rect.right()) { - tip_pos.rx() = pt.x() - size().width() - 8; - } - if (rect.contains({tip_pos, size()})) { - move(w->mapToGlobal(tip_pos)); - setVisible(true); - return; - } - } - setVisible(false); -} - -void TipLabel::paintEvent(QPaintEvent *ev) { - QStylePainter p(this); - QStyleOptionFrame opt; - opt.init(this); - p.drawPrimitive(QStyle::PE_PanelTipLabel, opt); - p.end(); - QLabel::paintEvent(ev); -} diff --git a/openpilot/tools/cabana/chart/tiplabel.h b/openpilot/tools/cabana/chart/tiplabel.h deleted file mode 100644 index cc96aa9864..0000000000 --- a/openpilot/tools/cabana/chart/tiplabel.h +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once - -#include - -class TipLabel : public QLabel { - Q_OBJECT - -public: - TipLabel(QWidget *parent = nullptr); - void showText(const QPoint &pt, const QString &sec, QWidget *w, const QRect &rect); - void paintEvent(QPaintEvent *ev) override; -}; diff --git a/openpilot/tools/cabana/detailwidget.cc b/openpilot/tools/cabana/detailwidget.cc deleted file mode 100644 index d0cccb5992..0000000000 --- a/openpilot/tools/cabana/detailwidget.cc +++ /dev/null @@ -1,323 +0,0 @@ -#include "tools/cabana/detailwidget.h" - -#include -#include -#include -#include -#include - -#include "tools/cabana/commands.h" -#include "tools/cabana/mainwin.h" - -// DetailWidget - -DetailWidget::DetailWidget(ChartsWidget *charts, QWidget *parent) : charts(charts), QWidget(parent) { - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - - // tabbar - tabbar = new TabBar(this); - tabbar->setUsesScrollButtons(true); - tabbar->setAutoHide(true); - tabbar->setContextMenuPolicy(Qt::CustomContextMenu); - main_layout->addWidget(tabbar); - - createToolBar(); - - // warning - warning_widget = new QWidget(this); - QHBoxLayout *warning_hlayout = new QHBoxLayout(warning_widget); - warning_hlayout->addWidget(warning_icon = new QLabel(this), 0, Qt::AlignTop); - warning_hlayout->addWidget(warning_label = new QLabel(this), 1, Qt::AlignLeft); - warning_widget->hide(); - main_layout->addWidget(warning_widget); - - // msg widget - splitter = new QSplitter(Qt::Vertical, this); - splitter->addWidget(binary_view = new BinaryView(this)); - splitter->addWidget(signal_view = new SignalView(charts, this)); - binary_view->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum); - signal_view->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); - splitter->setStretchFactor(0, 0); - splitter->setStretchFactor(1, 1); - - tab_widget = new QTabWidget(this); - tab_widget->setStyleSheet("QTabWidget::pane {border: none; margin-bottom: -2px;}"); - tab_widget->setTabPosition(QTabWidget::South); - tab_widget->addTab(splitter, utils::icon("file-earmark-ruled"), "&Msg"); - tab_widget->addTab(history_log = new LogsWidget(this), utils::icon("stopwatch"), "&Logs"); - main_layout->addWidget(tab_widget); - - QObject::connect(binary_view, &BinaryView::signalHovered, signal_view, &SignalView::signalHovered); - QObject::connect(binary_view, &BinaryView::signalClicked, [this](const cabana::Signal *s) { signal_view->selectSignal(s, true); }); - QObject::connect(binary_view, &BinaryView::editSignal, signal_view->model, &SignalModel::saveSignal); - QObject::connect(binary_view, &BinaryView::showChart, charts, &ChartsWidget::showChart); - QObject::connect(signal_view, &SignalView::showChart, charts, &ChartsWidget::showChart); - QObject::connect(signal_view, &SignalView::highlight, binary_view, &BinaryView::highlight); - QObject::connect(tab_widget, &QTabWidget::currentChanged, [this]() { updateState(); }); - connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool) { updateState(msgs); })); - connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); - connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { refresh(); })); - QObject::connect(tabbar, &QTabBar::customContextMenuRequested, this, &DetailWidget::showTabBarContextMenu); - QObject::connect(tabbar, &QTabBar::currentChanged, [this](int index) { - if (index != -1) { - setMessage(tabbar->tabData(index).value()); - } - }); - QObject::connect(tabbar, &QTabBar::tabCloseRequested, tabbar, &QTabBar::removeTab); - QObject::connect(charts, &ChartsWidget::seriesChanged, signal_view, &SignalView::updateChartState); -} - -void DetailWidget::createToolBar() { - QToolBar *toolbar = new QToolBar(this); - int icon_size = style()->pixelMetric(QStyle::PM_SmallIconSize); - toolbar->setIconSize({icon_size, icon_size}); - toolbar->addWidget(name_label = new ElidedLabel(this)); - name_label->setStyleSheet("QLabel{font-weight:bold;}"); - - QWidget *spacer = new QWidget(); - spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); - toolbar->addWidget(spacer); - -// Heatmap label and radio buttons - toolbar->addWidget(new QLabel(tr("Heatmap:"), this)); - auto *heatmap_live = new QRadioButton(tr("Live"), this); - auto *heatmap_all = new QRadioButton(tr("All"), this); - heatmap_live->setChecked(true); - - toolbar->addWidget(heatmap_live); - toolbar->addWidget(heatmap_all); - - // Edit and remove buttons - toolbar->addSeparator(); - toolbar->addAction(utils::icon("pencil"), tr("Edit Message"), this, &DetailWidget::editMsg); - action_remove_msg = toolbar->addAction(utils::icon("x-lg"), tr("Remove Message"), this, &DetailWidget::removeMsg); - - layout()->addWidget(toolbar); - - connect(heatmap_live, &QAbstractButton::toggled, this, [this](bool on) { binary_view->setHeatmapLiveMode(on); }); - connections_.push_back(can->timeRangeChanged.connect([=](const std::optional> &range) { - auto text = range ? QString("%1 - %2").arg(range->first, 0, 'f', 3).arg(range->second, 0, 'f', 3) : "All"; - heatmap_all->setText(text); - (range ? heatmap_all : heatmap_live)->setChecked(true); - })); -} - -void DetailWidget::showTabBarContextMenu(const QPoint &pt) { - int index = tabbar->tabAt(pt); - if (index >= 0) { - QMenu menu(this); - menu.addAction(tr("Close Other Tabs")); - if (menu.exec(tabbar->mapToGlobal(pt))) { - tabbar->moveTab(index, 0); - tabbar->setCurrentIndex(0); - while (tabbar->count() > 1) { - tabbar->removeTab(1); - } - } - } -} - -int DetailWidget::findOrAddTab(const MessageId& message_id) { - int index = tabbar->count() - 1; - for (/**/; index >= 0; --index) { - if (tabbar->tabData(index).value() == message_id) break; - } - if (index == -1) { - index = tabbar->addTab(QString::fromStdString(message_id.toString())); - tabbar->setTabData(index, QVariant::fromValue(message_id)); - tabbar->setTabToolTip(index, QString::fromStdString(msgName(message_id))); - } - return index; -} - -void DetailWidget::setMessage(const MessageId &message_id) { - if (std::exchange(msg_id, message_id) == message_id) return; - - tabbar->blockSignals(true); - int index = findOrAddTab(message_id); - tabbar->setCurrentIndex(index); - tabbar->blockSignals(false); - - setUpdatesEnabled(false); - signal_view->setMessage(msg_id); - binary_view->setMessage(msg_id); - history_log->setMessage(msg_id); - refresh(); - setUpdatesEnabled(true); -} - -std::pair DetailWidget::serializeMessageIds() const { - QStringList msgs; - for (int i = 0; i < tabbar->count(); ++i) { - MessageId id = tabbar->tabData(i).value(); - msgs.append(QString::fromStdString(id.toString())); - } - return std::make_pair(QString::fromStdString(msg_id.toString()), msgs); -} - -void DetailWidget::restoreTabs(const QString active_msg_id, const QStringList& msg_ids) { - tabbar->blockSignals(true); - for (const auto& str_id : msg_ids) { - MessageId id = MessageId::fromString(str_id.toStdString()); - if (dbc()->msg(id) != nullptr) - findOrAddTab(id); - } - tabbar->blockSignals(false); - - auto active_id = MessageId::fromString(active_msg_id.toStdString()); - if (dbc()->msg(active_id) != nullptr) - setMessage(active_id); -} - -void DetailWidget::refresh() { - QStringList warnings; - auto msg = dbc()->msg(msg_id); - if (msg) { - if (msg_id.source == INVALID_SOURCE) { - warnings.push_back(tr("No messages received.")); - } else if (msg->size != can->lastMessage(msg_id).dat.size()) { - warnings.push_back(tr("Message size (%1) is incorrect.").arg(msg->size)); - } - for (auto s : binary_view->getOverlappingSignals()) { - warnings.push_back(tr("%1 has overlapping bits.").arg(QString::fromStdString(s->name))); - } - } - QString msg_name = msg ? QString("%1 (%2)").arg(QString::fromStdString(msg->name), QString::fromStdString(msg->transmitter)) : QString::fromStdString(msgName(msg_id)); - name_label->setText(msg_name); - name_label->setToolTip(msg_name); - action_remove_msg->setEnabled(msg != nullptr); - - if (!warnings.isEmpty()) { - warning_label->setText(warnings.join('\n')); - warning_icon->setPixmap(utils::icon(msg ? "exclamation-triangle" : "info-circle")); - } - warning_widget->setVisible(!warnings.isEmpty()); -} - -void DetailWidget::updateState(const std::set *msgs) { - if ((msgs && !msgs->count(msg_id))) - return; - - if (tab_widget->currentIndex() == 0) - binary_view->updateState(); - else - history_log->updateState(); -} - -void DetailWidget::editMsg() { - auto msg = dbc()->msg(msg_id); - int size = msg ? msg->size : can->lastMessage(msg_id).dat.size(); - EditMessageDialog dlg(msg_id, QString::fromStdString(msgName(msg_id)), size, this); - if (dlg.exec()) { - UndoStack::instance()->push(new EditMsgCommand(msg_id, dlg.name_edit->text().trimmed().toStdString(), dlg.size_spin->value(), - dlg.node->text().trimmed().toStdString(), dlg.comment_edit->toPlainText().trimmed().toStdString())); - } -} - -void DetailWidget::removeMsg() { - UndoStack::instance()->push(new RemoveMsgCommand(msg_id)); -} - -// EditMessageDialog - -EditMessageDialog::EditMessageDialog(const MessageId &msg_id, const QString &title, int size, QWidget *parent) - : original_name(title), msg_id(msg_id), QDialog(parent) { - setWindowTitle(tr("Edit message: %1").arg(QString::fromStdString(msg_id.toString()))); - QFormLayout *form_layout = new QFormLayout(this); - - form_layout->addRow("", error_label = new QLabel); - error_label->setVisible(false); - form_layout->addRow(tr("Name"), name_edit = new QLineEdit(title, this)); - name_edit->setValidator(new NameValidator(name_edit)); - - form_layout->addRow(tr("Size"), size_spin = new QSpinBox(this)); - size_spin->setRange(1, CAN_MAX_DATA_BYTES); - size_spin->setValue(size); - - form_layout->addRow(tr("Node"), node = new QLineEdit(this)); - node->setValidator(new NameValidator(name_edit)); - form_layout->addRow(tr("Comment"), comment_edit = new QTextEdit(this)); - form_layout->addRow(btn_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel)); - - if (auto msg = dbc()->msg(msg_id)) { - node->setText(QString::fromStdString(msg->transmitter)); - comment_edit->setText(QString::fromStdString(msg->comment)); - } - validateName(name_edit->text()); - setFixedWidth(parent->width() * 0.9); - connect(name_edit, &QLineEdit::textEdited, this, &EditMessageDialog::validateName); - connect(btn_box, &QDialogButtonBox::accepted, this, &QDialog::accept); - connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject); -} - -void EditMessageDialog::validateName(const QString &text) { - bool valid = text.compare(QString::fromStdString(UNTITLED), Qt::CaseInsensitive) != 0; - error_label->setVisible(false); - if (!text.isEmpty() && valid && text != original_name) { - valid = dbc()->msg(msg_id.source, text.toStdString()) == nullptr; - if (!valid) { - error_label->setText(tr("Name already exists")); - error_label->setVisible(true); - } - } - btn_box->button(QDialogButtonBox::Ok)->setEnabled(valid); -} - -// CenterWidget - -CenterWidget::CenterWidget(QWidget *parent) : QWidget(parent) { - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - main_layout->addWidget(welcome_widget = createWelcomeWidget()); -} - -DetailWidget* CenterWidget::ensureDetailWidget() { - if (!detail_widget) { - delete welcome_widget; - welcome_widget = nullptr; - layout()->addWidget(detail_widget = new DetailWidget(((MainWindow*)parentWidget())->charts_widget, this)); - } - return detail_widget; -} - -void CenterWidget::clear() { - delete detail_widget; - detail_widget = nullptr; - if (!welcome_widget) { - layout()->addWidget(welcome_widget = createWelcomeWidget()); - } -} - -QWidget *CenterWidget::createWelcomeWidget() { - QWidget *w = new QWidget(this); - QVBoxLayout *main_layout = new QVBoxLayout(w); - main_layout->addStretch(0); - QLabel *logo = new QLabel("CABANA"); - logo->setAlignment(Qt::AlignCenter); - logo->setStyleSheet("font-size:50px;font-weight:bold;"); - main_layout->addWidget(logo); - - auto newShortcutRow = [](const QString &title, const QString &key) { - QHBoxLayout *hlayout = new QHBoxLayout(); - auto btn = new QToolButton(); - btn->setText(key); - btn->setEnabled(false); - hlayout->addWidget(new QLabel(title), 0, Qt::AlignRight); - hlayout->addWidget(btn, 0, Qt::AlignLeft); - return hlayout; - }; - - auto lb = new QLabel(tr("<-Select a message to view details")); - lb->setAlignment(Qt::AlignHCenter); - main_layout->addWidget(lb); - main_layout->addLayout(newShortcutRow("Pause", "Space")); - main_layout->addLayout(newShortcutRow("Help", "F1")); - main_layout->addLayout(newShortcutRow("WhatsThis", "Shift+F1")); - main_layout->addStretch(0); - - w->setStyleSheet("QLabel{color:darkGray;}"); - w->setBackgroundRole(QPalette::Base); - w->setAutoFillBackground(true); - return w; -} diff --git a/openpilot/tools/cabana/detailwidget.h b/openpilot/tools/cabana/detailwidget.h deleted file mode 100644 index c003548da4..0000000000 --- a/openpilot/tools/cabana/detailwidget.h +++ /dev/null @@ -1,78 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/binaryview.h" -#include "tools/cabana/chart/chartswidget.h" -#include "tools/cabana/historylog.h" -#include "tools/cabana/signalview.h" -#include "tools/cabana/utils/elidedlabel.h" -#include "tools/cabana/utils/qtutil.h" - -class EditMessageDialog : public QDialog { -public: - EditMessageDialog(const MessageId &msg_id, const QString &title, int size, QWidget *parent); - void validateName(const QString &text); - - MessageId msg_id; - QString original_name; - QDialogButtonBox *btn_box; - QLineEdit *name_edit; - QLineEdit *node; - QTextEdit *comment_edit; - QLabel *error_label; - QSpinBox *size_spin; -}; - -class DetailWidget : public QWidget { - Q_OBJECT - -public: - DetailWidget(ChartsWidget *charts, QWidget *parent); - void setMessage(const MessageId &message_id); - void refresh(); - std::pair serializeMessageIds() const; - void restoreTabs(const QString active_msg_id, const QStringList &msg_ids); - -private: - void createToolBar(); - int findOrAddTab(const MessageId& message_id); - void showTabBarContextMenu(const QPoint &pt); - void editMsg(); - void removeMsg(); - void updateState(const std::set *msgs = nullptr); - - MessageId msg_id; - QLabel *warning_icon, *warning_label; - ElidedLabel *name_label; - QWidget *warning_widget; - TabBar *tabbar; - QTabWidget *tab_widget; - QAction *action_remove_msg; - LogsWidget *history_log; - BinaryView *binary_view; - SignalView *signal_view; - ChartsWidget *charts; - QSplitter *splitter; - Connections connections_; -}; - -class CenterWidget : public QWidget { - Q_OBJECT -public: - CenterWidget(QWidget *parent); - void setMessage(const MessageId &message_id) { ensureDetailWidget()->setMessage(message_id); } - DetailWidget* getDetailWidget() { return detail_widget; } - DetailWidget* ensureDetailWidget(); - void clear(); - -private: - QWidget *createWelcomeWidget(); - DetailWidget *detail_widget = nullptr; - QWidget *welcome_widget = nullptr; -}; diff --git a/openpilot/tools/cabana/historylog.cc b/openpilot/tools/cabana/historylog.cc deleted file mode 100644 index 26c0f4168f..0000000000 --- a/openpilot/tools/cabana/historylog.cc +++ /dev/null @@ -1,251 +0,0 @@ -#include "tools/cabana/historylog.h" - -#include - -#include -#include -#include - -#include "tools/cabana/commands.h" -#include "tools/cabana/utils/export.h" - -HistoryLogModel::HistoryLogModel(QObject *parent) : QAbstractTableModel(parent) { - connections_.push_back(can->seekedTo.connect([this](double) { reset(); })); - connections_.push_back(dbc()->fileChanged.connect([this]() { reset(); })); - connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { reset(); })); -} - -QVariant HistoryLogModel::data(const QModelIndex &index, int role) const { - const auto &m = messages[index.row()]; - const int col = index.column(); - if (role == Qt::DisplayRole) { - if (col == 0) return QString::number(can->toSeconds(m.mono_time), 'f', 3); - if (!isHexMode()) return QString::fromStdString(sigs[col - 1]->formatValue(m.sig_values[col - 1], false)); - } else if (role == Qt::TextAlignmentRole) { - return (uint32_t)(Qt::AlignRight | Qt::AlignVCenter); - } - - if (isHexMode() && col == 1) { - if (role == ColorsRole) return QVariant::fromValue((void *)(&m.colors)); - if (role == BytesRole) return QVariant::fromValue((void *)(&m.data)); - } - return {}; -} - -void HistoryLogModel::setMessage(const MessageId &message_id) { - msg_id = message_id; - reset(); -} - -void HistoryLogModel::reset() { - beginResetModel(); - sigs.clear(); - if (auto dbc_msg = dbc()->msg(msg_id)) { - sigs = dbc_msg->getSignals(); - } - messages.clear(); - hex_colors = {}; - endResetModel(); - setFilter(0, "", nullptr); -} - -QVariant HistoryLogModel::headerData(int section, Qt::Orientation orientation, int role) const { - if (orientation == Qt::Horizontal) { - if (role == Qt::DisplayRole || role == Qt::ToolTipRole) { - if (section == 0) return "Time"; - if (isHexMode()) return "Data"; - - QString name = QString::fromStdString(sigs[section - 1]->name); - QString unit = QString::fromStdString(sigs[section - 1]->unit); - return unit.isEmpty() ? name : QString("%1 (%2)").arg(name, unit); - } else if (role == Qt::BackgroundRole && section > 0 && !isHexMode()) { - // Alpha-blend the signal color with the background to ensure contrast - QColor sigColor = toQColor(sigs[section - 1]->color); - sigColor.setAlpha(128); - return QBrush(sigColor); - } - } - return {}; -} - -void HistoryLogModel::setHexMode(bool hex) { - hex_mode = hex; - reset(); -} - -void HistoryLogModel::setFilter(int sig_idx, const QString &value, std::function cmp) { - filter_sig_idx = sig_idx; - filter_value = value.toDouble(); - filter_cmp = value.isEmpty() ? nullptr : cmp; - updateState(true); -} - -void HistoryLogModel::updateState(bool clear) { - if (clear && !messages.empty()) { - beginRemoveRows({}, 0, messages.size() - 1); - messages.clear(); - endRemoveRows(); - } - uint64_t current_time = can->toMonoTime(can->lastMessage(msg_id).ts) + 1; - fetchData(messages.begin(), current_time, messages.empty() ? 0 : messages.front().mono_time); -} - -bool HistoryLogModel::canFetchMore(const QModelIndex &parent) const { - const auto &events = can->events(msg_id); - return !events.empty() && !messages.empty() && messages.back().mono_time > events.front()->mono_time; -} - -void HistoryLogModel::fetchMore(const QModelIndex &parent) { - if (!messages.empty()) - fetchData(messages.end(), messages.back().mono_time, 0); -} - -void HistoryLogModel::fetchData(std::deque::iterator insert_pos, uint64_t from_time, uint64_t min_time) { - const auto &events = can->events(msg_id); - auto first = std::upper_bound(events.rbegin(), events.rend(), from_time, [](uint64_t ts, auto e) { - return ts > e->mono_time; - }); - - std::vector msgs; - std::vector values(sigs.size()); - msgs.reserve(batch_size); - for (; first != events.rend() && (*first)->mono_time > min_time; ++first) { - const CanEvent *e = *first; - for (int i = 0; i < sigs.size(); ++i) { - sigs[i]->getValue(e->dat, e->size, &values[i]); - } - if (!filter_cmp || filter_cmp(values[filter_sig_idx], filter_value)) { - msgs.emplace_back(Message{e->mono_time, values, {e->dat, e->dat + e->size}}); - if (msgs.size() >= batch_size && min_time == 0) { - break; - } - } - } - - if (!msgs.empty()) { - if (isHexMode() && (min_time > 0 || messages.empty())) { - const auto freq = can->lastMessage(msg_id).freq; - const std::vector no_mask; - for (auto &m : msgs) { - hex_colors.compute(msg_id, m.data.data(), m.data.size(), m.mono_time / (double)1e9, can->getSpeed(), no_mask, freq); - m.colors = hex_colors.colors; - } - } - int pos = std::distance(messages.begin(), insert_pos); - beginInsertRows({}, pos , pos + msgs.size() - 1); - messages.insert(insert_pos, std::move_iterator(msgs.begin()), std::move_iterator(msgs.end())); - endInsertRows(); - } -} - -// HeaderView - -QSize HeaderView::sectionSizeFromContents(int logicalIndex) const { - static const QSize time_col_size = fontMetrics().size(Qt::TextSingleLine, "000000.000") + QSize(10, 6); - if (logicalIndex == 0) { - return time_col_size; - } else { - int default_size = qMax(100, (rect().width() - time_col_size.width()) / (model()->columnCount() - 1)); - QString text = model()->headerData(logicalIndex, this->orientation(), Qt::DisplayRole).toString(); - const QRect rect = fontMetrics().boundingRect({0, 0, default_size, 2000}, defaultAlignment(), text.replace(QChar('_'), ' ')); - QSize size = rect.size() + QSize{10, 6}; - return QSize{qMax(size.width(), default_size), size.height()}; - } -} - -void HeaderView::paintSection(QPainter *painter, const QRect &rect, int logicalIndex) const { - auto bg_role = model()->headerData(logicalIndex, Qt::Horizontal, Qt::BackgroundRole); - if (bg_role.isValid()) { - painter->fillRect(rect, bg_role.value()); - } - QString text = model()->headerData(logicalIndex, Qt::Horizontal, Qt::DisplayRole).toString(); - painter->setPen(palette().color(utils::isDarkTheme() ? QPalette::BrightText : QPalette::Text)); - painter->drawText(rect.adjusted(5, 3, -5, -3), defaultAlignment(), text.replace(QChar('_'), ' ')); -} - -// LogsWidget - -LogsWidget::LogsWidget(QWidget *parent) : QFrame(parent) { - setFrameStyle(QFrame::StyledPanel | QFrame::Plain); - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - main_layout->setSpacing(0); - - QWidget *toolbar = new QWidget(this); - toolbar->setAutoFillBackground(true); - QHBoxLayout *h = new QHBoxLayout(toolbar); - - filters_widget = new QWidget(this); - QHBoxLayout *filter_layout = new QHBoxLayout(filters_widget); - filter_layout->setContentsMargins(0, 0, 0, 0); - filter_layout->addWidget(display_type_cb = new QComboBox(this)); - filter_layout->addWidget(signals_cb = new QComboBox(this)); - filter_layout->addWidget(comp_box = new QComboBox(this)); - filter_layout->addWidget(value_edit = new QLineEdit(this)); - h->addWidget(filters_widget); - h->addStretch(0); - export_btn = new ToolButton("filetype-csv", tr("Export to CSV file...")); - h->addWidget(export_btn, 0, Qt::AlignRight); - - display_type_cb->addItems({"Signal", "Hex"}); - display_type_cb->setToolTip(tr("Display signal value or raw hex value")); - comp_box->addItems({">", "=", "!=", "<"}); - value_edit->setClearButtonEnabled(true); - value_edit->setValidator(new DoubleValidator(this)); - - main_layout->addWidget(toolbar); - QFrame *line = new QFrame(this); - line->setFrameStyle(QFrame::HLine | QFrame::Sunken); - main_layout->addWidget(line); - main_layout->addWidget(logs = new QTableView(this)); - logs->setModel(model = new HistoryLogModel(this)); - logs->setItemDelegate(delegate = new MessageBytesDelegate(this)); - logs->setHorizontalHeader(new HeaderView(Qt::Horizontal, this)); - logs->horizontalHeader()->setDefaultAlignment(Qt::AlignRight | (Qt::Alignment)Qt::TextWordWrap); - logs->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); - logs->verticalHeader()->setSectionResizeMode(QHeaderView::Fixed); - logs->verticalHeader()->setDefaultSectionSize(delegate->sizeForBytes(8).height()); - logs->setFrameShape(QFrame::NoFrame); - - QObject::connect(display_type_cb, qOverload(&QComboBox::activated), model, &HistoryLogModel::setHexMode); - QObject::connect(signals_cb, SIGNAL(activated(int)), this, SLOT(filterChanged())); - QObject::connect(comp_box, SIGNAL(activated(int)), this, SLOT(filterChanged())); - QObject::connect(value_edit, &QLineEdit::textEdited, this, &LogsWidget::filterChanged); - QObject::connect(export_btn, &QToolButton::clicked, this, &LogsWidget::exportToCSV); - QObject::connect(model, &HistoryLogModel::modelReset, this, &LogsWidget::modelReset); - QObject::connect(model, &HistoryLogModel::rowsInserted, [this]() { export_btn->setEnabled(true); }); -} - -void LogsWidget::modelReset() { - signals_cb->clear(); - for (auto s : model->sigs) { - signals_cb->addItem(QString::fromStdString(s->name)); - } - export_btn->setEnabled(false); - value_edit->clear(); - comp_box->setCurrentIndex(0); - filters_widget->setVisible(!model->sigs.empty()); -} - -void LogsWidget::filterChanged() { - if (value_edit->text().isEmpty() && !value_edit->isModified()) return; - - std::function cmp = nullptr; - switch (comp_box->currentIndex()) { - case 0: cmp = std::greater{}; break; - case 1: cmp = std::equal_to{}; break; - case 2: cmp = [](double l, double r) { return l != r; }; break; // not equal - case 3: cmp = std::less{}; break; - } - model->setFilter(signals_cb->currentIndex(), value_edit->text(), cmp); -} - -void LogsWidget::exportToCSV() { - QString dir = QString("%1/%2_%3.csv").arg(QString::fromStdString(settings.last_dir)).arg(QString::fromStdString(can->routeName())).arg(QString::fromStdString(msgName(model->msg_id))); - QString fn = QFileDialog::getSaveFileName(this, QString("Export %1 to CSV file").arg(QString::fromStdString(msgName(model->msg_id))), - dir, tr("csv (*.csv)")); - if (!fn.isEmpty()) { - model->isHexMode() ? utils::exportToCSV(fn.toStdString(), model->msg_id) - : utils::exportSignalsToCSV(fn.toStdString(), model->msg_id); - } -} diff --git a/openpilot/tools/cabana/historylog.h b/openpilot/tools/cabana/historylog.h deleted file mode 100644 index 1b75e3b8a9..0000000000 --- a/openpilot/tools/cabana/historylog.h +++ /dev/null @@ -1,83 +0,0 @@ -#pragma once - -#include -#include - -#include -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/streams/abstractstream.h" -#include "tools/cabana/utils/qtutil.h" - -class HeaderView : public QHeaderView { -public: - HeaderView(Qt::Orientation orientation, QWidget *parent = nullptr) : QHeaderView(orientation, parent) {} - QSize sectionSizeFromContents(int logicalIndex) const override; - void paintSection(QPainter *painter, const QRect &rect, int logicalIndex) const; -}; - -class HistoryLogModel : public QAbstractTableModel { - Q_OBJECT - -public: - HistoryLogModel(QObject *parent); - void setMessage(const MessageId &message_id); - void updateState(bool clear = false); - void setFilter(int sig_idx, const QString &value, std::function cmp); - QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - void fetchMore(const QModelIndex &parent) override; - bool canFetchMore(const QModelIndex &parent) const override; - int rowCount(const QModelIndex &parent = QModelIndex()) const override { return messages.size(); } - int columnCount(const QModelIndex &parent = QModelIndex()) const override { return !isHexMode() ? sigs.size() + 1 : 2; } - inline bool isHexMode() const { return sigs.empty() || hex_mode; } - void reset(); - void setHexMode(bool hex_mode); - - struct Message { - uint64_t mono_time = 0; - std::vector sig_values; - std::vector data; - std::vector colors; - }; - - void fetchData(std::deque::iterator insert_pos, uint64_t from_time, uint64_t min_time); - - MessageId msg_id; - CanData hex_colors; - const int batch_size = 50; - int filter_sig_idx = -1; - double filter_value = 0; - std::function filter_cmp = nullptr; - std::deque messages; - std::vector sigs; - bool hex_mode = false; - Connections connections_; -}; - -class LogsWidget : public QFrame { - Q_OBJECT - -public: - LogsWidget(QWidget *parent); - void setMessage(const MessageId &message_id) { model->setMessage(message_id); } - void updateState() { model->updateState(); } - void showEvent(QShowEvent *event) override { model->updateState(true); } - -private slots: - void filterChanged(); - void exportToCSV(); - void modelReset(); - -private: - QTableView *logs; - HistoryLogModel *model; - QComboBox *signals_cb, *comp_box, *display_type_cb; - QLineEdit *value_edit; - QWidget *filters_widget; - ToolButton *export_btn; - MessageBytesDelegate *delegate; -}; diff --git a/openpilot/tools/cabana/mainwin.cc b/openpilot/tools/cabana/mainwin.cc deleted file mode 100644 index c7a838a756..0000000000 --- a/openpilot/tools/cabana/mainwin.cc +++ /dev/null @@ -1,752 +0,0 @@ -#include "tools/cabana/mainwin.h" - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "json11/json11.hpp" -#include "tools/cabana/commands.h" -#include "tools/cabana/settingsdialog.h" -#include "tools/cabana/streamselector.h" -#include "tools/cabana/tools/findsignal.h" -#include "tools/cabana/utils/export.h" -#include "tools/cabana/utils/qtutil.h" -#include "tools/replay/py_downloader.h" -#include "tools/replay/util.h" - -MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainWindow() { - loadFingerprints(); - createDockWindows(); - setCentralWidget(center_widget = new CenterWidget(this)); - createActions(); - createStatusBar(); - createShortcuts(); - - // save default window state to allow resetting it - default_state = utils::toBytes(saveState()); - - // restore states; restoreGeometry() itself corrects stale off-screen geometry - restoreGeometry(utils::qbytes(settings.geometry)); - restoreState(utils::qbytes(settings.window_state)); - - // download handlers are called from download threads - static auto static_main_win = this; - installDownloadProgressHandler([](uint64_t cur, uint64_t total, bool success) { - utils::runOnMainThread([=]() { static_main_win->updateDownloadProgress(cur, total, success); }); - }); - installMessageHandler([](ReplyMsgType type, const std::string msg) { - utils::runOnMainThread([=]() { static_main_win->statusBar()->showMessage(QString::fromStdString(msg), 2000); }); - }); - - setStyleSheet(QString(R"(QMainWindow::separator { - width: %1px; /* when vertical */ - height: %1px; /* when horizontal */ - })").arg(style()->pixelMetric(QStyle::PM_SplitterWidth))); - - connections_.push_back(dbc()->fileChanged.connect([this]() { DBCFileChanged(); })); - connections_.push_back(UndoStack::instance()->cleanChanged.connect([this](bool clean) { undoStackCleanChanged(clean); })); - connections_.push_back(settings.changed.connect([this]() { updateStatus(); })); - - // temporary pump for the non-Qt main thread queue until imgui owns the loop - auto *queue_timer = new QTimer(this); - QObject::connect(queue_timer, &QTimer::timeout, utils::drainMainThreadQueue); - queue_timer->start(10); - - QTimer::singleShot(0, this, [=]() { stream ? openStream(stream, dbc_file) : selectAndOpenStream(); }); - show(); -} - -void MainWindow::loadFingerprints() { - std::ifstream json_file((QApplication::applicationDirPath() + "/dbc/car_fingerprint_to_dbc.json").toStdString()); - if (!json_file) return; - const std::string contents{std::istreambuf_iterator(json_file), std::istreambuf_iterator()}; - std::string err; - auto doc = json11::Json::parse(contents, err); - if (!err.empty() || !doc.is_object()) return; - fingerprint_to_dbc.clear(); - for (const auto &kv : doc.object_items()) { - if (kv.second.is_string()) { - fingerprint_to_dbc.emplace(kv.first, kv.second.string_value()); - } - } -} - -void MainWindow::createActions() { - // File menu - QMenu *file_menu = menuBar()->addMenu(tr("&File")); - file_menu->addAction(tr("Open Stream..."), this, &MainWindow::selectAndOpenStream); - close_stream_act = file_menu->addAction(tr("Close stream"), this, &MainWindow::closeStream); - export_to_csv_act = file_menu->addAction(tr("Export to CSV..."), this, &MainWindow::exportToCSV); - close_stream_act->setEnabled(false); - export_to_csv_act->setEnabled(false); - file_menu->addSeparator(); - - file_menu->addAction(tr("New DBC File"), [this]() { newFile(); }, QKeySequence::New); - file_menu->addAction(tr("Open DBC File..."), [this]() { openFile(); }, QKeySequence::Open); - - manage_dbcs_menu = file_menu->addMenu(tr("Manage &DBC Files")); - QObject::connect(manage_dbcs_menu, &QMenu::aboutToShow, this, &MainWindow::updateLoadSaveMenus); - - open_recent_menu = file_menu->addMenu(tr("Open &Recent")); - QObject::connect(open_recent_menu, &QMenu::aboutToShow, this, &MainWindow::updateRecentFileMenu); - - file_menu->addSeparator(); - QMenu *load_opendbc_menu = file_menu->addMenu(tr("Load DBC from commaai/opendbc")); - // load_opendbc_menu->setStyleSheet("QMenu { menu-scrollable: true; }"); - std::vector dbc_names; - std::error_code ec; - for (const auto &entry : std::filesystem::directory_iterator(OPENDBC_FILE_PATH, ec)) { - if (entry.is_regular_file() && entry.path().extension() == ".dbc") { - dbc_names.push_back(entry.path().filename().string()); - } - } - std::sort(dbc_names.begin(), dbc_names.end()); - for (const auto &dbc_name : dbc_names) { - QString name = QString::fromStdString(dbc_name); - load_opendbc_menu->addAction(name, [this, name]() { loadDBCFromOpendbc(name); }); - } - - file_menu->addAction(tr("Load DBC From Clipboard"), [=]() { loadFromClipboard(); }); - - file_menu->addSeparator(); - save_dbc = file_menu->addAction(tr("Save DBC..."), this, &MainWindow::save, QKeySequence::Save); - save_dbc_as = file_menu->addAction(tr("Save DBC As..."), this, &MainWindow::saveAs, QKeySequence::SaveAs); - copy_dbc_to_clipboard = file_menu->addAction(tr("Copy DBC To Clipboard"), this, &MainWindow::saveToClipboard); - - file_menu->addSeparator(); - file_menu->addAction(tr("Settings..."), this, &MainWindow::setOption, QKeySequence::Preferences); - - file_menu->addSeparator(); - file_menu->addAction(tr("E&xit"), qApp, &QApplication::closeAllWindows, QKeySequence::Quit); - - // Edit Menu - QMenu *edit_menu = menuBar()->addMenu(tr("&Edit")); - undo_act = edit_menu->addAction(tr("&Undo"), []() { UndoStack::instance()->undo(); }); - undo_act->setShortcuts(QKeySequence::Undo); - redo_act = edit_menu->addAction(tr("&Redo"), []() { UndoStack::instance()->redo(); }); - redo_act->setShortcuts(QKeySequence::Redo); - connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { updateUndoRedoActions(); })); - updateUndoRedoActions(); - - // View Menu - QMenu *view_menu = menuBar()->addMenu(tr("&View")); - auto act = view_menu->addAction(tr("Full Screen"), this, &MainWindow::toggleFullScreen, QKeySequence::FullScreen); - addAction(act); - view_menu->addSeparator(); - view_menu->addAction(messages_dock->toggleViewAction()); - view_menu->addAction(video_dock->toggleViewAction()); - view_menu->addSeparator(); - view_menu->addAction(tr("Reset Window Layout"), [this]() { restoreState(utils::qbytes(default_state)); }); - - // Tools Menu - tools_menu = menuBar()->addMenu(tr("&Tools")); - tools_menu->addAction(tr("Find &Similar Bits"), this, &MainWindow::findSimilarBits); - tools_menu->addAction(tr("&Find Signal"), this, &MainWindow::findSignal); - - // Help Menu - QMenu *help_menu = menuBar()->addMenu(tr("&Help")); - help_menu->addAction(tr("Help"), this, &MainWindow::onlineHelp, QKeySequence::HelpContents); - help_menu->addAction(tr("About &Qt"), qApp, &QApplication::aboutQt); -} - -void MainWindow::createDockWindows() { - messages_dock = new QDockWidget(tr("MESSAGES"), this); - messages_dock->setObjectName("MessagesPanel"); - messages_dock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea | Qt::TopDockWidgetArea | Qt::BottomDockWidgetArea); - messages_dock->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetClosable); - addDockWidget(Qt::LeftDockWidgetArea, messages_dock); - - video_dock = new QDockWidget("", this); - video_dock->setObjectName(tr("VideoPanel")); - video_dock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea); - video_dock->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetClosable); - addDockWidget(Qt::RightDockWidgetArea, video_dock); -} - -void MainWindow::createDockWidgets() { - messages_widget = new MessagesWidget(this); - messages_dock->setWidget(messages_widget); - QObject::connect(messages_widget, &MessagesWidget::titleChanged, messages_dock, &QDockWidget::setWindowTitle); - QObject::connect(messages_widget, &MessagesWidget::msgSelectionChanged, center_widget, &CenterWidget::setMessage); - - // right panel - charts_widget = new ChartsWidget(this); - QWidget *charts_container = new QWidget(this); - charts_layout = new QVBoxLayout(charts_container); - charts_layout->setContentsMargins(0, 0, 0, 0); - charts_layout->addWidget(charts_widget); - - // splitter between video and charts - video_splitter = new QSplitter(Qt::Vertical, this); - video_widget = new VideoWidget(this); - video_splitter->addWidget(video_widget); - - video_splitter->addWidget(charts_container); - video_splitter->setStretchFactor(1, 1); - video_splitter->restoreState(utils::qbytes(settings.video_splitter_state)); - video_splitter->handle(1)->setEnabled(!can->liveStreaming()); - video_dock->setWidget(video_splitter); - QObject::connect(charts_widget, &ChartsWidget::toggleChartsDocking, this, &MainWindow::toggleChartsDocking); - QObject::connect(charts_widget, &ChartsWidget::showTip, video_widget, &VideoWidget::showThumbnail); -} - -void MainWindow::createStatusBar() { - progress_bar = new QProgressBar(); - progress_bar->setRange(0, 100); - progress_bar->setTextVisible(true); - progress_bar->setFixedSize({300, 16}); - progress_bar->setVisible(false); - statusBar()->addWidget(new QLabel(tr("For Help, Press F1"))); - statusBar()->addPermanentWidget(progress_bar); - statusBar()->addPermanentWidget(status_label = new QLabel(this)); - updateStatus(); -} - -void MainWindow::createShortcuts() { - auto shortcut = new QShortcut(QKeySequence(Qt::Key_Space), this, nullptr, nullptr, Qt::ApplicationShortcut); - QObject::connect(shortcut, &QShortcut::activated, this, []() { - if (can) can->pause(!can->isPaused()); - }); - // TODO: add more shortcuts here. -} - -void MainWindow::undoStackCleanChanged(bool clean) { - setWindowModified(!clean); -} - -void MainWindow::updateUndoRedoActions() { - auto stack = UndoStack::instance(); - undo_act->setEnabled(stack->canUndo()); - undo_act->setText(stack->canUndo() ? tr("&Undo %1").arg(QString::fromStdString(stack->undoText())) : tr("&Undo")); - redo_act->setEnabled(stack->canRedo()); - redo_act->setText(stack->canRedo() ? tr("&Redo %1").arg(QString::fromStdString(stack->redoText())) : tr("&Redo")); -} - -void MainWindow::DBCFileChanged() { - UndoStack::instance()->clear(); - - // Update file menu - int cnt = dbc()->nonEmptyDBCCount(); - save_dbc->setText(cnt > 1 ? tr("Save %1 DBCs...").arg(cnt) : tr("Save DBC...")); - save_dbc->setEnabled(cnt > 0); - save_dbc_as->setEnabled(cnt == 1); - // TODO: Support clipboard for multiple files - copy_dbc_to_clipboard->setEnabled(cnt == 1); - manage_dbcs_menu->setEnabled(dynamic_cast(can) == nullptr); - - QStringList title; - for (auto f : dbc()->allDBCFiles()) { - title.push_back(tr("(%1) %2").arg(QString::fromStdString(toString(dbc()->sources(f))), QString::fromStdString(f->name()))); - } - setWindowFilePath(title.join(" | ")); - - QTimer::singleShot(0, this, &::MainWindow::restoreSessionState); -} - -void MainWindow::selectAndOpenStream() { - StreamSelector dlg(this); - if (dlg.exec()) { - openStream(dlg.stream(), dlg.dbcFile()); - } else if (!can) { - openStream(new DummyStream()); - } -} - -void MainWindow::closeStream() { - openStream(new DummyStream()); - if (dbc()->nonEmptyDBCCount() > 0) { - dbc()->fileChanged(); - } - statusBar()->showMessage(tr("stream closed")); -} - -void MainWindow::exportToCSV() { - QString dir = QString("%1/%2.csv").arg(QString::fromStdString(settings.last_dir)).arg(QString::fromStdString(can->routeName())); - QString fn = QFileDialog::getSaveFileName(this, "Export stream to CSV file", dir, tr("csv (*.csv)")); - if (!fn.isEmpty()) { - utils::exportToCSV(fn.toStdString()); - } -} - -void MainWindow::newFile(SourceSet s) { - closeFile(s); - dbc()->open(s, std::string(""), std::string("")); -} - -void MainWindow::openFile(SourceSet s) { - remindSaveChanges(); - QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), QString::fromStdString(settings.last_dir), "DBC (*.dbc)"); - if (!fn.isEmpty()) { - loadFile(fn, s); - } -} - -void MainWindow::loadFile(const QString &fn, SourceSet s) { - if (!fn.isEmpty()) { - closeFile(s); - - std::string error; - if (dbc()->open(s, fn.toStdString(), &error)) { - updateRecentFiles(fn); - statusBar()->showMessage(tr("DBC File %1 loaded").arg(fn), 2000); - } else { - QMessageBox msg_box(QMessageBox::Warning, tr("Failed to load DBC file"), tr("Failed to parse DBC file %1").arg(fn)); - msg_box.setDetailedText(QString::fromStdString(error)); - msg_box.exec(); - } - } -} - -void MainWindow::loadDBCFromOpendbc(const QString &name) { - loadFile(QString("%1/%2").arg(OPENDBC_FILE_PATH, name)); -} - -void MainWindow::loadFromClipboard(SourceSet s, bool close_all) { - std::string text; - if (!utils::getClipboardText(&text)) { - QMessageBox::warning(this, tr("Load From Clipboard"), tr("No clipboard tool found. Install xclip (X11) or wl-clipboard (Wayland).")); - return; - } - if (text.empty()) { - QMessageBox::warning(this, tr("Load From Clipboard"), tr("Clipboard is empty.")); - return; - } - - closeFile(s); - - std::string error; - bool ret = dbc()->open(s, std::string(""), text, &error); - if (ret && dbc()->nonEmptyDBCCount() > 0) { - QMessageBox::information(this, tr("Load From Clipboard"), tr("DBC Successfully Loaded!")); - } else { - QMessageBox msg_box(QMessageBox::Warning, tr("Failed to load DBC from clipboard"), tr("Make sure that you paste the text with correct format.")); - msg_box.setDetailedText(QString::fromStdString(error)); - msg_box.exec(); - } -} - -// stream threads read the global `can` until its destructor joins them -MainWindow::~MainWindow() { - delete can; - can = nullptr; -} - -void MainWindow::openStream(AbstractStream *stream, const QString &dbc_file) { - stream_connections_.clear(); - if (wait_dlg_) wait_dlg_->deleteLater(); - wait_dlg_ = nullptr; - delete can; - can = nullptr; - startStream(stream, dbc_file); -} - -void MainWindow::startStream(AbstractStream *stream, QString dbc_file) { - center_widget->clear(); - delete messages_widget; - delete video_splitter; - - can = stream; // take ownership - stream_connections_.push_back(can->error.connect([this](const std::string &msg) { - QMessageBox::warning(this, tr("Error"), QString::fromStdString(msg)); - })); - can->start(); - - loadFile(dbc_file); - statusBar()->showMessage(tr("Stream [%1] started").arg(QString::fromStdString(can->routeName())), 2000); - - bool has_stream = dynamic_cast(can) == nullptr; - close_stream_act->setEnabled(has_stream); - export_to_csv_act->setEnabled(has_stream); - tools_menu->setEnabled(has_stream); - createDockWidgets(); - - video_dock->setWindowTitle(QString::fromStdString(can->routeName())); - if (can->liveStreaming() || video_splitter->sizes()[0] == 0) { - // display video at minimum size. - video_splitter->setSizes({1, 1}); - } - // Don't overwrite already loaded DBC - if (!dbc()->nonEmptyDBCCount()) { - newFile(); - } - - stream_connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &) { eventsMerged(); })); - - if (has_stream) { - wait_dlg_ = new QProgressDialog( - can->liveStreaming() ? tr("Waiting for the live stream to start...") : tr("Loading segment data..."), - tr("&Abort"), 0, 100, this); - wait_dlg_->setWindowModality(Qt::WindowModal); - wait_dlg_->setFixedSize(400, wait_dlg_->sizeHint().height()); - QObject::connect(wait_dlg_, &QProgressDialog::canceled, this, &MainWindow::close); - wait_dlg_connection_ = can->eventsMerged.connect([this](const MessageEventsMap &) { - wait_dlg_->deleteLater(); - wait_dlg_ = nullptr; - wait_dlg_connection_.disconnect(); - }); - } -} - -void MainWindow::eventsMerged() { - if (!can->liveStreaming() && std::exchange(car_fingerprint, QString::fromStdString(can->carFingerprint())) != car_fingerprint) { - video_dock->setWindowTitle(tr("ROUTE: %1 FINGERPRINT: %2") - .arg(QString::fromStdString(can->routeName())) - .arg(car_fingerprint.isEmpty() ? tr("Unknown Car") : car_fingerprint)); - // Don't overwrite already loaded DBC - auto it = fingerprint_to_dbc.find(car_fingerprint.toStdString()); - if (!dbc()->nonEmptyDBCCount() && it != fingerprint_to_dbc.end()) { - QTimer::singleShot(0, this, [this, dbc_name = QString::fromStdString(it->second)]() { - loadDBCFromOpendbc(dbc_name + ".dbc"); - }); - } - } -} - -void MainWindow::save() { - // Save all open DBC files - for (auto dbc_file : dbc()->allDBCFiles()) { - if (dbc_file->isEmpty()) continue; - saveFile(dbc_file); - } -} - -void MainWindow::saveAs() { - // Save as all open DBC files. Should not be called with more than 1 file open - for (auto dbc_file : dbc()->allDBCFiles()) { - if (dbc_file->isEmpty()) continue; - saveFileAs(dbc_file); - } -} - -void MainWindow::closeFile(SourceSet s) { - remindSaveChanges(); - if (s == SOURCE_ALL) { - dbc()->closeAll(); - } else { - dbc()->close(s); - } -} - -void MainWindow::closeFile(DBCFile *dbc_file) { - assert(dbc_file != nullptr); - remindSaveChanges(); - dbc()->close(dbc_file); - // Ensure we always have at least one file open - if (dbc()->dbcCount() == 0) { - newFile(); - } -} - -void MainWindow::saveFile(DBCFile *dbc_file) { - assert(dbc_file != nullptr); - if (!dbc_file->filename.empty()) { - dbc_file->save(); - UndoStack::instance()->setClean(); - statusBar()->showMessage(tr("File saved"), 2000); - } else if (!dbc_file->isEmpty()) { - saveFileAs(dbc_file); - } -} - -void MainWindow::saveFileAs(DBCFile *dbc_file) { - QString title = tr("Save File (bus: %1)").arg(QString::fromStdString(toString(dbc()->sources(dbc_file)))); - QString fn = QFileDialog::getSaveFileName(this, title, QString::fromStdString((std::filesystem::path(settings.last_dir) / "untitled.dbc").string()), tr("DBC (*.dbc)")); - if (!fn.isEmpty()) { - dbc_file->saveAs(fn.toStdString()); - UndoStack::instance()->setClean(); - statusBar()->showMessage(tr("File saved as %1").arg(fn), 2000); - updateRecentFiles(fn); - } -} - -void MainWindow::saveToClipboard() { - // Copy all open DBC files to clipboard. Should not be called with more than 1 file open - for (auto dbc_file : dbc()->allDBCFiles()) { - if (dbc_file->isEmpty()) continue; - saveFileToClipboard(dbc_file); - } -} - -void MainWindow::saveFileToClipboard(DBCFile *dbc_file) { - assert(dbc_file != nullptr); - if (utils::setClipboardText(dbc_file->generateDBC())) { - QMessageBox::information(this, tr("Copy To Clipboard"), tr("DBC Successfully copied!")); - } else { - QMessageBox::warning(this, tr("Copy To Clipboard"), tr("Failed to copy DBC to clipboard. Install xclip (X11) or wl-clipboard (Wayland).")); - } -} - -void MainWindow::updateLoadSaveMenus() { - manage_dbcs_menu->clear(); - - for (int source : can->sources) { - if (source >= 64) continue; // Sent and blocked buses are handled implicitly - - SourceSet ss = {source, uint8_t(source + 128), uint8_t(source + 192)}; - - QMenu *bus_menu = new QMenu(this); - bus_menu->addAction(tr("New DBC File..."), [=]() { newFile(ss); }); - bus_menu->addAction(tr("Open DBC File..."), [=]() { openFile(ss); }); - bus_menu->addAction(tr("Load DBC From Clipboard..."), [=]() { loadFromClipboard(ss, false); }); - - // Show sub-menu for each dbc for this source. - auto dbc_file = dbc()->findDBCFile(source); - if (dbc_file) { - bus_menu->addSeparator(); - bus_menu->addAction(QString::fromStdString(dbc_file->name()) + " (" + QString::fromStdString(toString(dbc()->sources(dbc_file))) + ")")->setEnabled(false); - bus_menu->addAction(tr("Save..."), [=]() { saveFile(dbc_file); }); - bus_menu->addAction(tr("Save As..."), [=]() { saveFileAs(dbc_file); }); - bus_menu->addAction(tr("Copy to Clipboard..."), [=]() { saveFileToClipboard(dbc_file); }); - bus_menu->addAction(tr("Remove from this bus..."), [=]() { closeFile(ss); }); - bus_menu->addAction(tr("Remove from all buses..."), [=]() { closeFile(dbc_file); }); - } - bus_menu->setTitle(tr("Bus %1 (%2)").arg(source).arg(dbc_file ? QString::fromStdString(dbc_file->name()) : "No DBCs loaded")); - - manage_dbcs_menu->addMenu(bus_menu); - } -} - -void MainWindow::updateRecentFiles(const QString &fn) { - const std::string filename = fn.toStdString(); - settings.recent_files.erase(std::remove(settings.recent_files.begin(), settings.recent_files.end(), filename), settings.recent_files.end()); - settings.recent_files.insert(settings.recent_files.begin(), filename); - while (settings.recent_files.size() > MAX_RECENT_FILES) { - settings.recent_files.pop_back(); - } - settings.last_dir = std::filesystem::absolute(fn.toStdString()).parent_path().string(); -} - -void MainWindow::updateRecentFileMenu() { - open_recent_menu->clear(); - - int num_recent_files = std::min(settings.recent_files.size(), MAX_RECENT_FILES); - if (!num_recent_files) { - open_recent_menu->addAction(tr("No Recent Files"))->setEnabled(false); - return; - } - - for (int i = 0; i < num_recent_files; ++i) { - QString text = tr("&%1 %2").arg(i + 1).arg(QString::fromStdString(std::filesystem::path(settings.recent_files[i]).filename().string())); - open_recent_menu->addAction(text, this, [this, file = settings.recent_files[i]]() { loadFile(QString::fromStdString(file)); }); - } -} - -void MainWindow::remindSaveChanges() { - while (!UndoStack::instance()->isClean()) { - QString text = tr("You have unsaved changes. Press ok to save them, cancel to discard."); - int ret = QMessageBox::question(this, tr("Unsaved Changes"), text, QMessageBox::Ok | QMessageBox::Cancel); - if (ret != QMessageBox::Ok) break; - save(); - } - UndoStack::instance()->clear(); -} - -void MainWindow::updateDownloadProgress(uint64_t cur, uint64_t total, bool success) { - if (wait_dlg_) wait_dlg_->setValue((int)((cur / (double)total) * 100)); - if (success && cur < total) { - progress_bar->setValue((cur / (double)total) * 100); - progress_bar->setFormat(tr("Downloading %p% (%1)").arg(formattedDataSize(total).c_str())); - progress_bar->show(); - } else { - progress_bar->hide(); - } -} - -void MainWindow::updateStatus() { - status_label->setText(tr("Cached Minutes:%1").arg(settings.max_cached_minutes)); -} - -bool MainWindow::eventFilter(QObject *obj, QEvent *event) { - if (obj == floating_window && event->type() == QEvent::Close) { - toggleChartsDocking(); - return true; - } - return QMainWindow::eventFilter(obj, event); -} - -void MainWindow::toggleChartsDocking() { - if (floating_window) { - // Dock the charts widget back to the main window - floating_window->removeEventFilter(this); - charts_layout->insertWidget(0, charts_widget, 1); - floating_window->deleteLater(); - floating_window = nullptr; - charts_widget->setIsDocked(true); - } else { - // Float the charts widget in a separate window - floating_window = new QWidget(this, Qt::Window); - floating_window->setWindowTitle("Charts"); - floating_window->setLayout(new QVBoxLayout()); - floating_window->layout()->addWidget(charts_widget); - floating_window->installEventFilter(this); - floating_window->showMaximized(); - charts_widget->setIsDocked(false); - } -} - -void MainWindow::closeEvent(QCloseEvent *event) { - remindSaveChanges(); - - installDownloadProgressHandler(nullptr); - installMessageHandler(nullptr); - - if (floating_window) - floating_window->deleteLater(); - - // save states - settings.geometry = utils::toBytes(saveGeometry()); - settings.window_state = utils::toBytes(saveState()); - if (can && !can->liveStreaming()) { - settings.video_splitter_state = utils::toBytes(video_splitter->saveState()); - } - if (messages_widget) { - settings.message_header_state = messages_widget->saveHeaderState(); - } - - saveSessionState(); - settings.save(); - QWidget::closeEvent(event); -} - -void MainWindow::setOption() { - SettingsDialog dlg(this); - dlg.exec(); -} - -void MainWindow::findSimilarBits() { - FindSimilarBitsDlg *dlg = new FindSimilarBitsDlg(this); - QObject::connect(dlg, &FindSimilarBitsDlg::openMessage, messages_widget, &MessagesWidget::selectMessage); - dlg->show(); -} - -void MainWindow::findSignal() { - FindSignalDlg *dlg = new FindSignalDlg(this); - QObject::connect(dlg, &FindSignalDlg::openMessage, messages_widget, &MessagesWidget::selectMessage); - dlg->show(); -} - -void MainWindow::onlineHelp() { - if (auto help = findChild()) { - help->close(); - } else { - help = new HelpOverlay(this); - help->setGeometry(rect()); - help->show(); - help->raise(); - } -} - -void MainWindow::toggleFullScreen() { - if (isFullScreen()) { - menuBar()->show(); - statusBar()->show(); - showNormal(); - showMaximized(); - } else { - menuBar()->hide(); - statusBar()->hide(); - showFullScreen(); - } -} - -void MainWindow::saveSessionState() { - settings.recent_dbc_file = ""; - settings.active_msg_id = ""; - settings.selected_msg_ids.clear(); - settings.active_charts.clear(); - - for (auto &f : dbc()->allDBCFiles()) - if (!f->isEmpty()) { settings.recent_dbc_file = f->filename; break; } - - if (auto *detail = center_widget->getDetailWidget()) { - auto [active_id, ids] = detail->serializeMessageIds(); - settings.active_msg_id = active_id.toStdString(); - settings.selected_msg_ids.clear(); - for (const auto &id : ids) settings.selected_msg_ids.push_back(id.toStdString()); - } - if (charts_widget) { - settings.active_charts.clear(); - for (const auto &id : charts_widget->serializeChartIds()) settings.active_charts.push_back(id.toStdString()); - } -} - -void MainWindow::restoreSessionState() { - if (settings.recent_dbc_file.empty() || dbc()->nonEmptyDBCCount() == 0) return; - - QString dbc_file; - for (auto& f : dbc()->allDBCFiles()) - if (!f->isEmpty()) { dbc_file = QString::fromStdString(f->filename); break; } - if (dbc_file.toStdString() != settings.recent_dbc_file) return; - - if (!settings.selected_msg_ids.empty()) { - QStringList ids; - for (const auto &id : settings.selected_msg_ids) ids.push_back(QString::fromStdString(id)); - center_widget->ensureDetailWidget()->restoreTabs(QString::fromStdString(settings.active_msg_id), ids); - } - - if (charts_widget != nullptr && !settings.active_charts.empty()) { - QStringList ids; - for (const auto &id : settings.active_charts) ids.push_back(QString::fromStdString(id)); - charts_widget->restoreChartsFromIds(ids); - } -} - -// HelpOverlay -HelpOverlay::HelpOverlay(MainWindow *parent) : QWidget(parent) { - setAttribute(Qt::WA_NoSystemBackground, true); - setAttribute(Qt::WA_TranslucentBackground, true); - setAttribute(Qt::WA_DeleteOnClose); - parent->installEventFilter(this); -} - -void HelpOverlay::paintEvent(QPaintEvent *event) { - QPainter painter(this); - painter.fillRect(rect(), QColor(0, 0, 0, 50)); - auto parent = parentWidget(); - drawHelpForWidget(painter, parent->findChild()); - drawHelpForWidget(painter, parent->findChild()); - drawHelpForWidget(painter, parent->findChild()); - drawHelpForWidget(painter, parent->findChild()); - drawHelpForWidget(painter, parent->findChild()); -} - -void HelpOverlay::drawHelpForWidget(QPainter &painter, QWidget *w) { - if (w && w->isVisible() && !w->whatsThis().isEmpty()) { - QPoint pt = mapFromGlobal(w->mapToGlobal(w->rect().center())); - if (rect().contains(pt)) { - QTextDocument document; - document.setHtml(w->whatsThis()); - QSize doc_size = document.size().toSize(); - QPoint topleft = {pt.x() - doc_size.width() / 2, pt.y() - doc_size.height() / 2}; - painter.translate(topleft); - painter.fillRect(QRect{{0, 0}, doc_size}, palette().toolTipBase()); - document.drawContents(&painter); - painter.translate(-topleft); - } - } -} - -bool HelpOverlay::eventFilter(QObject *obj, QEvent *event) { - if (obj == parentWidget() && event->type() == QEvent::Resize) { - QResizeEvent *resize_event = (QResizeEvent *)(event); - setGeometry(QRect{QPoint(0, 0), resize_event->size()}); - } - return false; -} - -void HelpOverlay::mouseReleaseEvent(QMouseEvent *event) { - close(); -} diff --git a/openpilot/tools/cabana/mainwin.h b/openpilot/tools/cabana/mainwin.h deleted file mode 100644 index a57232ceb2..0000000000 --- a/openpilot/tools/cabana/mainwin.h +++ /dev/null @@ -1,122 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/chart/chartswidget.h" -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/detailwidget.h" -#include "tools/cabana/messageswidget.h" -#include "tools/cabana/videowidget.h" -#include "tools/cabana/tools/findsimilarbits.h" - -class QProgressDialog; - -class MainWindow : public QMainWindow { - Q_OBJECT - -public: - MainWindow(AbstractStream *stream, const QString &dbc_file); - ~MainWindow(); - void toggleChartsDocking(); - void showStatusMessage(const QString &msg, int timeout = 0) { statusBar()->showMessage(msg, timeout); } - void loadFile(const QString &fn, SourceSet s = SOURCE_ALL); - ChartsWidget *charts_widget = nullptr; - -public slots: - void selectAndOpenStream(); - void openStream(AbstractStream *stream, const QString &dbc_file = {}); - void closeStream(); - void exportToCSV(); - - void newFile(SourceSet s = SOURCE_ALL); - void openFile(SourceSet s = SOURCE_ALL); - void loadDBCFromOpendbc(const QString &name); - void save(); - void saveAs(); - void saveToClipboard(); - -protected: - void startStream(AbstractStream *stream, QString dbc_file); - bool eventFilter(QObject *obj, QEvent *event) override; - void remindSaveChanges(); - void closeFile(SourceSet s = SOURCE_ALL); - void closeFile(DBCFile *dbc_file); - void saveFile(DBCFile *dbc_file); - void saveFileAs(DBCFile *dbc_file); - void saveFileToClipboard(DBCFile *dbc_file); - void loadFingerprints(); - void loadFromClipboard(SourceSet s = SOURCE_ALL, bool close_all = true); - void updateRecentFiles(const QString &fn); - void updateRecentFileMenu(); - void createActions(); - void createDockWindows(); - void createStatusBar(); - void createShortcuts(); - void closeEvent(QCloseEvent *event) override; - void DBCFileChanged(); - void updateDownloadProgress(uint64_t cur, uint64_t total, bool success); - void setOption(); - void findSimilarBits(); - void findSignal(); - void undoStackCleanChanged(bool clean); - void updateUndoRedoActions(); - void onlineHelp(); - void toggleFullScreen(); - void updateStatus(); - void updateLoadSaveMenus(); - void createDockWidgets(); - void eventsMerged(); - void saveSessionState(); - void restoreSessionState(); - - VideoWidget *video_widget = nullptr; - QDockWidget *video_dock; - QDockWidget *messages_dock; - MessagesWidget *messages_widget = nullptr; - CenterWidget *center_widget; - QWidget *floating_window = nullptr; - QVBoxLayout *charts_layout; - QProgressBar *progress_bar; - QLabel *status_label; - std::unordered_map fingerprint_to_dbc; - QSplitter *video_splitter = nullptr; - enum { MAX_RECENT_FILES = 15 }; - QMenu *open_recent_menu = nullptr; - QMenu *manage_dbcs_menu = nullptr; - QMenu *tools_menu = nullptr; - QAction *close_stream_act = nullptr; - QAction *export_to_csv_act = nullptr; - QAction *save_dbc = nullptr; - QAction *save_dbc_as = nullptr; - QAction *copy_dbc_to_clipboard = nullptr; - QAction *undo_act = nullptr; - QAction *redo_act = nullptr; - QString car_fingerprint; - std::vector default_state; - Connections connections_; - Connections stream_connections_; - Connection wait_dlg_connection_; - QProgressDialog *wait_dlg_ = nullptr; -}; - -class HelpOverlay : public QWidget { - Q_OBJECT -public: - HelpOverlay(MainWindow *parent); - -protected: - void drawHelpForWidget(QPainter &painter, QWidget *w); - void paintEvent(QPaintEvent *event) override; - void mouseReleaseEvent(QMouseEvent *event) override; - bool eventFilter(QObject *obj, QEvent *event) override; -}; diff --git a/openpilot/tools/cabana/messageswidget.cc b/openpilot/tools/cabana/messageswidget.cc deleted file mode 100644 index e2d0eba079..0000000000 --- a/openpilot/tools/cabana/messageswidget.cc +++ /dev/null @@ -1,467 +0,0 @@ -#include "tools/cabana/messageswidget.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/commands.h" - -MessagesWidget::MessagesWidget(QWidget *parent) : menu(new QMenu(this)), QWidget(parent) { - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - // toolbar - main_layout->addWidget(createToolBar()); - // message table - main_layout->addWidget(view = new MessageView(this)); - view->setItemDelegate(delegate = new MessageBytesDelegate(view, settings.multiple_lines_hex)); - view->setModel(model = new MessageListModel(this)); - view->setHeader(header = new MessageViewHeader(this)); - view->setSortingEnabled(true); - view->sortByColumn(MessageListModel::Column::NAME, Qt::AscendingOrder); - view->setAllColumnsShowFocus(true); - view->setEditTriggers(QAbstractItemView::NoEditTriggers); - view->setItemsExpandable(false); - view->setIndentation(0); - view->setRootIsDecorated(false); - - // Must be called before setting any header parameters to avoid overriding - restoreHeaderState(settings.message_header_state); - header->setSectionsMovable(true); - header->setSectionResizeMode(MessageListModel::Column::DATA, QHeaderView::Fixed); - header->setStretchLastSection(true); - header->setContextMenuPolicy(Qt::CustomContextMenu); - - // signals/slots - QObject::connect(menu, &QMenu::aboutToShow, this, &MessagesWidget::menuAboutToShow); - QObject::connect(header, &MessageViewHeader::customContextMenuRequested, this, &MessagesWidget::headerContextMenuEvent); - QObject::connect(view->horizontalScrollBar(), &QScrollBar::valueChanged, header, &MessageViewHeader::updateHeaderPositions); - QObject::connect(model, &MessageListModel::modelReset, [this]() { - if (current_msg_id) { - selectMessage(*current_msg_id); - } - view->updateBytesSectionSize(); - updateTitle(); - }); - QObject::connect(view->selectionModel(), &QItemSelectionModel::currentChanged, [=](const QModelIndex ¤t, const QModelIndex &previous) { - if (current.isValid() && current.row() < model->items_.size()) { - const auto &id = model->items_[current.row()].id; - if (!current_msg_id || id != *current_msg_id) { - current_msg_id = id; - emit msgSelectionChanged(*current_msg_id); - } - } - }); - - setWhatsThis(tr(R"( - Message View
- - Byte color
- constant changing
- increasing
- decreasing
- Shortcuts
- Horizontal Scrolling:  shift+wheel  - )")); -} - -QWidget *MessagesWidget::createToolBar() { - QWidget *toolbar = new QWidget(this); - QHBoxLayout *layout = new QHBoxLayout(toolbar); - layout->setContentsMargins(0, 9, 0, 0); - layout->addWidget(suppress_add = new QPushButton("Suppress Highlighted")); - layout->addWidget(suppress_clear = new QPushButton()); - suppress_clear->setToolTip(tr("Clear suppressed")); - layout->addStretch(1); - QCheckBox *suppress_defined_signals = new QCheckBox(tr("Suppress Signals"), this); - suppress_defined_signals->setToolTip(tr("Suppress defined signals")); - suppress_defined_signals->setChecked(settings.suppress_defined_signals); - layout->addWidget(suppress_defined_signals); - - auto view_button = new ToolButton("three-dots", tr("View...")); - view_button->setMenu(menu); - view_button->setPopupMode(QToolButton::InstantPopup); - view_button->setStyleSheet("QToolButton::menu-indicator { image: none; }"); - layout->addWidget(view_button); - - QObject::connect(suppress_add, &QPushButton::clicked, this, &MessagesWidget::suppressHighlighted); - QObject::connect(suppress_clear, &QPushButton::clicked, this, &MessagesWidget::suppressHighlighted); - QObject::connect(suppress_defined_signals, &QCheckBox::stateChanged, this, [](int state) { can->suppressDefinedSignals(state); }); - - suppressHighlighted(); - return toolbar; -} - -void MessagesWidget::updateTitle() { - auto stats = std::accumulate( - model->items_.begin(), model->items_.end(), std::pair(), - [](const auto &pair, const auto &item) { - auto m = dbc()->msg(item.id); - return m ? std::make_pair(pair.first + 1, pair.second + m->sigs.size()) : pair; - }); - emit titleChanged(tr("%1 Messages (%2 DBC Messages, %3 Signals)") - .arg(model->items_.size()).arg(stats.first).arg(stats.second)); -} - -void MessagesWidget::selectMessage(const MessageId &msg_id) { - auto it = std::find_if(model->items_.cbegin(), model->items_.cend(), - [&msg_id](auto &item) { return item.id == msg_id; }); - if (it != model->items_.cend()) { - view->setCurrentIndex(model->index(std::distance(model->items_.cbegin(), it), 0)); - } -} - -void MessagesWidget::suppressHighlighted() { - int n = sender() == suppress_add ? can->suppressHighlighted() : (can->clearSuppressed(), 0); - suppress_clear->setText(n > 0 ? tr("Clear (%1)").arg(n) : tr("Clear")); - suppress_clear->setEnabled(n > 0); -} - -void MessagesWidget::headerContextMenuEvent(const QPoint &pos) { - menu->exec(header->mapToGlobal(pos)); -} - -void MessagesWidget::menuAboutToShow() { - menu->clear(); - for (int i = 0; i < header->count(); ++i) { - int logical_index = header->logicalIndex(i); - auto action = menu->addAction(model->headerData(logical_index, Qt::Horizontal).toString(), - [=](bool checked) { header->setSectionHidden(logical_index, !checked); }); - action->setCheckable(true); - action->setChecked(!header->isSectionHidden(logical_index)); - // Can't hide the name column - action->setEnabled(logical_index > 0); - } - menu->addSeparator(); - auto action = menu->addAction(tr("Multi-Line bytes"), this, &MessagesWidget::setMultiLineBytes); - action->setCheckable(true); - action->setChecked(settings.multiple_lines_hex); - - action = menu->addAction(tr("Show inactive messages"), model, &MessageListModel::showInactiveMessages); - action->setCheckable(true); - action->setChecked(model->show_inactive_messages); -} - -void MessagesWidget::setMultiLineBytes(bool multi) { - settings.multiple_lines_hex = multi; - delegate->setMultipleLines(multi); - view->updateBytesSectionSize(); - view->doItemsLayout(); -} - -// MessageListModel - -MessageListModel::MessageListModel(QObject *parent) : QAbstractTableModel(parent) { - connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool has_new_ids) { msgsReceived(msgs, has_new_ids); })); - connections_.push_back(dbc()->fileChanged.connect([this]() { dbcModified(); })); - connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { dbcModified(); })); -} - -QVariant MessageListModel::headerData(int section, Qt::Orientation orientation, int role) const { - if (orientation == Qt::Horizontal && role == Qt::DisplayRole) { - switch (section) { - case Column::NAME: return tr("Name"); - case Column::SOURCE: return tr("Bus"); - case Column::ADDRESS: return tr("ID"); - case Column::NODE: return tr("Node"); - case Column::FREQ: return tr("Freq"); - case Column::COUNT: return tr("Count"); - case Column::DATA: return tr("Bytes"); - } - } - return {}; -} - -QVariant MessageListModel::data(const QModelIndex &index, int role) const { - if (!index.isValid() || index.row() >= items_.size()) return {}; - - auto getFreq = [](float freq) { - if (freq > 0) { - return freq >= 0.95 ? QString::number(std::nearbyint(freq)) : QString::number(freq, 'f', 2); - } else { - return QStringLiteral("--"); - } - }; - - const static QString NA = QStringLiteral("N/A"); - const auto &item = items_[index.row()]; - if (role == Qt::DisplayRole) { - switch (index.column()) { - case Column::NAME: return item.name; - case Column::SOURCE: return item.id.source != INVALID_SOURCE ? QString::number(item.id.source) : NA; - case Column::ADDRESS: return QString::fromStdString(utils::toHexString(item.id.address)); - case Column::NODE: return item.node; - case Column::FREQ: return item.id.source != INVALID_SOURCE ? getFreq(can->lastMessage(item.id).freq) : NA; - case Column::COUNT: return item.id.source != INVALID_SOURCE ? QString::number(can->lastMessage(item.id).count) : NA; - case Column::DATA: return item.id.source != INVALID_SOURCE ? "" : NA; - } - } else if (role == ColorsRole) { - return QVariant::fromValue((void*)(&can->lastMessage(item.id).colors)); - } else if (role == BytesRole && index.column() == Column::DATA && item.id.source != INVALID_SOURCE) { - return QVariant::fromValue((void*)(&can->lastMessage(item.id).dat)); - } else if (role == Qt::ToolTipRole && index.column() == Column::NAME) { - auto msg = dbc()->msg(item.id); - auto tooltip = item.name; - if (msg && !msg->comment.empty()) tooltip += "
" + QString::fromStdString(msg->comment) + ""; - return tooltip; - } - return {}; -} - -void MessageListModel::setFilterStrings(const std::map &filters) { - filters_ = filters; - filterAndSort(); -} - -void MessageListModel::showInactiveMessages(bool show) { - show_inactive_messages = show; - filterAndSort(); -} - -void MessageListModel::dbcModified() { - dbc_messages_.clear(); - for (const auto &[_, m] : dbc()->getMessages(-1)) { - dbc_messages_.insert(MessageId{.source = INVALID_SOURCE, .address = m.address}); - } - filterAndSort(); -} - -void MessageListModel::sortItems(std::vector &items) { - auto compare = [this](const auto &l, const auto &r) { - switch (sort_column) { - case Column::NAME: return std::tie(l.name, l.id) < std::tie(r.name, r.id); - case Column::SOURCE: return std::tie(l.id.source, l.id.address) < std::tie(r.id.source, r.id.address); - case Column::ADDRESS: return std::tie(l.id.address, l.id.source) < std::tie(r.id.address, r.id.source); - case Column::NODE: return std::tie(l.node, l.id) < std::tie(r.node, r.id); - case Column::FREQ: return std::tie(can->lastMessage(l.id).freq, l.id) < std::tie(can->lastMessage(r.id).freq, r.id); - case Column::COUNT: return std::tie(can->lastMessage(l.id).count, l.id) < std::tie(can->lastMessage(r.id).count, r.id); - default: return false; // Default case to suppress compiler warning - } - }; - - if (sort_order == Qt::DescendingOrder) - std::stable_sort(items.rbegin(), items.rend(), compare); - else - std::stable_sort(items.begin(), items.end(), compare); -} - -static bool parseRange(const QString &filter, uint32_t value, int base = 10) { - // Parse out filter string into a range (e.g. "1" -> {1, 1}, "1-3" -> {1, 3}, "1-" -> {1, inf}) - unsigned int min = std::numeric_limits::min(); - unsigned int max = std::numeric_limits::max(); - auto s = filter.split('-'); - bool ok = s.size() >= 1 && s.size() <= 2; - if (ok && !s[0].isEmpty()) min = s[0].toUInt(&ok, base); - if (ok && s.size() == 1) { - max = min; - } else if (ok && s.size() == 2 && !s[1].isEmpty()) { - max = s[1].toUInt(&ok, base); - } - return ok && value >= min && value <= max; -} - -bool MessageListModel::match(const MessageListModel::Item &item) { - if (filters_.empty()) - return true; - - bool match = true; - const auto &data = can->lastMessage(item.id); - for (auto it = filters_.cbegin(); it != filters_.cend() && match; ++it) { - const QString &txt = it->second; - switch (it->first) { - case Column::NAME: { - match = item.name.contains(txt, Qt::CaseInsensitive); - if (!match) { - const auto m = dbc()->msg(item.id); - match = m && std::any_of(m->sigs.cbegin(), m->sigs.cend(), - [&txt](const auto &s) { return QString::fromStdString(s->name).contains(txt, Qt::CaseInsensitive); }); - } - break; - } - case Column::SOURCE: - match = parseRange(txt, item.id.source); - break; - case Column::ADDRESS: - match = QString::fromStdString(utils::toHexString(item.id.address)).contains(txt, Qt::CaseInsensitive); - match = match || parseRange(txt, item.id.address, 16); - break; - case Column::NODE: - match = item.node.contains(txt, Qt::CaseInsensitive); - break; - case Column::FREQ: - match = parseRange(txt, data.freq); - break; - case Column::COUNT: - match = parseRange(txt, data.count); - break; - case Column::DATA: - match = QString::fromStdString(utils::toHex(data.dat)).contains(txt, Qt::CaseInsensitive); - break; - } - } - return match; -} - -bool MessageListModel::filterAndSort() { - // merge CAN and DBC messages - std::vector all_messages; - all_messages.reserve(can->lastMessages().size() + dbc_messages_.size()); - auto dbc_msgs = dbc_messages_; - for (const auto &[id, m] : can->lastMessages()) { - all_messages.push_back(id); - dbc_msgs.erase(MessageId{.source = INVALID_SOURCE, .address = id.address}); - } - all_messages.insert(all_messages.end(), dbc_msgs.begin(), dbc_msgs.end()); - - // filter and sort - std::vector items; - items.reserve(all_messages.size()); - for (const auto &id : all_messages) { - if (show_inactive_messages || can->isMessageActive(id)) { - auto msg = dbc()->msg(id); - Item item = {.id = id, - .name = msg ? QString::fromStdString(msg->name) : QString::fromStdString(UNTITLED), - .node = msg ? QString::fromStdString(msg->transmitter) : QString()}; - if (match(item)) - items.emplace_back(item); - } - } - sortItems(items); - - if (items_ != items) { - beginResetModel(); - items_ = std::move(items); - endResetModel(); - return true; - } - return false; -} - -void MessageListModel::msgsReceived(const std::set *new_msgs, bool has_new_ids) { - if (has_new_ids || ((filters_.count(Column::FREQ) || filters_.count(Column::COUNT) || filters_.count(Column::DATA)) && - ++sort_threshold_ == STREAM_UPDATE_FPS)) { - sort_threshold_ = 0; - if (filterAndSort()) return; - } - - // Update viewport - emit dataChanged(index(0, 0), index(rowCount() - 1, columnCount() - 1)); -} - -void MessageListModel::sort(int column, Qt::SortOrder order) { - if (column != Column::DATA) { - sort_column = column; - sort_order = order; - filterAndSort(); - } -} - -// MessageView - -void MessageView::drawRow(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { - const auto &item = ((MessageListModel*)model())->items_[index.row()]; - if (!can->isMessageActive(item.id)) { - QStyleOptionViewItem custom_option = option; - custom_option.palette.setBrush(QPalette::Text, custom_option.palette.color(QPalette::Disabled, QPalette::Text)); - auto color = QApplication::palette().color(QPalette::HighlightedText); - color.setAlpha(100); - custom_option.palette.setBrush(QPalette::HighlightedText, color); - QTreeView::drawRow(painter, custom_option, index); - } else { - QTreeView::drawRow(painter, option, index); - } - - QPen oldPen = painter->pen(); - const int gridHint = style()->styleHint(QStyle::SH_Table_GridLineColor, &option, this); - painter->setPen(QColor::fromRgba(static_cast(gridHint))); - // Draw bottom border for the row - painter->drawLine(option.rect.bottomLeft(), option.rect.bottomRight()); - // Draw vertical borders for each column - for (int i = 0; i < header()->count(); ++i) { - int sectionX = header()->sectionViewportPosition(i); - painter->drawLine(sectionX, option.rect.top(), sectionX, option.rect.bottom()); - } - painter->setPen(oldPen); -} - -void MessageView::setModel(QAbstractItemModel *model) { - QTreeView::setModel(model); - // Bypass the slow call to QTreeView::dataChanged. - // QTreeView::dataChanged will invalidate the height cache and that's what we don't need in MessageView. - QObject::disconnect(model, &QAbstractItemModel::dataChanged, this, nullptr); - QObject::connect(model, &QAbstractItemModel::dataChanged, this, - [this](const QModelIndex &tl, const QModelIndex &br, const auto &roles) { QAbstractItemView::dataChanged(tl, br, roles); }); -} - -void MessageView::updateBytesSectionSize() { - auto delegate = ((MessageBytesDelegate *)itemDelegate()); - int max_bytes = 8; - if (!delegate->multipleLines()) { - for (const auto &[_, m] : can->lastMessages()) { - max_bytes = std::max(max_bytes, m.dat.size()); - } - } - setUniformRowHeights(!delegate->multipleLines()); - header()->resizeSection(MessageListModel::Column::DATA, delegate->sizeForBytes(max_bytes).width()); -} - -void MessageView::wheelEvent(QWheelEvent *event) { - if (event->modifiers() == Qt::ShiftModifier) { - QApplication::sendEvent(horizontalScrollBar(), event); - } else { - QTreeView::wheelEvent(event); - } -} - -// MessageViewHeader - -MessageViewHeader::MessageViewHeader(QWidget *parent) : QHeaderView(Qt::Horizontal, parent) { - QObject::connect(this, &QHeaderView::sectionResized, this, &MessageViewHeader::updateHeaderPositions); - QObject::connect(this, &QHeaderView::sectionMoved, this, &MessageViewHeader::updateHeaderPositions); -} - -void MessageViewHeader::updateFilters() { - std::map filters; - for (int i = 0; i < (int)editors.size(); i++) { - if (!editors[i]->text().isEmpty()) { - filters[i] = editors[i]->text(); - } - } - qobject_cast(model())->setFilterStrings(filters); -} - -void MessageViewHeader::updateHeaderPositions() { - QSize sz = QHeaderView::sizeHint(); - for (int i = 0; i < (int)editors.size(); i++) { - int h = editors[i]->sizeHint().height(); - editors[i]->setGeometry(sectionViewportPosition(i), sz.height(), sectionSize(i), h); - editors[i]->setHidden(isSectionHidden(i)); - } -} - -void MessageViewHeader::updateGeometries() { - for (int i = (int)editors.size(); i < count(); i++) { - QString column_name = model()->headerData(i, Qt::Horizontal, Qt::DisplayRole).toString(); - auto edit = new QLineEdit(this); - edit->setClearButtonEnabled(true); - edit->setPlaceholderText(tr("Filter %1").arg(column_name)); - - QObject::connect(edit, &QLineEdit::textChanged, this, &MessageViewHeader::updateFilters); - editors.push_back(edit); - } - setViewportMargins(0, 0, 0, !editors.empty() ? editors[0]->sizeHint().height() : 0); - - QHeaderView::updateGeometries(); - updateHeaderPositions(); -} - -QSize MessageViewHeader::sizeHint() const { - QSize sz = QHeaderView::sizeHint(); - return !editors.empty() ? QSize(sz.width(), sz.height() + editors[0]->height() + 1) : sz; -} diff --git a/openpilot/tools/cabana/messageswidget.h b/openpilot/tools/cabana/messageswidget.h deleted file mode 100644 index 7c9fc0253f..0000000000 --- a/openpilot/tools/cabana/messageswidget.h +++ /dev/null @@ -1,131 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/streams/abstractstream.h" -#include "tools/cabana/utils/qtutil.h" - -class MessageListModel : public QAbstractTableModel { -Q_OBJECT - -public: - enum Column { - NAME = 0, - SOURCE, - ADDRESS, - NODE, - FREQ, - COUNT, - DATA, - }; - - MessageListModel(QObject *parent); - QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; - int columnCount(const QModelIndex &parent = QModelIndex()) const override { return Column::DATA + 1; } - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const; - int rowCount(const QModelIndex &parent = QModelIndex()) const override { return items_.size(); } - void sort(int column, Qt::SortOrder order = Qt::AscendingOrder) override; - void setFilterStrings(const std::map &filters); - void showInactiveMessages(bool show); - void msgsReceived(const std::set *new_msgs, bool has_new_ids); - bool filterAndSort(); - void dbcModified(); - - struct Item { - MessageId id; - QString name; - QString node; - bool operator==(const Item &other) const { - return id == other.id && name == other.name && node == other.node; - } - }; - std::vector items_; - bool show_inactive_messages = true; - -private: - void sortItems(std::vector &items); - bool match(const MessageListModel::Item &id); - - std::map filters_; - std::set dbc_messages_; - int sort_column = 0; - Qt::SortOrder sort_order = Qt::AscendingOrder; - int sort_threshold_ = 0; - Connections connections_; -}; - -class MessageView : public QTreeView { - Q_OBJECT -public: - MessageView(QWidget *parent) : QTreeView(parent) {} - void updateBytesSectionSize(); - void setModel(QAbstractItemModel *model) override; - -protected: - void drawRow(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - void drawBranches(QPainter *painter, const QRect &rect, const QModelIndex &index) const override {} - void wheelEvent(QWheelEvent *event) override; -}; - -class MessageViewHeader : public QHeaderView { - // https://stackoverflow.com/a/44346317 - Q_OBJECT -public: - MessageViewHeader(QWidget *parent); - void updateHeaderPositions(); - void updateGeometries() override; - QSize sizeHint() const override; - void updateFilters(); - - std::vector editors; -}; - -class MessagesWidget : public QWidget { - Q_OBJECT - -public: - MessagesWidget(QWidget *parent); - void selectMessage(const MessageId &message_id); - std::vector saveHeaderState() const { - const auto state = view->header()->saveState(); - return {state.begin(), state.end()}; - } - bool restoreHeaderState(const std::vector &state) const { - return view->header()->restoreState({(const char *)state.data(), (int)state.size()}); - } - void suppressHighlighted(); - -signals: - void msgSelectionChanged(const MessageId &message_id); - void titleChanged(const QString &title); - -protected: - QWidget *createToolBar(); - void headerContextMenuEvent(const QPoint &pos); - void menuAboutToShow(); - void setMultiLineBytes(bool multi); - void updateTitle(); - - MessageView *view; - MessageViewHeader *header; - MessageBytesDelegate *delegate; - std::optional current_msg_id; - MessageListModel *model; - QPushButton *suppress_add; - QPushButton *suppress_clear; - QMenu *menu; -}; diff --git a/openpilot/tools/cabana/routesdialog.cc b/openpilot/tools/cabana/routesdialog.cc deleted file mode 100644 index 33b773edb9..0000000000 --- a/openpilot/tools/cabana/routesdialog.cc +++ /dev/null @@ -1,110 +0,0 @@ -#include "tools/cabana/routesdialog.h" - -#include -#include - -#include -#include -#include -#include -#include - -#include "tools/cabana/utils/util.h" - -// The RouteListWidget class extends QListWidget to display a custom message when empty -class RouteListWidget : public QListWidget { -public: - RouteListWidget(QWidget *parent = nullptr) : QListWidget(parent) {} - void setEmptyText(const QString &text) { - empty_text_ = text; - viewport()->update(); - } - void paintEvent(QPaintEvent *event) override { - QListWidget::paintEvent(event); - if (count() == 0) { - QPainter painter(viewport()); - painter.drawText(viewport()->rect(), Qt::AlignCenter, empty_text_); - } - } - QString empty_text_ = tr("No items"); -}; - -RoutesDialog::RoutesDialog(QWidget *parent) : QDialog(parent) { - setWindowTitle(tr("Remote routes")); - - QFormLayout *layout = new QFormLayout(this); - layout->addRow(tr("Device"), device_list_ = new QComboBox(this)); - layout->addRow(period_selector_ = new QComboBox(this)); - layout->addRow(route_list_ = new RouteListWidget(this)); - auto button_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - layout->addRow(button_box); - - device_list_->addItem(tr("Loading...")); - period_selector_->addItem(tr("Last week"), 7); - period_selector_->addItem(tr("Last 2 weeks"), 14); - period_selector_->addItem(tr("Last month"), 30); - period_selector_->addItem(tr("Last 6 months"), 180); - period_selector_->addItem(tr("Preserved"), -1); - - connect(device_list_, QOverload::of(&QComboBox::currentIndexChanged), this, &RoutesDialog::fetchRoutes); - connect(period_selector_, QOverload::of(&QComboBox::currentIndexChanged), this, &RoutesDialog::fetchRoutes); - connect(route_list_, &QListWidget::itemDoubleClicked, this, &QDialog::accept); - connect(button_box, &QDialogButtonBox::accepted, this, &QDialog::accept); - connect(button_box, &QDialogButtonBox::rejected, this, &QDialog::reject); - - routes::fetchDevices([this, alive = std::weak_ptr(alive_)](std::vector devices, bool success, int error_code) { - utils::runOnMainThread([this, alive, devices = std::move(devices), success, error_code]() { - if (!alive.expired()) setDeviceList(devices, success, error_code); - }); - }); -} - -void RoutesDialog::setDeviceList(const std::vector &devices, bool success, int error_code) { - if (success) { - device_list_->clear(); - for (const auto &device : devices) { - QString dongle_id = QString::fromStdString(device.dongle_id); - device_list_->addItem(dongle_id, dongle_id); - } - } else { - QMessageBox::warning(this, tr("Error"), error_code == 401 ? tr("Unauthorized. Authenticate with openpilot/tools/lib/auth.py") : tr("Network error")); - reject(); - } -} - -void RoutesDialog::fetchRoutes() { - if (device_list_->currentIndex() == -1 || device_list_->currentData().isNull()) - return; - - route_list_->clear(); - route_list_->setEmptyText(tr("Loading...")); - - int request_id = ++fetch_id_; - auto on_routes = [this, alive = std::weak_ptr(alive_), request_id](std::vector list, bool success, int) { - utils::runOnMainThread([this, alive, list = std::move(list), success, request_id]() { - if (!alive.expired() && fetch_id_ == request_id) setRouteList(list, success); - }); - }; - routes::fetchRoutes(device_list_->currentText().toStdString(), period_selector_->currentData().toInt(), std::move(on_routes)); -} - -void RoutesDialog::setRouteList(const std::vector &list, bool success) { - if (success) { - for (const auto &route : list) { - const int mins = static_cast((route.end_ms - route.start_ms) / 60000); - auto item = new QListWidgetItem(QString::fromStdString(routes::formatUnixMs(route.start_ms) + " " + std::to_string(mins) + "min")); - item->setData(Qt::UserRole, QString::fromStdString(route.name)); - route_list_->addItem(item); - } - if (route_list_->count() > 0) route_list_->setCurrentRow(0); - } else { - QMessageBox::warning(this, tr("Error"), tr("Failed to fetch routes. Check your network connection.")); - reject(); - } - route_list_->setEmptyText(tr("No items")); -} - -std::string RoutesDialog::route() { - auto current_item = route_list_->currentItem(); - return current_item ? current_item->data(Qt::UserRole).toString().toStdString() : ""; -} diff --git a/openpilot/tools/cabana/routesdialog.h b/openpilot/tools/cabana/routesdialog.h deleted file mode 100644 index 4983f1b558..0000000000 --- a/openpilot/tools/cabana/routesdialog.h +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -#include -#include -#include - -#include -#include - -#include "tools/cabana/routes.h" - -class RouteListWidget; - -class RoutesDialog : public QDialog { - Q_OBJECT -public: - RoutesDialog(QWidget *parent); - std::string route(); - -protected: - void setDeviceList(const std::vector &devices, bool success, int error_code); - void setRouteList(const std::vector &list, bool success); - void fetchRoutes(); - - QComboBox *device_list_; - QComboBox *period_selector_; - RouteListWidget *route_list_; - std::atomic fetch_id_{0}; - // expires on destruction; guards main-thread callbacks from detached worker threads - std::shared_ptr alive_ = std::make_shared(true); -}; diff --git a/openpilot/tools/cabana/settingsdialog.cc b/openpilot/tools/cabana/settingsdialog.cc deleted file mode 100644 index 0fbc82c508..0000000000 --- a/openpilot/tools/cabana/settingsdialog.cc +++ /dev/null @@ -1,88 +0,0 @@ -#include "tools/cabana/settingsdialog.h" - -#include - -#include -#include -#include -#include -#include - -#include "tools/cabana/settings.h" -#include "tools/cabana/utils/qtutil.h" - -const int MIN_CACHE_MINIUTES = 30; -const int MAX_CACHE_MINIUTES = 120; - -SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent) { - setWindowTitle(tr("Settings")); - QVBoxLayout *main_layout = new QVBoxLayout(this); - QGroupBox *groupbox = new QGroupBox("General"); - QFormLayout *form_layout = new QFormLayout(groupbox); - - form_layout->addRow(tr("Color Theme"), theme = new QComboBox(this)); - theme->setToolTip(tr("You may need to restart cabana after changes theme")); - theme->addItems({tr("Light"), tr("Dark")}); - theme->setCurrentIndex(settings.theme - LIGHT_THEME); - - form_layout->addRow(tr("Max Cached Minutes"), cached_minutes = new QSpinBox(this)); - cached_minutes->setRange(MIN_CACHE_MINIUTES, MAX_CACHE_MINIUTES); - cached_minutes->setSingleStep(1); - cached_minutes->setValue(settings.max_cached_minutes); - main_layout->addWidget(groupbox); - - groupbox = new QGroupBox("New Signal Settings"); - form_layout = new QFormLayout(groupbox); - form_layout->addRow(tr("Drag Direction"), drag_direction = new QComboBox(this)); - drag_direction->addItems({tr("MSB First"), tr("LSB First"), tr("Always Little Endian"), tr("Always Big Endian")}); - drag_direction->setCurrentIndex(settings.drag_direction); - main_layout->addWidget(groupbox); - - groupbox = new QGroupBox("Chart"); - form_layout = new QFormLayout(groupbox); - form_layout->addRow(tr("Chart Height"), chart_height = new QSpinBox(this)); - chart_height->setRange(100, 500); - chart_height->setSingleStep(10); - chart_height->setValue(settings.chart_height); - main_layout->addWidget(groupbox); - - log_livestream = new QGroupBox(tr("Enable live stream logging"), this); - log_livestream->setCheckable(true); - log_livestream->setChecked(settings.log_livestream); - QHBoxLayout *path_layout = new QHBoxLayout(log_livestream); - path_layout->addWidget(log_path = new QLineEdit(QString::fromStdString(settings.log_path), this)); - log_path->setReadOnly(true); - auto browse_btn = new QPushButton(tr("B&rowse...")); - path_layout->addWidget(browse_btn); - main_layout->addWidget(log_livestream); - - auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - main_layout->addWidget(buttonBox); - setFixedSize(400, sizeHint().height()); - - QObject::connect(browse_btn, &QPushButton::clicked, [this]() { - QString fn = QFileDialog::getExistingDirectory( - this, tr("Log File Location"), - QString::fromStdString(utils::homePath()), - QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); - if (!fn.isEmpty()) { - log_path->setText(fn); - } - }); - QObject::connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); - QObject::connect(buttonBox, &QDialogButtonBox::accepted, this, &SettingsDialog::save); -} - -void SettingsDialog::save() { - if (std::exchange(settings.theme, theme->currentIndex() + LIGHT_THEME) != settings.theme) { - // set theme before emit changed - utils::setTheme(settings.theme); - } - settings.max_cached_minutes = cached_minutes->value(); - settings.chart_height = chart_height->value(); - settings.log_livestream = log_livestream->isChecked(); - settings.log_path = log_path->text().toStdString(); - settings.drag_direction = (Settings::DragDirection)drag_direction->currentIndex(); - settings.changed(); - QDialog::accept(); -} diff --git a/openpilot/tools/cabana/settingsdialog.h b/openpilot/tools/cabana/settingsdialog.h deleted file mode 100644 index 895b789f30..0000000000 --- a/openpilot/tools/cabana/settingsdialog.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -class SettingsDialog : public QDialog { -public: - SettingsDialog(QWidget *parent); - void save(); - QSpinBox *cached_minutes; - QSpinBox *chart_height; - QComboBox *chart_series_type; - QComboBox *theme; - QGroupBox *log_livestream; - QLineEdit *log_path; - QComboBox *drag_direction; -}; diff --git a/openpilot/tools/cabana/signalview.cc b/openpilot/tools/cabana/signalview.cc deleted file mode 100644 index de80ce2a3f..0000000000 --- a/openpilot/tools/cabana/signalview.cc +++ /dev/null @@ -1,719 +0,0 @@ -#include "tools/cabana/signalview.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/commands.h" -#include "tools/cabana/utils/qtutil.h" - -// SignalModel - -static QString signalTypeToString(cabana::Signal::Type type) { - if (type == cabana::Signal::Type::Multiplexor) return "Multiplexor Signal"; - else if (type == cabana::Signal::Type::Multiplexed) return "Multiplexed Signal"; - else return "Normal Signal"; -} - -SignalModel::SignalModel(QObject *parent) : root(new Item), QAbstractItemModel(parent) { - connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); - connections_.push_back(dbc()->msgUpdated.connect([this](MessageId id) { handleMsgChanged(id); })); - connections_.push_back(dbc()->msgRemoved.connect([this](MessageId id) { handleMsgChanged(id); })); - connections_.push_back(dbc()->signalAdded.connect([this](MessageId id, const cabana::Signal *sig) { handleSignalAdded(id, sig); })); - connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { handleSignalUpdated(sig); })); - connections_.push_back(dbc()->signalRemoved.connect([this](const cabana::Signal *sig) { handleSignalRemoved(sig); })); -} - -void SignalModel::insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig) { - Item *parent_item = new Item{.type = Item::Sig, .parent = root_item, .sig = sig, .title = QString::fromStdString(sig->name)}; - root_item->children.insert(root_item->children.begin() + pos, parent_item); - QString titles[]{"Name", "Size", "Receiver Nodes", "Little Endian", "Signed", "Offset", "Factor", "Type", - "Multiplex Value", "Extra Info", "Unit", "Comment", "Minimum Value", "Maximum Value", "Value Table"}; - for (int i = 0; i < std::size(titles); ++i) { - auto item = new Item{.type = (Item::Type)(i + Item::Name), .parent = parent_item, .sig = sig, .title = titles[i]}; - parent_item->children.push_back(item); - if (item->type == Item::ExtraInfo) { - parent_item = item; - } - } -} - -void SignalModel::setMessage(const MessageId &id) { - msg_id = id; - filter_str = ""; - refresh(); -} - -void SignalModel::setFilter(const QString &txt) { - filter_str = txt; - refresh(); -} - -void SignalModel::refresh() { - beginResetModel(); - root.reset(new SignalModel::Item); - if (auto msg = dbc()->msg(msg_id)) { - for (auto s : msg->getSignals()) { - if (filter_str.isEmpty() || QString::fromStdString(s->name).contains(filter_str, Qt::CaseInsensitive)) { - insertItem(root.get(), root->children.size(), s); - } - } - } - endResetModel(); -} - -SignalModel::Item *SignalModel::getItem(const QModelIndex &index) const { - auto item = index.isValid() ? (SignalModel::Item *)index.internalPointer() : nullptr; - return item ? item : root.get(); -} - -int SignalModel::rowCount(const QModelIndex &parent) const { - if (parent.isValid() && parent.column() > 0) return 0; - - return getItem(parent)->children.size(); -} - -Qt::ItemFlags SignalModel::flags(const QModelIndex &index) const { - if (!index.isValid()) return Qt::NoItemFlags; - - auto item = getItem(index); - Qt::ItemFlags flags = Qt::ItemIsSelectable | Qt::ItemIsEnabled; - if (index.column() == 1 && item->children.empty()) { - flags |= (item->type == Item::Endian || item->type == Item::Signed) ? Qt::ItemIsUserCheckable : Qt::ItemIsEditable; - } - if (item->type == Item::MultiplexValue && item->sig->type != cabana::Signal::Type::Multiplexed) { - flags &= ~Qt::ItemIsEnabled; - } - return flags; -} - -int SignalModel::signalRow(const cabana::Signal *sig) const { - for (int i = 0; i < root->children.size(); ++i) { - if (root->children[i]->sig == sig) return i; - } - return -1; -} - -QModelIndex SignalModel::index(int row, int column, const QModelIndex &parent) const { - if (parent.isValid() && parent.column() != 0) return {}; - - auto parent_item = getItem(parent); - if (parent_item && row < parent_item->children.size()) { - return createIndex(row, column, parent_item->children[row]); - } - return {}; -} - -QModelIndex SignalModel::parent(const QModelIndex &index) const { - if (!index.isValid()) return {}; - Item *parent_item = getItem(index)->parent; - return !parent_item || parent_item == root.get() ? QModelIndex() : createIndex(parent_item->row(), 0, parent_item); -} - -QVariant SignalModel::data(const QModelIndex &index, int role) const { - if (index.isValid()) { - const Item *item = getItem(index); - if (role == Qt::DisplayRole || role == Qt::EditRole) { - if (index.column() == 0) { - return item->type == Item::Sig ? QString::fromStdString(item->sig->name) : item->title; - } else { - switch (item->type) { - case Item::Sig: return item->sig_val; - case Item::Name: return QString::fromStdString(item->sig->name); - case Item::Size: return item->sig->size; - case Item::Node: return QString::fromStdString(item->sig->receiver_name); - case Item::SignalType: return signalTypeToString(item->sig->type); - case Item::MultiplexValue: return item->sig->multiplex_value; - case Item::Offset: return QString::fromStdString(doubleToString(item->sig->offset)); - case Item::Factor: return QString::fromStdString(doubleToString(item->sig->factor)); - case Item::Unit: return QString::fromStdString(item->sig->unit); - case Item::Comment: return QString::fromStdString(item->sig->comment); - case Item::Min: return QString::fromStdString(doubleToString(item->sig->min)); - case Item::Max: return QString::fromStdString(doubleToString(item->sig->max)); - case Item::Desc: { - QStringList val_desc; - for (auto &[val, desc] : item->sig->val_desc) { - val_desc << QString("%1 \"%2\"").arg(val).arg(QString::fromStdString(desc)); - } - return val_desc.join(" "); - } - default: break; - } - } - } else if (role == Qt::CheckStateRole && index.column() == 1) { - if (item->type == Item::Endian) return item->sig->is_little_endian ? Qt::Checked : Qt::Unchecked; - if (item->type == Item::Signed) return item->sig->is_signed ? Qt::Checked : Qt::Unchecked; - } else if (role == Qt::ToolTipRole && item->type == Item::Sig) { - return (index.column() == 0) ? QString::fromStdString(utils::signalToolTip(item->sig)) : QString(); - } - } - return {}; -} - -bool SignalModel::setData(const QModelIndex &index, const QVariant &value, int role) { - if (role != Qt::EditRole && role != Qt::CheckStateRole) return false; - - Item *item = getItem(index); - cabana::Signal s = *item->sig; - switch (item->type) { - case Item::Name: s.name = value.toString().toStdString(); break; - case Item::Size: s.size = value.toInt(); break; - case Item::Node: s.receiver_name = value.toString().trimmed().toStdString(); break; - case Item::SignalType: s.type = (cabana::Signal::Type)value.toInt(); break; - case Item::MultiplexValue: s.multiplex_value = value.toInt(); break; - case Item::Endian: s.is_little_endian = value.toBool(); break; - case Item::Signed: s.is_signed = value.toBool(); break; - case Item::Offset: s.offset = value.toDouble(); break; - case Item::Factor: s.factor = value.toDouble(); break; - case Item::Unit: s.unit = value.toString().toStdString(); break; - case Item::Comment: s.comment = value.toString().toStdString(); break; - case Item::Min: s.min = value.toDouble(); break; - case Item::Max: s.max = value.toDouble(); break; - case Item::Desc: s.val_desc = value.value(); break; - default: return false; - } - bool ret = saveSignal(item->sig, s); - emit dataChanged(index, index, {Qt::DisplayRole, Qt::EditRole, Qt::CheckStateRole}); - return ret; -} - -bool SignalModel::saveSignal(const cabana::Signal *origin_s, cabana::Signal &s) { - auto msg = dbc()->msg(msg_id); - if (s.name != origin_s->name && msg->sig(s.name) != nullptr) { - QString text = tr("There is already a signal with the same name '%1'").arg(QString::fromStdString(s.name)); - QMessageBox::warning(nullptr, tr("Failed to save signal"), text); - return false; - } - - if (s.is_little_endian != origin_s->is_little_endian) { - s.start_bit = flipBitPos(s.start_bit); - } - UndoStack::instance()->push(new EditSignalCommand(msg_id, origin_s, s)); - return true; -} - -void SignalModel::handleMsgChanged(MessageId id) { - if (id.address == msg_id.address) { - refresh(); - } -} - -void SignalModel::handleSignalAdded(MessageId id, const cabana::Signal *sig) { - if (id == msg_id) { - if (filter_str.isEmpty()) { - int i = dbc()->msg(msg_id)->indexOf(sig); - beginInsertRows({}, i, i); - insertItem(root.get(), i, sig); - endInsertRows(); - } else if (QString::fromStdString(sig->name).contains(filter_str, Qt::CaseInsensitive)) { - refresh(); - } - } -} - -void SignalModel::handleSignalUpdated(const cabana::Signal *sig) { - if (int row = signalRow(sig); row != -1) { - emit dataChanged(index(row, 0), index(row, 1), {Qt::DisplayRole, Qt::EditRole, Qt::CheckStateRole}); - - if (filter_str.isEmpty()) { - // move row when the order changes. - int to = dbc()->msg(msg_id)->indexOf(sig); - if (to != row) { - beginMoveRows({}, row, row, {}, to > row ? to + 1 : to); - auto item = root->children[row]; - root->children.erase(root->children.begin() + row); - root->children.insert(root->children.begin() + to, item); - endMoveRows(); - } - } - } -} - -void SignalModel::handleSignalRemoved(const cabana::Signal *sig) { - if (int row = signalRow(sig); row != -1) { - beginRemoveRows({}, row, row); - delete root->children[row]; - root->children.erase(root->children.begin() + row); - endRemoveRows(); - } -} - -// SignalItemDelegate - -SignalItemDelegate::SignalItemDelegate(QObject *parent) : QStyledItemDelegate(parent) { - name_validator = new NameValidator(this); - node_validator = new NodeValidator(this); - double_validator = new DoubleValidator(this); - - label_font.setPointSize(8); - minmax_font.setPixelSize(10); -} - -QSize SignalItemDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { - int width = option.widget->size().width() / 2; - if (index.column() == 0) { - int spacing = option.widget->style()->pixelMetric(QStyle::PM_TreeViewIndentation) + color_label_width + 8; - auto text = index.data(Qt::DisplayRole).toString(); - auto item = (SignalModel::Item *)index.internalPointer(); - if (item->type == SignalModel::Item::Sig && item->sig->type != cabana::Signal::Type::Normal) { - text += item->sig->type == cabana::Signal::Type::Multiplexor ? QString(" M ") : QString(" m%1 ").arg(item->sig->multiplex_value); - spacing += (option.widget->style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1) * 2; - } - width = std::min(option.widget->size().width() / 3.0, option.fontMetrics.horizontalAdvance(text) + spacing); - } - return {width, option.fontMetrics.height() + option.widget->style()->pixelMetric(QStyle::PM_FocusFrameVMargin) * 2}; -} - -void SignalItemDelegate::updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option, const QModelIndex &index) const { - auto item = (SignalModel::Item *)index.internalPointer(); - if (editor && item->type == SignalModel::Item::Sig && index.column() == 1) { - QRect geom = option.rect; - geom.setLeft(geom.right() - editor->sizeHint().width()); - editor->setGeometry(geom); - button_size = geom.size(); - return; - } - QStyledItemDelegate::updateEditorGeometry(editor, option, index); -} - -void SignalItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { - const int h_margin = option.widget->style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1; - const int v_margin = option.widget->style()->pixelMetric(QStyle::PM_FocusFrameVMargin); - auto item = static_cast(index.internalPointer()); - - QRect rect = option.rect.adjusted(h_margin, v_margin, -h_margin, -v_margin); - painter->setRenderHint(QPainter::Antialiasing); - if (option.state & QStyle::State_Selected) { - painter->fillRect(option.rect, option.palette.brush(QPalette::Normal, QPalette::Highlight)); - } - - if (index.column() == 0) { - if (item->type == SignalModel::Item::Sig) { - // color label - QPainterPath path; - QRect icon_rect{rect.x(), rect.y(), color_label_width, rect.height()}; - path.addRoundedRect(icon_rect, 3, 3); - painter->setPen(item->highlight ? Qt::white : Qt::black); - painter->setFont(label_font); - painter->fillPath(path, toQColor(item->sig->color.darker(item->highlight ? 125 : 0))); - painter->drawText(icon_rect, Qt::AlignCenter, QString::number(item->row() + 1)); - - rect.setLeft(icon_rect.right() + h_margin * 2); - // multiplexer indicator - if (item->sig->type != cabana::Signal::Type::Normal) { - QString indicator = item->sig->type == cabana::Signal::Type::Multiplexor ? QString(" M ") : QString(" m%1 ").arg(item->sig->multiplex_value); - QRect indicator_rect{rect.x(), rect.y(), option.fontMetrics.horizontalAdvance(indicator), rect.height()}; - painter->setBrush(Qt::gray); - painter->setPen(Qt::NoPen); - painter->drawRoundedRect(indicator_rect, 3, 3); - painter->setPen(Qt::white); - painter->drawText(indicator_rect, Qt::AlignCenter, indicator); - rect.setLeft(indicator_rect.right() + h_margin * 2); - } - } else { - rect.setLeft(option.widget->style()->pixelMetric(QStyle::PM_TreeViewIndentation) + color_label_width + h_margin * 3); - } - - // name - auto text = option.fontMetrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, rect.width()); - painter->setPen(option.palette.color(option.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text)); - painter->setFont(option.font); - painter->drawText(rect, option.displayAlignment, text); - } else if (index.column() == 1) { - if (!item->sparkline.pixmap.isNull()) { - QSize sparkline_size = item->sparkline.pixmap.size() / item->sparkline.pixmap.devicePixelRatio(); - painter->drawPixmap(QRect(rect.topLeft(), sparkline_size), item->sparkline.pixmap); - // min-max value - painter->setPen(option.palette.color(option.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text)); - rect.adjust(sparkline_size.width() + 1, 0, 0, 0); - int value_adjust = 10; - if (!item->sparkline.isEmpty() && (item->highlight || option.state & QStyle::State_Selected)) { - painter->drawLine(rect.topLeft(), rect.bottomLeft()); - rect.adjust(5, -v_margin, 0, v_margin); - painter->setFont(minmax_font); - QString min = QString::number(item->sparkline.min_val); - QString max = QString::number(item->sparkline.max_val); - painter->drawText(rect, Qt::AlignLeft | Qt::AlignTop, max); - painter->drawText(rect, Qt::AlignLeft | Qt::AlignBottom, min); - QFontMetrics fm(minmax_font); - value_adjust = std::max(fm.horizontalAdvance(min), fm.horizontalAdvance(max)) + 5; - } else if (!item->sparkline.isEmpty() && item->sig->type == cabana::Signal::Type::Multiplexed) { - // display freq of multiplexed signal - painter->setFont(label_font); - QString freq = QString("%1 hz").arg(item->sparkline.freq(), 0, 'g', 2); - painter->drawText(rect.adjusted(5, 0, 0, 0), Qt::AlignLeft | Qt::AlignVCenter, freq); - value_adjust = QFontMetrics(label_font).horizontalAdvance(freq) + 10; - } - // signal value - painter->setFont(option.font); - rect.adjust(value_adjust, 0, -button_size.width(), 0); - auto text = option.fontMetrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, rect.width()); - painter->drawText(rect, Qt::AlignRight | Qt::AlignVCenter, text); - } else { - QStyledItemDelegate::paint(painter, option, index); - } - } -} - -QWidget *SignalItemDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const { - auto item = (SignalModel::Item *)index.internalPointer(); - if (item->type == SignalModel::Item::Name || item->type == SignalModel::Item::Node || item->type == SignalModel::Item::Offset || - item->type == SignalModel::Item::Factor || item->type == SignalModel::Item::MultiplexValue || - item->type == SignalModel::Item::Min || item->type == SignalModel::Item::Max) { - QLineEdit *e = new QLineEdit(parent); - e->setFrame(false); - if (item->type == SignalModel::Item::Name) e->setValidator(name_validator); - else if (item->type == SignalModel::Item::Node) e->setValidator(node_validator); - else e->setValidator(double_validator); - - return e; - } else if (item->type == SignalModel::Item::Size) { - QSpinBox *spin = new QSpinBox(parent); - spin->setFrame(false); - spin->setRange(1, CAN_MAX_DATA_BYTES); - return spin; - } else if (item->type == SignalModel::Item::SignalType) { - QComboBox *c = new QComboBox(parent); - c->addItem(signalTypeToString(cabana::Signal::Type::Normal), (int)cabana::Signal::Type::Normal); - if (!dbc()->msg(((SignalModel *)index.model())->msg_id)->multiplexor) { - c->addItem(signalTypeToString(cabana::Signal::Type::Multiplexor), (int)cabana::Signal::Type::Multiplexor); - } else if (item->sig->type != cabana::Signal::Type::Multiplexor) { - c->addItem(signalTypeToString(cabana::Signal::Type::Multiplexed), (int)cabana::Signal::Type::Multiplexed); - } - return c; - } else if (item->type == SignalModel::Item::Desc) { - ValueDescriptionDlg dlg(item->sig->val_desc, parent); - dlg.setWindowTitle(QString::fromStdString(item->sig->name)); - if (dlg.exec()) { - ((QAbstractItemModel *)index.model())->setData(index, QVariant::fromValue(dlg.val_desc)); - } - return nullptr; - } - return QStyledItemDelegate::createEditor(parent, option, index); -} - -void SignalItemDelegate::setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const { - auto item = (SignalModel::Item *)index.internalPointer(); - if (item->type == SignalModel::Item::SignalType) { - model->setData(index, ((QComboBox*)editor)->currentData().toInt()); - return; - } - QStyledItemDelegate::setModelData(editor, model, index); -} - -// SignalView - -SignalView::SignalView(ChartsWidget *charts, QWidget *parent) : charts(charts), QFrame(parent) { - setFrameStyle(QFrame::StyledPanel | QFrame::Plain); - // title bar - QWidget *title_bar = new QWidget(this); - QHBoxLayout *hl = new QHBoxLayout(title_bar); - hl->addWidget(signal_count_lb = new QLabel()); - filter_edit = new QLineEdit(this); - filter_edit->setValidator(new NonWhitespaceValidator(this)); - filter_edit->setClearButtonEnabled(true); - filter_edit->setPlaceholderText(tr("Filter Signal")); - hl->addWidget(filter_edit); - hl->addStretch(1); - - // WARNING: increasing the maximum range can result in severe performance degradation. - // 30s is a reasonable value at present. - const int max_range = 30; // 30s - settings.sparkline_range = std::clamp(settings.sparkline_range, 1, max_range); - hl->addWidget(sparkline_label = new QLabel()); - hl->addWidget(sparkline_range_slider = new QSlider(Qt::Horizontal, this)); - sparkline_range_slider->setRange(1, max_range); - sparkline_range_slider->setValue(settings.sparkline_range); - sparkline_range_slider->setToolTip(tr("Sparkline time range")); - - auto collapse_btn = new ToolButton("dash-square", tr("Collapse All")); - collapse_btn->setIconSize({12, 12}); - hl->addWidget(collapse_btn); - - // tree view - tree = new TreeView(this); - tree->setModel(model = new SignalModel(this)); - tree->setItemDelegate(delegate = new SignalItemDelegate(this)); - tree->setFrameShape(QFrame::NoFrame); - tree->setHeaderHidden(true); - tree->setMouseTracking(true); - tree->setExpandsOnDoubleClick(false); - tree->setEditTriggers(QAbstractItemView::AllEditTriggers); - tree->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents); - tree->header()->setStretchLastSection(true); - tree->setMinimumHeight(300); - - // Use a distinctive background for the whole row containing a QSpinBox or QLineEdit - QString nodeBgColor = palette().color(QPalette::AlternateBase).name(QColor::HexArgb); - tree->setStyleSheet(QString("QSpinBox{background-color:%1;border:none;} QLineEdit{background-color:%1;}").arg(nodeBgColor)); - - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - main_layout->setSpacing(0); - main_layout->addWidget(title_bar); - main_layout->addWidget(tree); - updateToolBar(); - - QObject::connect(filter_edit, &QLineEdit::textEdited, model, &SignalModel::setFilter); - QObject::connect(sparkline_range_slider, &QSlider::valueChanged, this, &SignalView::setSparklineRange); - QObject::connect(collapse_btn, &QPushButton::clicked, tree, &QTreeView::collapseAll); - QObject::connect(tree, &QAbstractItemView::clicked, this, &SignalView::rowClicked); - QObject::connect(tree, &QTreeView::viewportEntered, [this]() { emit highlight(nullptr); }); - QObject::connect(tree, &QTreeView::entered, [this](const QModelIndex &index) { emit highlight(model->getItem(index)->sig); }); - QObject::connect(model, &QAbstractItemModel::modelReset, this, &SignalView::rowsChanged); - QObject::connect(model, &QAbstractItemModel::rowsRemoved, this, &SignalView::rowsChanged); - connections_.push_back(dbc()->signalAdded.connect([this](MessageId id, const cabana::Signal *sig) { handleSignalAdded(id, sig); })); - connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { handleSignalUpdated(sig); })); - QObject::connect(tree->verticalScrollBar(), &QScrollBar::valueChanged, [this]() { updateState(); }); - QObject::connect(tree->verticalScrollBar(), &QScrollBar::rangeChanged, [this]() { updateState(); }); - connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool) { updateState(msgs); })); - QObject::connect(tree->header(), &QHeaderView::sectionResized, [this](int logicalIndex, int oldSize, int newSize) { - if (logicalIndex == 1) { - value_column_width = newSize; - updateState(); - } - }); - - setWhatsThis(tr(R"( - Signal view
- - )")); -} - -void SignalView::setMessage(const MessageId &id) { - max_value_width = 0; - filter_edit->clear(); - model->setMessage(id); -} - -void SignalView::rowsChanged() { - for (int i = 0; i < model->rowCount(); ++i) { - auto index = model->index(i, 1); - if (!tree->indexWidget(index)) { - QWidget *w = new QWidget(this); - QHBoxLayout *h = new QHBoxLayout(w); - int v_margin = style()->pixelMetric(QStyle::PM_FocusFrameVMargin); - int h_margin = style()->pixelMetric(QStyle::PM_FocusFrameHMargin); - h->setContentsMargins(0, v_margin, -h_margin, v_margin); - h->setSpacing(style()->pixelMetric(QStyle::PM_ToolBarItemSpacing)); - - auto remove_btn = new ToolButton("x", tr("Remove signal")); - auto plot_btn = new ToolButton("graph-up", ""); - plot_btn->setCheckable(true); - h->addWidget(plot_btn); - h->addWidget(remove_btn); - - tree->setIndexWidget(index, w); - auto sig = model->getItem(index)->sig; - QObject::connect(remove_btn, &QToolButton::clicked, [=]() { UndoStack::instance()->push(new RemoveSigCommand(model->msg_id, sig)); }); - QObject::connect(plot_btn, &QToolButton::clicked, [=](bool checked) { - emit showChart(model->msg_id, sig, checked, QGuiApplication::keyboardModifiers() & Qt::ShiftModifier); - }); - } - } - updateToolBar(); - updateChartState(); - updateState(); -} - -void SignalView::rowClicked(const QModelIndex &index) { - auto item = model->getItem(index); - if (item->type == SignalModel::Item::Sig || item->type == SignalModel::Item::ExtraInfo) { - auto expand_index = model->index(index.row(), 0, index.parent()); - tree->setExpanded(expand_index, !tree->isExpanded(expand_index)); - } -} - -void SignalView::selectSignal(const cabana::Signal *sig, bool expand) { - if (int row = model->signalRow(sig); row != -1) { - auto idx = model->index(row, 0); - if (expand) { - tree->setExpanded(idx, !tree->isExpanded(idx)); - } - tree->scrollTo(idx, QAbstractItemView::PositionAtTop); - tree->setCurrentIndex(idx); - } -} - -void SignalView::updateChartState() { - int i = 0; - for (auto item : model->root->children) { - bool chart_opened = charts->hasSignal(model->msg_id, item->sig); - auto buttons = tree->indexWidget(model->index(i, 1))->findChildren(); - if (buttons.size() > 0) { - buttons[0]->setChecked(chart_opened); - buttons[0]->setToolTip(chart_opened ? tr("Close Plot") : tr("Show Plot\nSHIFT click to add to previous opened plot")); - } - ++i; - } -} - -void SignalView::signalHovered(const cabana::Signal *sig) { - auto &children = model->root->children; - for (int i = 0; i < children.size(); ++i) { - bool highlight = children[i]->sig == sig; - if (std::exchange(children[i]->highlight, highlight) != highlight) { - emit model->dataChanged(model->index(i, 0), model->index(i, 0), {Qt::DecorationRole}); - emit model->dataChanged(model->index(i, 1), model->index(i, 1), {Qt::DisplayRole}); - } - } -} - -void SignalView::updateToolBar() { - signal_count_lb->setText(tr("Signals: %1").arg(model->rowCount())); - sparkline_label->setText(QString::fromStdString(utils::formatSeconds(settings.sparkline_range))); -} - -void SignalView::setSparklineRange(int value) { - settings.sparkline_range = value; - updateToolBar(); - updateState(); -} - -void SignalView::handleSignalAdded(MessageId id, const cabana::Signal *sig) { - if (id.address == model->msg_id.address) { - selectSignal(sig); - } -} - -void SignalView::handleSignalUpdated(const cabana::Signal *sig) { - if (int row = model->signalRow(sig); row != -1) - updateState(); -} - -std::pair SignalView::visibleSignalRange() { - auto topLevelIndex = [](QModelIndex index) { - while (index.isValid() && index.parent().isValid()) index = index.parent(); - return index; - }; - - const auto viewport_rect = tree->viewport()->rect(); - QModelIndex first_visible = tree->indexAt(viewport_rect.topLeft()); - if (first_visible.parent().isValid()) { - first_visible = topLevelIndex(first_visible); - first_visible = first_visible.siblingAtRow(first_visible.row() + 1); - } - - QModelIndex last_visible = topLevelIndex(tree->indexAt(viewport_rect.bottomRight())); - if (!last_visible.isValid()) { - last_visible = model->index(model->rowCount() - 1, 0); - } - return {first_visible, last_visible}; -} - -void SignalView::updateState(const std::set *msgs) { - const auto &last_msg = can->lastMessage(model->msg_id); - if (model->rowCount() == 0 || (msgs && !msgs->count(model->msg_id)) || last_msg.dat.size() == 0) return; - - for (auto item : model->root->children) { - double value = 0; - if (item->sig->getValue(last_msg.dat.data(), last_msg.dat.size(), &value)) { - item->sig_val = QString::fromStdString(item->sig->formatValue(value)); - max_value_width = std::max(max_value_width, fontMetrics().horizontalAdvance(item->sig_val)); - } - } - - auto [first_visible, last_visible] = visibleSignalRange(); - if (first_visible.isValid() && last_visible.isValid()) { - const static int min_max_width = QFontMetrics(delegate->minmax_font).horizontalAdvance("-000.00") + 5; - int available_width = value_column_width - delegate->button_size.width(); - int value_width = std::min(max_value_width + min_max_width, available_width / 2); - QSize size(available_width - value_width, - delegate->button_size.height() - style()->pixelMetric(QStyle::PM_FocusFrameVMargin) * 2); - - auto [first, last] = can->eventsInRange(model->msg_id, std::make_pair(last_msg.ts -settings.sparkline_range, last_msg.ts)); - std::vector> futures; - for (int i = first_visible.row(); i <= last_visible.row(); ++i) { - auto item = model->getItem(model->index(i, 1)); - futures.push_back(std::async(std::launch::async, - &Sparkline::update, &item->sparkline, item->sig, first, last, settings.sparkline_range, size)); - } - for (auto &f : futures) f.get(); - } - - for (int i = 0; i < model->rowCount(); ++i) { - emit model->dataChanged(model->index(i, 1), model->index(i, 1), {Qt::DisplayRole}); - } -} - -void SignalView::resizeEvent(QResizeEvent* event) { - updateState(); - QFrame::resizeEvent(event); -} - -// ValueDescriptionDlg - -ValueDescriptionDlg::ValueDescriptionDlg(const ValueDescription &descriptions, QWidget *parent) : QDialog(parent) { - QHBoxLayout *toolbar_layout = new QHBoxLayout(); - QPushButton *add = new QPushButton(utils::icon("plus"), ""); - QPushButton *remove = new QPushButton(utils::icon("dash"), ""); - remove->setEnabled(false); - toolbar_layout->addWidget(add); - toolbar_layout->addWidget(remove); - toolbar_layout->addStretch(0); - - table = new QTableWidget(descriptions.size(), 2, this); - table->setItemDelegate(new Delegate(this)); - table->setHorizontalHeaderLabels({"Value", "Description"}); - table->horizontalHeader()->setStretchLastSection(true); - table->setSelectionBehavior(QAbstractItemView::SelectRows); - table->setSelectionMode(QAbstractItemView::SingleSelection); - table->setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed); - table->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - - int row = 0; - for (auto &[val, desc] : descriptions) { - table->setItem(row, 0, new QTableWidgetItem(QString::number(val))); - table->setItem(row, 1, new QTableWidgetItem(QString::fromStdString(desc))); - ++row; - } - - auto btn_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->addLayout(toolbar_layout); - main_layout->addWidget(table); - main_layout->addWidget(btn_box); - setMinimumWidth(500); - - QObject::connect(btn_box, &QDialogButtonBox::accepted, this, &ValueDescriptionDlg::save); - QObject::connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject); - QObject::connect(add, &QPushButton::clicked, [this]() { - table->setRowCount(table->rowCount() + 1); - table->setItem(table->rowCount() - 1, 0, new QTableWidgetItem); - table->setItem(table->rowCount() - 1, 1, new QTableWidgetItem); - }); - QObject::connect(remove, &QPushButton::clicked, [this]() { table->removeRow(table->currentRow()); }); - QObject::connect(table, &QTableWidget::itemSelectionChanged, [=]() { - remove->setEnabled(table->currentRow() != -1); - }); -} - -void ValueDescriptionDlg::save() { - for (int i = 0; i < table->rowCount(); ++i) { - QString val = table->item(i, 0)->text().trimmed(); - QString desc = table->item(i, 1)->text().trimmed(); - if (!val.isEmpty() && !desc.isEmpty()) { - val_desc.push_back({val.toDouble(), desc.toStdString()}); - } - } - QDialog::accept(); -} - -QWidget *ValueDescriptionDlg::Delegate::createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const { - QLineEdit *edit = new QLineEdit(parent); - edit->setFrame(false); - if (index.column() == 0) { - edit->setValidator(new DoubleValidator(parent)); - } - return edit; -} diff --git a/openpilot/tools/cabana/signalview.h b/openpilot/tools/cabana/signalview.h deleted file mode 100644 index e2a99ebbd1..0000000000 --- a/openpilot/tools/cabana/signalview.h +++ /dev/null @@ -1,155 +0,0 @@ -#pragma once - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/chart/chartswidget.h" -#include "tools/cabana/chart/sparkline.h" - -class SignalModel : public QAbstractItemModel { - Q_OBJECT -public: - struct Item { - enum Type {Root, Sig, Name, Size, Node, Endian, Signed, Offset, Factor, SignalType, MultiplexValue, ExtraInfo, Unit, Comment, Min, Max, Desc }; - ~Item() { for (auto c : children) delete c; } - inline int row() { - auto it = std::find(parent->children.begin(), parent->children.end(), this); - return it != parent->children.end() ? std::distance(parent->children.begin(), it) : -1; - } - - Type type = Type::Root; - Item *parent = nullptr; - std::vector children; - - const cabana::Signal *sig = nullptr; - QString title; - bool highlight = false; - QString sig_val = "-"; - Sparkline sparkline; - }; - - SignalModel(QObject *parent); - int rowCount(const QModelIndex &parent = QModelIndex()) const override; - int columnCount(const QModelIndex &parent = QModelIndex()) const override { return 2; } - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override; - QModelIndex parent(const QModelIndex &index) const override; - Qt::ItemFlags flags(const QModelIndex &index) const override; - bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override; - void setMessage(const MessageId &id); - void setFilter(const QString &txt); - bool saveSignal(const cabana::Signal *origin_s, cabana::Signal &s); - Item *getItem(const QModelIndex &index) const; - int signalRow(const cabana::Signal *sig) const; - -private: - void insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig); - void handleSignalAdded(MessageId id, const cabana::Signal *sig); - void handleSignalUpdated(const cabana::Signal *sig); - void handleSignalRemoved(const cabana::Signal *sig); - void handleMsgChanged(MessageId id); - void refresh(); - - MessageId msg_id; - QString filter_str; - std::unique_ptr root; - Connections connections_; - friend class SignalView; - friend class SignalItemDelegate; -}; - -class ValueDescriptionDlg : public QDialog { -public: - ValueDescriptionDlg(const ValueDescription &descriptions, QWidget *parent); - ValueDescription val_desc; - -private: - struct Delegate : public QStyledItemDelegate { - Delegate(QWidget *parent) : QStyledItemDelegate(parent) {} - QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - }; - - void save(); - QTableWidget *table; -}; - -class SignalItemDelegate : public QStyledItemDelegate { -public: - SignalItemDelegate(QObject *parent); - void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; - QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - void updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override; - - QValidator *name_validator, *double_validator, *node_validator; - QFont label_font, minmax_font; - const int color_label_width = 18; - mutable QSize button_size; -}; - -class SignalView : public QFrame { - Q_OBJECT - -public: - SignalView(ChartsWidget *charts, QWidget *parent); - void setMessage(const MessageId &id); - void signalHovered(const cabana::Signal *sig); - void updateChartState(); - void selectSignal(const cabana::Signal *sig, bool expand = false); - void rowClicked(const QModelIndex &index); - SignalModel *model = nullptr; - -signals: - void highlight(const cabana::Signal *sig); - void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge); - -private: - void rowsChanged(); - void resizeEvent(QResizeEvent* event) override; - void updateToolBar(); - void setSparklineRange(int value); - void handleSignalAdded(MessageId id, const cabana::Signal *sig); - void handleSignalUpdated(const cabana::Signal *sig); - void updateState(const std::set *msgs = nullptr); - std::pair visibleSignalRange(); - - struct TreeView : public QTreeView { - TreeView(QWidget *parent) : QTreeView(parent) {} - void rowsInserted(const QModelIndex &parent, int start, int end) override { - ((SignalView *)parentWidget())->rowsChanged(); - // update widget geometries in QTreeView::rowsInserted - QTreeView::rowsInserted(parent, start, end); - } - void setModel(QAbstractItemModel *m) override { - QTreeView::setModel(m); - // Bypass the slow call to QTreeView::dataChanged. - QObject::disconnect(m, &QAbstractItemModel::dataChanged, this, nullptr); - QObject::connect(m, &QAbstractItemModel::dataChanged, this, - [this](const QModelIndex &tl, const QModelIndex &br, const auto &roles) { QAbstractItemView::dataChanged(tl, br, roles); }); - } - void leaveEvent(QEvent *event) override { - emit static_cast(parentWidget())->highlight(nullptr); - QTreeView::leaveEvent(event); - } - }; - int max_value_width = 0; - int value_column_width = 0; - TreeView *tree; - QLabel *sparkline_label; - QSlider *sparkline_range_slider; - QLineEdit *filter_edit; - ChartsWidget *charts; - QLabel *signal_count_lb; - SignalItemDelegate *delegate; - Connections connections_; -}; diff --git a/openpilot/tools/cabana/streamselector.cc b/openpilot/tools/cabana/streamselector.cc deleted file mode 100644 index 31532737f3..0000000000 --- a/openpilot/tools/cabana/streamselector.cc +++ /dev/null @@ -1,330 +0,0 @@ -#include "tools/cabana/streamselector.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/streams/devicestream.h" -#include "tools/cabana/streams/replaystream.h" -#include "tools/cabana/routesdialog.h" -#include "tools/cabana/utils/qtutil.h" - -// OpenReplayWidget - -OpenReplayWidget::OpenReplayWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { - QGridLayout *grid_layout = new QGridLayout(this); - grid_layout->addWidget(new QLabel(tr("Route")), 0, 0); - grid_layout->addWidget(route_edit = new QLineEdit(this), 0, 1); - route_edit->setPlaceholderText(tr("Enter route name or browse for local/remote route")); - auto browse_remote_btn = new QPushButton(tr("Remote route..."), this); - grid_layout->addWidget(browse_remote_btn, 0, 2); - auto browse_local_btn = new QPushButton(tr("Local route..."), this); - grid_layout->addWidget(browse_local_btn, 0, 3); - - QHBoxLayout *camera_layout = new QHBoxLayout(); - for (auto c : {tr("Road camera"), tr("Driver camera"), tr("Wide road camera")}) - camera_layout->addWidget(cameras.emplace_back(new QCheckBox(c, this))); - cameras[0]->setChecked(true); - camera_layout->addStretch(1); - grid_layout->addItem(camera_layout, 1, 1); - - setMinimumWidth(550); - QObject::connect(browse_local_btn, &QPushButton::clicked, [=]() { - QString dir = QFileDialog::getExistingDirectory(this, tr("Open Local Route"), QString::fromStdString(settings.last_route_dir)); - if (!dir.isEmpty()) { - route_edit->setText(dir); - settings.last_route_dir = std::filesystem::absolute(dir.toStdString()).parent_path().string(); - } - }); - QObject::connect(browse_remote_btn, &QPushButton::clicked, [this]() { - RoutesDialog route_dlg(this); - if (route_dlg.exec()) { - route_edit->setText(QString::fromStdString(route_dlg.route())); - } - }); -} - -AbstractStream *OpenReplayWidget::open() { - QString route = route_edit->text(); - QString data_dir; - if (int idx = route.lastIndexOf('/'); idx != -1 && util::file_exists(route.toStdString())) { - data_dir = route.mid(0, idx + 1); - route = route.mid(idx + 1); - } - - bool is_valid_format = Route::parseRoute(route.toStdString()).str.size() > 0; - if (!is_valid_format) { - QMessageBox::warning(nullptr, tr("Warning"), tr("Invalid route format: '%1'").arg(route)); - } else { - auto replay_stream = std::make_unique(); - Connection err = replay_stream->error.connect([](const std::string &msg) { - QMessageBox::warning(nullptr, tr("Error"), QString::fromStdString(msg)); - }); - uint32_t flags = REPLAY_FLAG_NONE; - if (cameras[1]->isChecked()) flags |= REPLAY_FLAG_CABIN_CAMERA; - if (cameras[2]->isChecked()) flags |= REPLAY_FLAG_WIDE_ROAD; - if (flags == REPLAY_FLAG_NONE && !cameras[0]->isChecked()) flags = REPLAY_FLAG_NO_VIPC; - - if (replay_stream->loadRoute(route.toStdString(), data_dir.toStdString(), flags)) { - return replay_stream.release(); - } - } - return nullptr; -} - -// OpenPandaWidget - -static const uint32_t speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U}; -static const uint32_t data_speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U, 2000U, 5000U}; - -OpenPandaWidget::OpenPandaWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { - form_layout = new QFormLayout(this); - if (can && dynamic_cast(can) != nullptr) { - form_layout->addWidget(new QLabel(tr("Already connected to %1.").arg(QString::fromStdString(can->routeName())))); - form_layout->addWidget(new QLabel("Close the current connection via [File menu -> Close Stream] before connecting to another Panda.")); - QTimer::singleShot(0, [this]() { emit enableOpenButton(false); }); - return; - } - - QHBoxLayout *serial_layout = new QHBoxLayout(); - serial_layout->addWidget(serial_edit = new QComboBox()); - - QPushButton *refresh = new QPushButton(tr("Refresh")); - refresh->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); - serial_layout->addWidget(refresh); - form_layout->addRow(tr("Serial"), serial_layout); - - QObject::connect(refresh, &QPushButton::clicked, this, &OpenPandaWidget::refreshSerials); - QObject::connect(serial_edit, &QComboBox::currentTextChanged, this, &OpenPandaWidget::buildConfigForm); - - // Populate serials - refreshSerials(); - buildConfigForm(); -} - -void OpenPandaWidget::refreshSerials() { - serial_edit->clear(); - for (auto serial : Panda::list()) { - serial_edit->addItem(QString::fromStdString(serial)); - } -} - -void OpenPandaWidget::buildConfigForm() { - for (int i = form_layout->rowCount() - 1; i > 0; --i) { - form_layout->removeRow(i); - } - - QString serial = serial_edit->currentText(); - bool has_fd = false; - bool has_panda = !serial.isEmpty(); - if (has_panda) { - try { - Panda panda(serial.toStdString()); - has_fd = (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA) || (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA_V2); - } catch (const std::exception& e) { - fprintf(stderr, "failed to open panda %s\n", serial.toUtf8().constData()); - has_panda = false; - } - } - - if (has_panda) { - config.serial = serial.toStdString(); - config.bus_config.resize(3); - for (int i = 0; i < config.bus_config.size(); i++) { - QHBoxLayout *bus_layout = new QHBoxLayout; - - // CAN Speed - bus_layout->addWidget(new QLabel(tr("CAN Speed (kbps):"))); - QComboBox *can_speed = new QComboBox; - for (int j = 0; j < std::size(speeds); j++) { - can_speed->addItem(QString::number(speeds[j])); - - if (data_speeds[j] == config.bus_config[i].can_speed_kbps) { - can_speed->setCurrentIndex(j); - } - } - QObject::connect(can_speed, qOverload(&QComboBox::currentIndexChanged), [=](int index) {config.bus_config[i].can_speed_kbps = speeds[index];}); - bus_layout->addWidget(can_speed); - - // CAN-FD Speed - if (has_fd) { - QCheckBox *enable_fd = new QCheckBox("CAN-FD"); - bus_layout->addWidget(enable_fd); - bus_layout->addWidget(new QLabel(tr("Data Speed (kbps):"))); - QComboBox *data_speed = new QComboBox; - for (int j = 0; j < std::size(data_speeds); j++) { - data_speed->addItem(QString::number(data_speeds[j])); - - if (data_speeds[j] == config.bus_config[i].data_speed_kbps) { - data_speed->setCurrentIndex(j); - } - } - - data_speed->setEnabled(false); - bus_layout->addWidget(data_speed); - - QObject::connect(data_speed, qOverload(&QComboBox::currentIndexChanged), [=](int index) {config.bus_config[i].data_speed_kbps = data_speeds[index];}); - QObject::connect(enable_fd, &QCheckBox::stateChanged, data_speed, &QComboBox::setEnabled); - QObject::connect(enable_fd, &QCheckBox::stateChanged, [=](int state) {config.bus_config[i].can_fd = (bool)state;}); - } - - form_layout->addRow(tr("Bus %1:").arg(i), bus_layout); - } - } else { - config.serial = ""; - form_layout->addWidget(new QLabel(tr("No panda found"))); - } -} - -AbstractStream *OpenPandaWidget::open() { - try { - return new PandaStream(config); - } catch (std::exception &e) { - QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to connect to panda: '%1'").arg(e.what())); - return nullptr; - } -} - -// OpenDeviceWidget - -OpenDeviceWidget::OpenDeviceWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { - QRadioButton *msgq = new QRadioButton(tr("MSGQ")); - QRadioButton *zmq = new QRadioButton(tr("ZMQ")); - ip_address = new QLineEdit(this); - ip_address->setPlaceholderText(tr("Enter device Ip Address")); - ip_address->setValidator(new IpAddressValidator(this)); - - group = new QButtonGroup(this); - group->addButton(msgq, 0); - group->addButton(zmq, 1); - - QFormLayout *form_layout = new QFormLayout(this); - form_layout->addRow(msgq); - form_layout->addRow(zmq, ip_address); - QObject::connect(group, qOverload(&QButtonGroup::buttonToggled), [=](QAbstractButton *button, bool checked) { - ip_address->setEnabled(button == zmq && checked); - }); - zmq->setChecked(true); -} - -AbstractStream *OpenDeviceWidget::open() { - std::string ip = ip_address->text().isEmpty() ? "127.0.0.1" : ip_address->text().toStdString(); - bool msgq = group->checkedId() == 0; - return new DeviceStream(msgq ? "" : ip); -} - -#ifdef __linux__ -// OpenSocketCanWidget - -OpenSocketCanWidget::OpenSocketCanWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->addStretch(1); - - QFormLayout *form_layout = new QFormLayout(); - - QHBoxLayout *device_layout = new QHBoxLayout(); - device_edit = new QComboBox(); - device_edit->setFixedWidth(300); - device_layout->addWidget(device_edit); - - QPushButton *refresh = new QPushButton(tr("Refresh")); - refresh->setFixedWidth(100); - device_layout->addWidget(refresh); - form_layout->addRow(tr("Device"), device_layout); - main_layout->addLayout(form_layout); - - main_layout->addStretch(1); - - QObject::connect(refresh, &QPushButton::clicked, this, &OpenSocketCanWidget::refreshDevices); - QObject::connect(device_edit, &QComboBox::currentTextChanged, this, [=]{ config.device = device_edit->currentText().toStdString(); }); - - // Populate devices - refreshDevices(); -} - -void OpenSocketCanWidget::refreshDevices() { - device_edit->clear(); - // Scan /sys/class/net/ for CAN interfaces (type 280 = ARPHRD_CAN) - std::error_code ec; - for (const auto &entry : std::filesystem::directory_iterator("/sys/class/net", ec)) { - std::ifstream type_file(entry.path() / "type"); - int type = 0; - if (type_file >> type && type == 280) { - device_edit->addItem(QString::fromStdString(entry.path().filename().string())); - } - } -} - -AbstractStream *OpenSocketCanWidget::open() { - try { - return new SocketCanStream(config); - } catch (std::exception &e) { - QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to connect to SocketCAN device: '%1'").arg(e.what())); - return nullptr; - } -} -#endif - -// StreamSelector - -StreamSelector::StreamSelector(QWidget *parent) : QDialog(parent) { - setWindowTitle(tr("Open stream")); - QVBoxLayout *layout = new QVBoxLayout(this); - tab = new QTabWidget(this); - layout->addWidget(tab); - - QHBoxLayout *dbc_layout = new QHBoxLayout(); - dbc_file = new QLineEdit(this); - dbc_file->setReadOnly(true); - dbc_file->setPlaceholderText(tr("Choose a dbc file to open")); - QPushButton *file_btn = new QPushButton(tr("Browse...")); - dbc_layout->addWidget(new QLabel(tr("dbc File"))); - dbc_layout->addWidget(dbc_file); - dbc_layout->addWidget(file_btn); - layout->addLayout(dbc_layout); - - QFrame *line = new QFrame(this); - line->setFrameStyle(QFrame::HLine | QFrame::Sunken); - layout->addWidget(line); - - btn_box = new QDialogButtonBox(QDialogButtonBox::Open | QDialogButtonBox::Cancel); - layout->addWidget(btn_box); - - addStreamWidget(new OpenReplayWidget, tr("&Replay")); - addStreamWidget(new OpenPandaWidget, tr("&Panda")); -#ifdef __linux__ - if (SocketCanStream::available()) { - addStreamWidget(new OpenSocketCanWidget, tr("&SocketCAN")); - } -#endif - addStreamWidget(new OpenDeviceWidget, tr("&Device")); - - QObject::connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject); - QObject::connect(btn_box, &QDialogButtonBox::accepted, [=]() { - setEnabled(false); - if (stream_ = ((AbstractOpenStreamWidget *)tab->currentWidget())->open(); stream_) { - accept(); - } - setEnabled(true); - }); - QObject::connect(file_btn, &QPushButton::clicked, [this]() { - QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), QString::fromStdString(settings.last_dir), "DBC (*.dbc)"); - if (!fn.isEmpty()) { - dbc_file->setText(fn); - settings.last_dir = std::filesystem::absolute(fn.toStdString()).parent_path().string(); - } - }); -} - -void StreamSelector::addStreamWidget(AbstractOpenStreamWidget *w, const QString &title) { - tab->addTab(w, title); - auto open_btn = btn_box->button(QDialogButtonBox::Open); - QObject::connect(w, &AbstractOpenStreamWidget::enableOpenButton, open_btn, &QPushButton::setEnabled); -} diff --git a/openpilot/tools/cabana/streamselector.h b/openpilot/tools/cabana/streamselector.h deleted file mode 100644 index 1210806a3b..0000000000 --- a/openpilot/tools/cabana/streamselector.h +++ /dev/null @@ -1,99 +0,0 @@ -#pragma once - -#include - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/streams/abstractstream.h" -#include "tools/cabana/streams/pandastream.h" -#ifdef __linux__ -#include "tools/cabana/streams/socketcanstream.h" -#endif - -class AbstractOpenStreamWidget : public QWidget { - Q_OBJECT -public: - AbstractOpenStreamWidget(QWidget *parent = nullptr) : QWidget(parent) {} - virtual AbstractStream *open() = 0; - -signals: - void enableOpenButton(bool); -}; - -class OpenReplayWidget : public AbstractOpenStreamWidget { - Q_OBJECT - -public: - OpenReplayWidget(QWidget *parent = nullptr); - AbstractStream *open() override; - -private: - QLineEdit *route_edit; - std::vector cameras; -}; - -class OpenPandaWidget : public AbstractOpenStreamWidget { - Q_OBJECT - -public: - OpenPandaWidget(QWidget *parent = nullptr); - AbstractStream *open() override; - -private: - void refreshSerials(); - void buildConfigForm(); - - QComboBox *serial_edit; - QFormLayout *form_layout; - PandaStreamConfig config = {}; -}; - -class OpenDeviceWidget : public AbstractOpenStreamWidget { - Q_OBJECT - -public: - OpenDeviceWidget(QWidget *parent = nullptr); - AbstractStream *open() override; - -private: - QLineEdit *ip_address; - QButtonGroup *group; -}; - -#ifdef __linux__ -// no Q_OBJECT: moc does not define __linux__ and would otherwise skip this class -class OpenSocketCanWidget : public AbstractOpenStreamWidget { -public: - OpenSocketCanWidget(QWidget *parent = nullptr); - AbstractStream *open() override; - -private: - void refreshDevices(); - - QComboBox *device_edit; - SocketCanStreamConfig config = {}; -}; -#endif - -class StreamSelector : public QDialog { - Q_OBJECT - -public: - StreamSelector(QWidget *parent = nullptr); - void addStreamWidget(AbstractOpenStreamWidget *w, const QString &title); - QString dbcFile() const { return dbc_file->text(); } - AbstractStream *stream() const { return stream_; } - -private: - AbstractStream *stream_ = nullptr; - QLineEdit *dbc_file; - QTabWidget *tab; - QDialogButtonBox *btn_box; -}; diff --git a/openpilot/tools/cabana/tools/findsignal.cc b/openpilot/tools/cabana/tools/findsignal.cc deleted file mode 100644 index 2e533741cc..0000000000 --- a/openpilot/tools/cabana/tools/findsignal.cc +++ /dev/null @@ -1,286 +0,0 @@ -#include "tools/cabana/tools/findsignal.h" - -#include -#include - -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/utils/qtutil.h" - -// FindSignalModel - -QVariant FindSignalModel::headerData(int section, Qt::Orientation orientation, int role) const { - static QString titles[] = {"Id", "Start Bit, size", "(time, value)"}; - if (role != Qt::DisplayRole) return {}; - return orientation == Qt::Horizontal ? titles[section] : QString::number(section + 1); -} - -QVariant FindSignalModel::data(const QModelIndex &index, int role) const { - if (role == Qt::DisplayRole) { - const auto &s = filtered_signals[index.row()]; - switch (index.column()) { - case 0: return QString::fromStdString(s.id.toString()); - case 1: return QString("%1, %2").arg(s.sig.start_bit).arg(s.sig.size); - case 2: return s.values.join(" "); - } - } - return {}; -} - -void FindSignalModel::search(std::function cmp) { - beginResetModel(); - - std::mutex lock; - const auto prev_sigs = !histories.empty() ? histories.back() : initial_signals; - filtered_signals.clear(); - filtered_signals.reserve(prev_sigs.size()); - - unsigned int num_threads = std::max(1u, std::thread::hardware_concurrency()); - size_t chunk = (prev_sigs.size() + num_threads - 1) / num_threads; - std::vector threads; - for (unsigned int t = 0; t < num_threads && t * chunk < (size_t)prev_sigs.size(); ++t) { - size_t start = t * chunk; - size_t end = std::min(start + chunk, (size_t)prev_sigs.size()); - threads.emplace_back([&, start, end]() { - for (size_t i = start; i < end; ++i) { - const auto &s = prev_sigs[i]; - const auto &events = can->events(s.id); - auto first = std::upper_bound(events.cbegin(), events.cend(), s.mono_time, CompareCanEvent()); - auto last = events.cend(); - if (last_time < std::numeric_limits::max()) { - last = std::upper_bound(events.cbegin(), events.cend(), last_time, CompareCanEvent()); - } - - auto it = std::find_if(first, last, [&](const CanEvent *e) { return cmp(get_raw_value(e->dat, e->size, s.sig)); }); - if (it != last) { - auto values = s.values; - values += QString("(%1, %2)").arg(can->toSeconds((*it)->mono_time), 0, 'f', 3).arg(get_raw_value((*it)->dat, (*it)->size, s.sig)); - std::lock_guard lk(lock); - filtered_signals.push_back({.id = s.id, .mono_time = (*it)->mono_time, .sig = s.sig, .values = values}); - } - } - }); - } - for (auto &th : threads) th.join(); - - histories.push_back(filtered_signals); - - endResetModel(); -} - -void FindSignalModel::undo() { - if (!histories.empty()) { - beginResetModel(); - histories.pop_back(); - filtered_signals.clear(); - if (!histories.empty()) filtered_signals = histories.back(); - endResetModel(); - } -} - -void FindSignalModel::reset() { - beginResetModel(); - histories.clear(); - filtered_signals.clear(); - initial_signals.clear(); - endResetModel(); -} - -// FindSignalDlg -FindSignalDlg::FindSignalDlg(QWidget *parent) : QDialog(parent, Qt::WindowFlags() | Qt::Window) { - setWindowTitle(tr("Find Signal")); - setAttribute(Qt::WA_DeleteOnClose); - QVBoxLayout *main_layout = new QVBoxLayout(this); - - // Messages group - message_group = new QGroupBox(tr("Messages"), this); - QFormLayout *message_layout = new QFormLayout(message_group); - message_layout->addRow(tr("Bus"), bus_edit = new QLineEdit()); - bus_edit->setPlaceholderText(tr("comma-separated values. Leave blank for all")); - message_layout->addRow(tr("Address"), address_edit = new QLineEdit()); - address_edit->setPlaceholderText(tr("comma-separated hex values. Leave blank for all")); - QHBoxLayout *hlayout = new QHBoxLayout(); - hlayout->addWidget(first_time_edit = new QLineEdit("0")); - hlayout->addWidget(new QLabel("-")); - hlayout->addWidget(last_time_edit = new QLineEdit("MAX")); - hlayout->addWidget(new QLabel("seconds")); - hlayout->addStretch(0); - message_layout->addRow(tr("Time"), hlayout); - - // Signal group - properties_group = new QGroupBox(tr("Signal")); - QFormLayout *property_layout = new QFormLayout(properties_group); - property_layout->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint); - - hlayout = new QHBoxLayout(); - hlayout->addWidget(min_size = new QSpinBox); - hlayout->addWidget(new QLabel("-")); - hlayout->addWidget(max_size = new QSpinBox); - hlayout->addWidget(litter_endian = new QCheckBox(tr("Little endian"))); - hlayout->addWidget(is_signed = new QCheckBox(tr("Signed"))); - hlayout->addStretch(0); - min_size->setRange(1, 64); - max_size->setRange(1, 64); - min_size->setValue(8); - max_size->setValue(8); - litter_endian->setChecked(true); - property_layout->addRow(tr("Size"), hlayout); - property_layout->addRow(tr("Factor"), factor_edit = new QLineEdit("1.0")); - property_layout->addRow(tr("Offset"), offset_edit = new QLineEdit("0.0")); - - // find group - QGroupBox *find_group = new QGroupBox(tr("Find signal"), this); - QVBoxLayout *vlayout = new QVBoxLayout(find_group); - hlayout = new QHBoxLayout(); - hlayout->addWidget(new QLabel(tr("Value"))); - hlayout->addWidget(compare_cb = new QComboBox(this)); - hlayout->addWidget(value1 = new QLineEdit); - hlayout->addWidget(to_label = new QLabel("-")); - hlayout->addWidget(value2 = new QLineEdit); - hlayout->addWidget(undo_btn = new QPushButton(tr("Undo prev find"), this)); - hlayout->addWidget(search_btn = new QPushButton(tr("Find"))); - hlayout->addWidget(reset_btn = new QPushButton(tr("Reset"), this)); - vlayout->addLayout(hlayout); - - compare_cb->addItems({"=", ">", ">=", "!=", "<", "<=", "between"}); - value1->setFocus(Qt::OtherFocusReason); - value2->setVisible(false); - to_label->setVisible(false); - undo_btn->setEnabled(false); - reset_btn->setEnabled(false); - - auto double_validator = new DoubleValidator(this); - for (auto edit : {value1, value2, factor_edit, offset_edit, first_time_edit, last_time_edit}) { - edit->setValidator(double_validator); - } - - vlayout->addWidget(view = new QTableView(this)); - view->setContextMenuPolicy(Qt::CustomContextMenu); - view->horizontalHeader()->setStretchLastSection(true); - view->horizontalHeader()->setSelectionMode(QAbstractItemView::NoSelection); - view->setSelectionBehavior(QAbstractItemView::SelectRows); - view->setModel(model = new FindSignalModel(this)); - - hlayout = new QHBoxLayout(); - hlayout->addWidget(message_group); - hlayout->addWidget(properties_group); - main_layout->addLayout(hlayout); - main_layout->addWidget(find_group); - main_layout->addWidget(stats_label = new QLabel()); - - setMinimumSize({700, 650}); - QObject::connect(search_btn, &QPushButton::clicked, this, &FindSignalDlg::search); - QObject::connect(undo_btn, &QPushButton::clicked, model, &FindSignalModel::undo); - QObject::connect(model, &QAbstractItemModel::modelReset, this, &FindSignalDlg::modelReset); - QObject::connect(reset_btn, &QPushButton::clicked, model, &FindSignalModel::reset); - QObject::connect(view, &QTableView::customContextMenuRequested, this, &FindSignalDlg::customMenuRequested); - QObject::connect(view, &QTableView::doubleClicked, [this](const QModelIndex &index) { - if (index.isValid()) emit openMessage(model->filtered_signals[index.row()].id); - }); - QObject::connect(compare_cb, qOverload(&QComboBox::currentIndexChanged), [=](int index) { - to_label->setVisible(index == compare_cb->count() - 1); - value2->setVisible(index == compare_cb->count() - 1); - }); -} - -void FindSignalDlg::search() { - if (model->histories.empty()) { - setInitialSignals(); - } - auto v1 = value1->text().toDouble(); - auto v2 = value2->text().toDouble(); - std::function cmp = nullptr; - switch (compare_cb->currentIndex()) { - case 0: cmp = [v1](double v) { return v == v1;}; break; - case 1: cmp = [v1](double v) { return v > v1;}; break; - case 2: cmp = [v1](double v) { return v >= v1;}; break; - case 3: cmp = [v1](double v) { return v != v1;}; break; - case 4: cmp = [v1](double v) { return v < v1;}; break; - case 5: cmp = [v1](double v) { return v <= v1;}; break; - case 6: cmp = [v1, v2](double v) { return v >= v1 && v <= v2;}; break; - } - properties_group->setEnabled(false); - message_group->setEnabled(false); - search_btn->setEnabled(false); - stats_label->setVisible(false); - search_btn->setText("Finding ...."); - QTimer::singleShot(0, this, [=]() { model->search(cmp); }); -} - -void FindSignalDlg::setInitialSignals() { - std::set buses; - for (auto bus : bus_edit->text().trimmed().split(",")) { - bus = bus.trimmed(); - if (!bus.isEmpty()) buses.insert(bus.toUShort()); - } - - std::set addresses; - for (auto addr : address_edit->text().trimmed().split(",")) { - addr = addr.trimmed(); - if (!addr.isEmpty()) addresses.insert(addr.toULong(nullptr, 16)); - } - - cabana::Signal sig{}; - sig.is_little_endian = litter_endian->isChecked(); - sig.is_signed = is_signed->isChecked(); - sig.factor = factor_edit->text().toDouble(); - sig.offset = offset_edit->text().toDouble(); - - double first_time_val = first_time_edit->text().toDouble(); - double last_time_val = last_time_edit->text().toDouble(); - auto [first_sec, last_sec] = std::minmax(first_time_val, last_time_val); - uint64_t first_time = can->toMonoTime(first_sec); - model->last_time = std::numeric_limits::max(); - if (last_sec > 0) { - model->last_time = can->toMonoTime(last_sec); - } - model->initial_signals.clear(); - - for (const auto &[id, m] : can->lastMessages()) { - if ((buses.empty() || buses.count(id.source)) && (addresses.empty() || addresses.count(id.address))) { - const auto &events = can->events(id); - auto e = std::lower_bound(events.cbegin(), events.cend(), first_time, CompareCanEvent()); - if (e != events.cend()) { - const int total_size = m.dat.size() * 8; - for (int size = min_size->value(); size <= max_size->value(); ++size) { - for (int start = 0; start <= total_size - size; ++start) { - FindSignalModel::SearchSignal s{.id = id, .mono_time = first_time, .sig = sig}; - s.sig.start_bit = start; - s.sig.size = size; - updateMsbLsb(s.sig); - s.value = get_raw_value((*e)->dat, (*e)->size, s.sig); - model->initial_signals.push_back(s); - } - } - } - } - } -} - -void FindSignalDlg::modelReset() { - properties_group->setEnabled(model->histories.empty()); - message_group->setEnabled(model->histories.empty()); - search_btn->setText(model->histories.empty() ? tr("Find") : tr("Find Next")); - reset_btn->setEnabled(!model->histories.empty()); - undo_btn->setEnabled(model->histories.size() > 1); - search_btn->setEnabled(model->rowCount() > 0 || model->histories.empty()); - stats_label->setVisible(true); - stats_label->setText(tr("%1 matches. right click on an item to create signal. double click to open message").arg(model->filtered_signals.size())); -} - -void FindSignalDlg::customMenuRequested(const QPoint &pos) { - if (auto index = view->indexAt(pos); index.isValid()) { - QMenu menu(this); - menu.addAction(tr("Create Signal")); - if (menu.exec(view->mapToGlobal(pos))) { - auto &s = model->filtered_signals[index.row()]; - UndoStack::instance()->push(new AddSigCommand(s.id, s.sig)); - emit openMessage(s.id); - } - } -} diff --git a/openpilot/tools/cabana/tools/findsignal.h b/openpilot/tools/cabana/tools/findsignal.h deleted file mode 100644 index b7cae73b49..0000000000 --- a/openpilot/tools/cabana/tools/findsignal.h +++ /dev/null @@ -1,71 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/commands.h" -#include "tools/cabana/settings.h" - -class FindSignalModel : public QAbstractTableModel { -public: - struct SearchSignal { - MessageId id = {}; - uint64_t mono_time = 0; - cabana::Signal sig = {}; - double value = 0.; - QStringList values; - }; - - FindSignalModel(QObject *parent) : QAbstractTableModel(parent) {} - QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - int columnCount(const QModelIndex &parent = QModelIndex()) const override { return 3; } - int rowCount(const QModelIndex &parent = QModelIndex()) const override { return std::min((int)filtered_signals.size(), 300); } - void search(std::function cmp); - void reset(); - void undo(); - - std::vector filtered_signals; - std::vector initial_signals; - std::vector> histories; - uint64_t last_time = std::numeric_limits::max(); -}; - -class FindSignalDlg : public QDialog { - Q_OBJECT -public: - FindSignalDlg(QWidget *parent); - -signals: - void openMessage(const MessageId &id); - -private: - void search(); - void modelReset(); - void setInitialSignals(); - void customMenuRequested(const QPoint &pos); - - QLineEdit *value1, *value2, *factor_edit, *offset_edit; - QLineEdit *bus_edit, *address_edit, *first_time_edit, *last_time_edit; - QComboBox *compare_cb; - QSpinBox *min_size, *max_size; - QCheckBox *litter_endian, *is_signed; - QPushButton *search_btn, *reset_btn, *undo_btn; - QGroupBox *properties_group, *message_group; - QTableView *view; - QLabel *to_label, *stats_label; - FindSignalModel *model; -}; diff --git a/openpilot/tools/cabana/tools/findsimilarbits.cc b/openpilot/tools/cabana/tools/findsimilarbits.cc deleted file mode 100644 index 8062b61199..0000000000 --- a/openpilot/tools/cabana/tools/findsimilarbits.cc +++ /dev/null @@ -1,161 +0,0 @@ -#include "tools/cabana/tools/findsimilarbits.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/streams/abstractstream.h" - -FindSimilarBitsDlg::FindSimilarBitsDlg(QWidget *parent) : QDialog(parent, Qt::WindowFlags() | Qt::Window) { - setWindowTitle(tr("Find similar bits")); - setAttribute(Qt::WA_DeleteOnClose); - - QVBoxLayout *main_layout = new QVBoxLayout(this); - - QHBoxLayout *src_layout = new QHBoxLayout(); - src_bus_combo = new QComboBox(this); - find_bus_combo = new QComboBox(this); - for (auto cb : {src_bus_combo, find_bus_combo}) { - for (uint8_t bus : can->sources) { - cb->addItem(QString::number(bus), bus); - } - } - - msg_cb = new QComboBox(this); - // TODO: update when src_bus_combo changes - for (auto &[address, msg] : dbc()->getMessages(-1)) { - msg_cb->addItem(QString::fromStdString(msg.name), address); - } - msg_cb->model()->sort(0); - msg_cb->setCurrentIndex(0); - - byte_idx_sb = new QSpinBox(this); - byte_idx_sb->setFixedWidth(50); - byte_idx_sb->setRange(0, 63); - - bit_idx_sb = new QSpinBox(this); - bit_idx_sb->setFixedWidth(50); - bit_idx_sb->setRange(0, 7); - - src_layout->addWidget(new QLabel(tr("Bus"))); - src_layout->addWidget(src_bus_combo); - src_layout->addWidget(msg_cb); - src_layout->addWidget(new QLabel(tr("Byte Index"))); - src_layout->addWidget(byte_idx_sb); - src_layout->addWidget(new QLabel(tr("Bit Index"))); - src_layout->addWidget(bit_idx_sb); - src_layout->addStretch(0); - - QHBoxLayout *find_layout = new QHBoxLayout(); - find_layout->addWidget(new QLabel(tr("Bus"))); - find_layout->addWidget(find_bus_combo); - find_layout->addWidget(new QLabel(tr("Equal"))); - equal_combo = new QComboBox(this); - equal_combo->addItems({"Yes", "No"}); - find_layout->addWidget(equal_combo); - min_msgs = new QLineEdit(this); - min_msgs->setValidator(new QIntValidator(this)); - min_msgs->setText("100"); - find_layout->addWidget(new QLabel(tr("Min msg count"))); - find_layout->addWidget(min_msgs); - search_btn = new QPushButton(tr("&Find"), this); - find_layout->addWidget(search_btn); - find_layout->addStretch(0); - - QGridLayout *grid_layout = new QGridLayout(); - grid_layout->addWidget(new QLabel("Find From:"), 0, 0); - grid_layout->addLayout(src_layout, 0, 1); - grid_layout->addWidget(new QLabel("Find In:"), 1, 0); - grid_layout->addLayout(find_layout, 1, 1); - main_layout->addLayout(grid_layout); - - table = new QTableWidget(this); - table->setSelectionBehavior(QAbstractItemView::SelectRows); - table->setSelectionMode(QAbstractItemView::SingleSelection); - table->setEditTriggers(QAbstractItemView::NoEditTriggers); - table->horizontalHeader()->setStretchLastSection(true); - main_layout->addWidget(table); - - setMinimumSize({700, 500}); - QObject::connect(search_btn, &QPushButton::clicked, this, &FindSimilarBitsDlg::find); - QObject::connect(table, &QTableWidget::doubleClicked, [this](const QModelIndex &index) { - if (index.isValid()) { - MessageId msg_id = {.source = (uint8_t)find_bus_combo->currentData().toUInt(), .address = table->item(index.row(), 0)->text().toUInt(0, 16)}; - emit openMessage(msg_id); - } - }); -} - -void FindSimilarBitsDlg::find() { - search_btn->setEnabled(false); - table->clear(); - uint32_t selected_address = msg_cb->currentData().toUInt(); - auto msg_mismatched = calcBits(src_bus_combo->currentText().toUInt(), selected_address, byte_idx_sb->value(), bit_idx_sb->value(), - find_bus_combo->currentText().toUInt(), equal_combo->currentIndex() == 0, min_msgs->text().toInt()); - table->setRowCount(msg_mismatched.size()); - table->setColumnCount(6); - table->setHorizontalHeaderLabels({"address", "byte idx", "bit idx", "mismatches", "total msgs", "% mismatched"}); - for (int i = 0; i < msg_mismatched.size(); ++i) { - auto &m = msg_mismatched[i]; - table->setItem(i, 0, new QTableWidgetItem(QString("%1").arg(m.address, 1, 16))); - table->setItem(i, 1, new QTableWidgetItem(QString::number(m.byte_idx))); - table->setItem(i, 2, new QTableWidgetItem(QString::number(m.bit_idx))); - table->setItem(i, 3, new QTableWidgetItem(QString::number(m.mismatches))); - table->setItem(i, 4, new QTableWidgetItem(QString::number(m.total))); - table->setItem(i, 5, new QTableWidgetItem(QString::number(m.perc, 'f', 2))); - } - search_btn->setEnabled(true); -} - -std::vector FindSimilarBitsDlg::calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, - int bit_idx, uint8_t find_bus, bool equal, int min_msgs_cnt) { - std::unordered_map> mismatches; - std::unordered_map msg_count; - const auto &events = can->allEvents(); - int bit_to_find = -1; - for (const CanEvent *e : events) { - if (e->src == bus) { - if (e->address == selected_address && e->size > byte_idx) { - bit_to_find = ((e->dat[byte_idx] >> (7 - bit_idx)) & 1) != 0; - } - } - if (e->src == find_bus) { - ++msg_count[e->address]; - if (bit_to_find == -1) continue; - - auto &mismatched = mismatches[e->address]; - if (mismatched.size() < e->size * 8) { - mismatched.resize(e->size * 8); - } - for (int i = 0; i < e->size; ++i) { - for (int j = 0; j < 8; ++j) { - int bit = ((e->dat[i] >> (7 - j)) & 1) != 0; - mismatched[i * 8 + j] += equal ? (bit != bit_to_find) : (bit == bit_to_find); - } - } - } - } - - std::vector result; - result.reserve(mismatches.size()); - for (auto it = mismatches.begin(); it != mismatches.end(); ++it) { - if (auto cnt = msg_count[it->first]; cnt > (uint32_t)min_msgs_cnt) { - auto &mismatched = it->second; - for (int i = 0; i < (int)mismatched.size(); ++i) { - if (float perc = (mismatched[i] / (double)cnt) * 100; perc < 50) { - result.push_back({it->first, (uint32_t)i / 8, (uint32_t)i % 8, mismatched[i], cnt, perc}); - } - } - } - } - std::sort(result.begin(), result.end(), [](auto &l, auto &r) { return l.perc < r.perc; }); - return result; -} diff --git a/openpilot/tools/cabana/tools/findsimilarbits.h b/openpilot/tools/cabana/tools/findsimilarbits.h deleted file mode 100644 index 3451360654..0000000000 --- a/openpilot/tools/cabana/tools/findsimilarbits.h +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once - -#include - -#include -#include -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" - -class FindSimilarBitsDlg : public QDialog { - Q_OBJECT - -public: - FindSimilarBitsDlg(QWidget *parent); - -signals: - void openMessage(const MessageId &msg_id); - -private: - struct mismatched_struct { - uint32_t address, byte_idx, bit_idx, mismatches, total; - float perc; - }; - std::vector calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, int bit_idx, uint8_t find_bus, - bool equal, int min_msgs_cnt); - void find(); - - QTableWidget *table; - QComboBox *src_bus_combo, *find_bus_combo, *msg_cb, *equal_combo; - QSpinBox *byte_idx_sb, *bit_idx_sb; - QPushButton *search_btn; - QLineEdit *min_msgs; -}; diff --git a/openpilot/tools/cabana/tools/routeinfo.cc b/openpilot/tools/cabana/tools/routeinfo.cc deleted file mode 100644 index 1037d4a206..0000000000 --- a/openpilot/tools/cabana/tools/routeinfo.cc +++ /dev/null @@ -1,40 +0,0 @@ -#include "tools/cabana/tools/routeinfo.h" -#include -#include -#include -#include -#include "tools/cabana/streams/replaystream.h" - -RouteInfoDlg::RouteInfoDlg(QWidget *parent) : QDialog(parent) { - auto *replay = dynamic_cast(can)->getReplay(); - setWindowTitle(tr("Route: %1").arg(QString::fromStdString(replay->route().name()))); - - auto *table = new QTableWidget(replay->route().segments().size(), 7, this); - table->setToolTip(tr("Click on a row to seek to the corresponding segment.")); - table->setEditTriggers(QAbstractItemView::NoEditTriggers); - table->setSelectionBehavior(QAbstractItemView::SelectRows); - table->setSelectionMode(QAbstractItemView::SingleSelection); - table->setHorizontalHeaderLabels({"", "rlog", "narrow road", "wide road", "driver", "qlog", "qcam"}); - table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); - table->verticalHeader()->setVisible(false); - table->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - - int row = 0; - for (const auto &[seg_num, seg] : replay->route().segments()) { - table->setItem(row, 0, new QTableWidgetItem(QString::number(seg_num))); - table->setItem(row, 1, new QTableWidgetItem(seg.rlog.empty() ? "--" : "Yes")); - table->setItem(row, 2, new QTableWidgetItem(seg.narrow_road_cam.empty() ? "--" : "Yes")); - table->setItem(row, 3, new QTableWidgetItem(seg.wide_road_cam.empty() ? "--" : "Yes")); - table->setItem(row, 4, new QTableWidgetItem(seg.cabin_cam.empty() ? "--" : "Yes")); - table->setItem(row, 5, new QTableWidgetItem(seg.qlog.empty() ? "--" : "Yes")); - table->setItem(row, 6, new QTableWidgetItem(seg.qcamera.empty() ? "--" : "Yes")); - ++row; - } - table->setMinimumWidth(table->horizontalHeader()->length() + table->verticalScrollBar()->sizeHint().width()); - table->setMinimumHeight(table->rowHeight(0) * std::min(table->rowCount(), 13) + table->horizontalHeader()->height() + table->frameWidth() * 2); - - connect(table, &QTableWidget::itemClicked, [](QTableWidgetItem *item) { can->seekTo(item->row() * 60.0); }); - - QVBoxLayout *layout = new QVBoxLayout(this); - layout->addWidget(table); -} diff --git a/openpilot/tools/cabana/tools/routeinfo.h b/openpilot/tools/cabana/tools/routeinfo.h deleted file mode 100644 index 36f32b4bf4..0000000000 --- a/openpilot/tools/cabana/tools/routeinfo.h +++ /dev/null @@ -1,8 +0,0 @@ -#pragma once -#include - -class RouteInfoDlg : public QDialog { - Q_OBJECT -public: - RouteInfoDlg(QWidget *parent = nullptr); -}; diff --git a/openpilot/tools/cabana/utils/elidedlabel.cc b/openpilot/tools/cabana/utils/elidedlabel.cc deleted file mode 100644 index 629a108b16..0000000000 --- a/openpilot/tools/cabana/utils/elidedlabel.cc +++ /dev/null @@ -1,29 +0,0 @@ -#include "tools/cabana/utils/elidedlabel.h" -#include -#include - -ElidedLabel::ElidedLabel(QWidget *parent) : ElidedLabel({}, parent) {} - -ElidedLabel::ElidedLabel(const QString &text, QWidget *parent) : QLabel(text.trimmed(), parent) { - setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); - setMinimumWidth(1); -} - -void ElidedLabel::resizeEvent(QResizeEvent* event) { - QLabel::resizeEvent(event); - lastText_ = elidedText_ = ""; -} - -void ElidedLabel::paintEvent(QPaintEvent *event) { - const QString curText = text(); - if (curText != lastText_) { - elidedText_ = fontMetrics().elidedText(curText, Qt::ElideRight, contentsRect().width()); - lastText_ = curText; - } - - QPainter painter(this); - drawFrame(&painter); - QStyleOption opt; - opt.initFrom(this); - style()->drawItemText(&painter, contentsRect(), alignment(), opt.palette, isEnabled(), elidedText_, foregroundRole()); -} diff --git a/openpilot/tools/cabana/utils/elidedlabel.h b/openpilot/tools/cabana/utils/elidedlabel.h deleted file mode 100644 index 577eea12a0..0000000000 --- a/openpilot/tools/cabana/utils/elidedlabel.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include -#include - -class ElidedLabel : public QLabel { - Q_OBJECT - -public: - explicit ElidedLabel(QWidget *parent = 0); - explicit ElidedLabel(const QString &text, QWidget *parent = 0); - -signals: - void clicked(); - -protected: - void paintEvent(QPaintEvent *event) override; - void resizeEvent(QResizeEvent* event) override; - void mouseReleaseEvent(QMouseEvent *event) override { - if (rect().contains(event->pos())) { - emit clicked(); - } - } - QString lastText_, elidedText_; -}; diff --git a/openpilot/tools/cabana/utils/qtutil.cc b/openpilot/tools/cabana/utils/qtutil.cc deleted file mode 100644 index 040c90c624..0000000000 --- a/openpilot/tools/cabana/utils/qtutil.cc +++ /dev/null @@ -1,236 +0,0 @@ -#include "tools/cabana/utils/qtutil.h" - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -// MessageBytesDelegate - -MessageBytesDelegate::MessageBytesDelegate(QObject *parent, bool multiple_lines) - : font_metrics(QApplication::font()), multiple_lines(multiple_lines), QStyledItemDelegate(parent) { - fixed_font = QFontDatabase::systemFont(QFontDatabase::FixedFont); - byte_size = QFontMetrics(fixed_font).size(Qt::TextSingleLine, "00 ") + QSize(0, 2); - for (int i = 0; i < 256; ++i) { - hex_text_table[i].setText(QStringLiteral("%1").arg(i, 2, 16, QLatin1Char('0')).toUpper()); - hex_text_table[i].prepare({}, fixed_font); - } - h_margin = QApplication::style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1; - v_margin = QApplication::style()->pixelMetric(QStyle::PM_FocusFrameVMargin) + 1; -} - -QSize MessageBytesDelegate::sizeForBytes(int n) const { - int rows = multiple_lines ? std::max(1, n / 8) : 1; - return {(n / rows) * byte_size.width() + h_margin * 2, rows * byte_size.height() + v_margin * 2}; -} - -QSize MessageBytesDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { - auto data = index.data(BytesRole); - return sizeForBytes(data.isValid() ? static_cast *>(data.value())->size() : 0); -} - -void MessageBytesDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { - if (option.state & QStyle::State_Selected) { - painter->fillRect(option.rect, option.palette.brush(QPalette::Normal, QPalette::Highlight)); - } - - QRect item_rect = option.rect.adjusted(h_margin, v_margin, -h_margin, -v_margin); - QColor highlighted_color = option.palette.color(QPalette::HighlightedText); - auto text_color = index.data(Qt::ForegroundRole).value(); - bool inactive = text_color.isValid(); - if (!inactive) { - text_color = option.palette.color(QPalette::Text); - } - auto data = index.data(BytesRole); - if (!data.isValid()) { - painter->setFont(option.font); - painter->setPen(option.state & QStyle::State_Selected ? highlighted_color : text_color); - QString text = font_metrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, item_rect.width()); - painter->drawText(item_rect, Qt::AlignLeft | Qt::AlignVCenter, text); - return; - } - - // Paint hex column - const auto &bytes = *static_cast *>(data.value()); - const auto &colors = *static_cast *>(index.data(ColorsRole).value()); - - painter->setFont(fixed_font); - const QPen text_pen(option.state & QStyle::State_Selected ? highlighted_color : text_color); - const QPoint pt = item_rect.topLeft(); - for (int i = 0; i < bytes.size(); ++i) { - int row = !multiple_lines ? 0 : i / 8; - int column = !multiple_lines ? i : i % 8; - QRect r({pt.x() + column * byte_size.width(), pt.y() + row * byte_size.height()}, byte_size); - - if (!inactive && i < colors.size() && colors[i].alpha() > 0) { - if (option.state & QStyle::State_Selected) { - painter->setPen(option.palette.color(QPalette::Text)); - painter->fillRect(r, option.palette.color(QPalette::Window)); - } - painter->fillRect(r, toQColor(colors[i])); - } else { - painter->setPen(text_pen); - } - utils::drawStaticText(painter, r, hex_text_table[bytes[i]]); - } -} - -// TabBar - -int TabBar::addTab(const QString &text) { - int index = QTabBar::addTab(text); - QToolButton *btn = new ToolButton("x", tr("Close Tab")); - int width = style()->pixelMetric(QStyle::PM_TabCloseIndicatorWidth, nullptr, btn); - int height = style()->pixelMetric(QStyle::PM_TabCloseIndicatorHeight, nullptr, btn); - btn->setFixedSize({width, height}); - setTabButton(index, QTabBar::RightSide, btn); - QObject::connect(btn, &QToolButton::clicked, this, &TabBar::closeTabClicked); - return index; -} - -void TabBar::closeTabClicked() { - QObject *object = sender(); - for (int i = 0; i < count(); ++i) { - if (tabButton(i, QTabBar::RightSide) == object) { - emit tabCloseRequested(i); - break; - } - } -} - -// validators - -static QValidator::State toQtState(ValidState s) { - switch (s) { - case ValidState::Acceptable: return QValidator::Acceptable; - case ValidState::Intermediate: return QValidator::Intermediate; - default: return QValidator::Invalid; - } -} - -QValidator::State NameValidator::validate(QString &input, int &pos) const { - std::string s = input.toStdString(); - auto state = validateName(s); - input = QString::fromStdString(s); - return toQtState(state); -} - -QValidator::State NodeValidator::validate(QString &input, int &pos) const { - return toQtState(validateNodes(input.toStdString())); -} - -QValidator::State NonWhitespaceValidator::validate(QString &input, int &pos) const { - return toQtState(validateNonWhitespace(input.toStdString())); -} - -QValidator::State IpAddressValidator::validate(QString &input, int &pos) const { - return toQtState(validateIpAddress(input.toStdString())); -} - -QValidator::State DoubleValidator::validate(QString &input, int &pos) const { - return toQtState(validateDouble(input.toLatin1().toStdString())); -} - -namespace utils { - -bool isDarkTheme() { - QColor windowColor = QApplication::palette().color(QPalette::Window); - return windowColor.lightness() < 128; -} - -QPixmap icon(const QString &id) { - bool dark_theme = isDarkTheme(); - - QPixmap pm; - QString key = "bootstrap_" % id % (dark_theme ? "1" : "0"); - if (!QPixmapCache::find(key, &pm)) { - pm = bootstrapPixmap(id); - if (dark_theme) { - QPainter p(&pm); - p.setCompositionMode(QPainter::CompositionMode_SourceIn); - p.fillRect(pm.rect(), QColor("#bbbbbb")); - } - QPixmapCache::insert(key, pm); - } - return pm; -} - -void setTheme(int theme) { - auto style = QApplication::style(); - if (!style) return; - - static int prev_theme = 0; - if (theme != prev_theme) { - prev_theme = theme; - QPalette new_palette; - if (theme == DARK_THEME) { - new_palette.setColor(QPalette::Window, toQColor(DarkTheme::window)); - new_palette.setColor(QPalette::WindowText, toQColor(DarkTheme::window_text)); - new_palette.setColor(QPalette::Base, toQColor(DarkTheme::base)); - new_palette.setColor(QPalette::AlternateBase, toQColor(DarkTheme::base)); - new_palette.setColor(QPalette::ToolTipBase, toQColor(DarkTheme::base)); - new_palette.setColor(QPalette::ToolTipText, toQColor(DarkTheme::tooltip_text)); - new_palette.setColor(QPalette::Text, toQColor(DarkTheme::text)); - new_palette.setColor(QPalette::Button, toQColor(DarkTheme::button)); - new_palette.setColor(QPalette::ButtonText, toQColor(DarkTheme::window_text)); - new_palette.setColor(QPalette::Highlight, toQColor(DarkTheme::highlight)); - new_palette.setColor(QPalette::HighlightedText, toQColor(DarkTheme::window_text)); - new_palette.setColor(QPalette::BrightText, toQColor(DarkTheme::bright_text)); - new_palette.setColor(QPalette::Disabled, QPalette::ButtonText, toQColor(DarkTheme::disabled_text)); - new_palette.setColor(QPalette::Disabled, QPalette::WindowText, toQColor(DarkTheme::disabled_text)); - new_palette.setColor(QPalette::Disabled, QPalette::Text, toQColor(DarkTheme::disabled_text)); - new_palette.setColor(QPalette::Light, toQColor(DarkTheme::light)); - new_palette.setColor(QPalette::Dark, toQColor(DarkTheme::dark)); - } else { - new_palette = style->standardPalette(); - } - qApp->setPalette(new_palette); - style->polish(qApp); - for (auto w : QApplication::allWidgets()) { - w->setPalette(new_palette); - } - } -} - -} // namespace utils - -void sigTermHandler(int s) { - std::signal(s, SIG_DFL); - qApp->quit(); -} - -void initApp(int argc, char *argv[], bool disable_hidpi) { - // setup signal handlers to exit gracefully - std::signal(SIGINT, sigTermHandler); - std::signal(SIGTERM, sigTermHandler); - -#ifdef __APPLE__ - // Get the devicePixelRatio, and scale accordingly to maintain 1:1 rendering - QApplication tmp(argc, argv); - if (disable_hidpi) { - qputenv("QT_SCALE_FACTOR", QString::number(1.0 / tmp.devicePixelRatio()).toLocal8Bit()); - } -#endif - - qputenv("QT_DBL_CLICK_DIST", "150"); - // ensure the current dir matches the exectuable's directory - std::error_code ec; - std::filesystem::current_path(executableDir(), ec); -} - -QPixmap bootstrapPixmap(const QString &id) { - QPixmap pixmap; - const std::string svg = utils::bootstrapSvg(id.toStdString()); - if (!svg.empty()) { - pixmap.loadFromData((const uchar *)svg.data(), svg.size(), "svg"); - } - return pixmap; -} diff --git a/openpilot/tools/cabana/utils/qtutil.h b/openpilot/tools/cabana/utils/qtutil.h deleted file mode 100644 index db0417ec9a..0000000000 --- a/openpilot/tools/cabana/utils/qtutil.h +++ /dev/null @@ -1,139 +0,0 @@ -#pragma once - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/core/observable.h" -#include "tools/cabana/dbc/dbc.h" -#include "tools/cabana/settings.h" -#include "tools/cabana/utils/strings.h" -#include "tools/cabana/utils/util.h" - -// needed by QVariant::fromValue() in the Qt views; goes away with QVariant -Q_DECLARE_METATYPE(MessageId) -Q_DECLARE_METATYPE(ValueDescription) - -inline QColor toQColor(const CabanaColor &color) { - return QColor(color.r, color.g, color.b, color.a); -} - -class LogSlider : public QSlider { - Q_OBJECT - -public: - LogSlider(double factor, Qt::Orientation orientation, QWidget *parent = nullptr) : scale(factor), QSlider(orientation, parent) {} - - void setRange(double min, double max) { - scale.setRange(min, max); - QSlider::setRange(min, max); - setValue(QSlider::value()); - } - int value() const { return scale.value(QSlider::value(), minimum(), maximum()); } - void setValue(int v) { QSlider::setValue(scale.position(v, minimum(), maximum())); } - -private: - LogScale scale; -}; - -enum { - ColorsRole = Qt::UserRole + 1, - BytesRole = Qt::UserRole + 2 -}; - -class MessageBytesDelegate : public QStyledItemDelegate { - Q_OBJECT -public: - MessageBytesDelegate(QObject *parent, bool multiple_lines = false); - void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; - bool multipleLines() const { return multiple_lines; } - void setMultipleLines(bool v) { multiple_lines = v; } - QSize sizeForBytes(int n) const; - -private: - std::array hex_text_table; - QFontMetrics font_metrics; - QFont fixed_font; - QSize byte_size = {}; - bool multiple_lines = false; - int h_margin, v_margin; -}; - -// QValidator wrappers around the std::string validators in util.h -#define CABANA_VALIDATOR(Name) \ - class Name : public QValidator { \ - Q_OBJECT \ - public: \ - Name(QObject *parent = nullptr) : QValidator(parent) {} \ - QValidator::State validate(QString &input, int &pos) const override; \ - }; -CABANA_VALIDATOR(NameValidator) -CABANA_VALIDATOR(NodeValidator) -CABANA_VALIDATOR(NonWhitespaceValidator) -CABANA_VALIDATOR(IpAddressValidator) -CABANA_VALIDATOR(DoubleValidator) -#undef CABANA_VALIDATOR - -namespace utils { - -QPixmap icon(const QString &id); -bool isDarkTheme(); -void setTheme(int theme); -inline void drawStaticText(QPainter *p, const QRect &r, const QStaticText &text) { - auto size = (r.size() - text.size()) / 2; - p->drawStaticText(r.left() + size.width(), r.top() + size.height(), text); -} -inline auto qbytes(const std::vector &dat) { - return decltype(QString().toUtf8())((const char *)dat.data(), (int)dat.size()); -} - -} - -class ToolButton : public QToolButton { - Q_OBJECT -public: - ToolButton(const QString &icon, const QString &tooltip = {}, QWidget *parent = nullptr) : QToolButton(parent) { - setIcon(icon); - setToolTip(tooltip); - setAutoRaise(true); - const int metric = QApplication::style()->pixelMetric(QStyle::PM_SmallIconSize); - setIconSize({metric, metric}); - theme = settings.theme; - settings_connection_ = settings.changed.connect([this]() { updateIcon(); }); - } - void setIcon(const QString &icon) { - icon_str = icon; - QToolButton::setIcon(utils::icon(icon_str)); - } - -private: - void updateIcon() { if (std::exchange(theme, settings.theme) != theme) setIcon(icon_str); } - Connection settings_connection_; - QString icon_str; - int theme; -}; - -class TabBar : public QTabBar { - Q_OBJECT - -public: - TabBar(QWidget *parent) : QTabBar(parent) {} - int addTab(const QString &text); - -private: - void closeTabClicked(); -}; - -void initApp(int argc, char *argv[], bool disable_hidpi = true); -QPixmap bootstrapPixmap(const QString &id); diff --git a/openpilot/tools/cabana/videowidget.cc b/openpilot/tools/cabana/videowidget.cc deleted file mode 100644 index f7b1b41628..0000000000 --- a/openpilot/tools/cabana/videowidget.cc +++ /dev/null @@ -1,434 +0,0 @@ -#include "tools/cabana/videowidget.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/tools/routeinfo.h" -#include "tools/cabana/utils/qtutil.h" - -const int MIN_VIDEO_HEIGHT = 100; -const int THUMBNAIL_MARGIN = 3; - -// Indexed by TimelineType: None, Engaged, AlertInfo, AlertWarning, AlertCritical, UserBookmark -static const QColor timeline_colors[] = { - QColor(111, 143, 175), - QColor(0, 163, 108), - Qt::green, - QColor(255, 195, 0), - QColor(199, 0, 57), - Qt::magenta, -}; - -static Replay *getReplay() { - auto stream = dynamic_cast(can); - return stream ? stream->getReplay() : nullptr; -} - -VideoWidget::VideoWidget(QWidget *parent) : QFrame(parent) { - setFrameStyle(QFrame::StyledPanel | QFrame::Plain); - auto main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - main_layout->setSpacing(0); - if (!can->liveStreaming()) - main_layout->addWidget(createCameraWidget()); - - createPlaybackController(); - - setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum); - connections_.push_back(can->paused.connect([this]() { updatePlayBtnState(); })); - connections_.push_back(can->resume.connect([this]() { updatePlayBtnState(); })); - connections_.push_back(can->msgsReceived.connect([this](const std::set *, bool) { updateState(); })); - connections_.push_back(can->seeking.connect([this](double) { updateState(); })); - connections_.push_back(can->timeRangeChanged.connect([this](const auto &) { timeRangeChanged(); })); - - updatePlayBtnState(); - setWhatsThis(tr(R"( - Video
- - Timeline color - - - - - - - -
Disengaged Engaged
User Flag Info
Warning Critical
- Shortcuts
- Pause/Resume:  space  - )").arg(timeline_colors[(int)TimelineType::None].name(), - timeline_colors[(int)TimelineType::Engaged].name(), - timeline_colors[(int)TimelineType::UserBookmark].name(), - timeline_colors[(int)TimelineType::AlertInfo].name(), - timeline_colors[(int)TimelineType::AlertWarning].name(), - timeline_colors[(int)TimelineType::AlertCritical].name())); -} - -void VideoWidget::createPlaybackController() { - QToolBar *toolbar = new QToolBar(this); - layout()->addWidget(toolbar); - - int icon_size = style()->pixelMetric(QStyle::PM_SmallIconSize); - toolbar->setIconSize({icon_size, icon_size}); - - toolbar->addAction(utils::icon("rewind"), tr("Seek backward"), []() { can->seekTo(can->currentSec() - 1); }); - play_toggle_action = toolbar->addAction(utils::icon("play"), tr("Play"), []() { can->pause(!can->isPaused()); }); - toolbar->addAction(utils::icon("fast-forward"), tr("Seek forward"), []() { can->seekTo(can->currentSec() + 1); }); - - if (can->liveStreaming()) { - skip_to_end_action = toolbar->addAction(utils::icon("skip-end"), tr("Skip to the end"), this, [this]() { - // set speed to 1.0 - speed_btn->menu()->actions()[7]->setChecked(true); - can->pause(false); - can->seekTo(can->maxSeconds() + 1); - }); - } - - time_display_action = toolbar->addAction("", this, [this]() { - settings.absolute_time = !settings.absolute_time; - time_display_action->setToolTip(settings.absolute_time ? tr("Elapsed time") : tr("Absolute time")); - updateState(); - }); - - QWidget *spacer = new QWidget(); - spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); - toolbar->addWidget(spacer); - - if (!can->liveStreaming()) { - toolbar->addAction(utils::icon("repeat"), tr("Loop playback"), this, &VideoWidget::loopPlaybackClicked); - createSpeedDropdown(toolbar); - toolbar->addSeparator(); - toolbar->addAction(utils::icon("info-circle"), tr("View route details"), this, &VideoWidget::showRouteInfo); - } else { - createSpeedDropdown(toolbar); - } -} - -void VideoWidget::createSpeedDropdown(QToolBar *toolbar) { - toolbar->addWidget(speed_btn = new QToolButton(this)); - speed_btn->setMenu(new QMenu(speed_btn)); - speed_btn->setPopupMode(QToolButton::InstantPopup); - QActionGroup *speed_group = new QActionGroup(this); - speed_group->setExclusive(true); - - for (float speed : {0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 0.8, 1., 2., 3., 5.}) { - auto act = speed_btn->menu()->addAction(QString("%1x").arg(speed), this, [this, speed]() { - can->setSpeed(speed); - speed_btn->setText(QString("%1x ").arg(speed)); - }); - - speed_group->addAction(act); - act->setCheckable(true); - if (speed == 1.0) { - act->setChecked(true); - act->trigger(); - } - } - - QFont font = speed_btn->font(); - font.setBold(true); - speed_btn->setFont(font); - speed_btn->setMinimumWidth(speed_btn->fontMetrics().horizontalAdvance("0.05x ") + style()->pixelMetric(QStyle::PM_MenuButtonIndicator)); -} - -QWidget *VideoWidget::createCameraWidget() { - QWidget *w = new QWidget(this); - QVBoxLayout *l = new QVBoxLayout(w); - l->setContentsMargins(0, 0, 0, 0); - l->setSpacing(0); - - l->addWidget(camera_tab = new TabBar(w)); - camera_tab->setAutoHide(true); - camera_tab->setExpanding(false); - - l->addWidget(cam_widget = new StreamCameraView("camerad", VISION_STREAM_NARROW_ROAD)); - cam_widget->setMinimumHeight(MIN_VIDEO_HEIGHT); - cam_widget->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::MinimumExpanding); - - l->addWidget(slider = new Slider(w)); - slider->setSingleStep(0); - slider->setTimeRange(can->minSeconds(), can->maxSeconds()); - - QObject::connect(slider, &QSlider::sliderReleased, [this]() { can->seekTo(slider->currentSecond()); }); - connections_.push_back(can->paused.connect([this]() { cam_widget->update(); })); - connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &) { slider->update(); })); - connections_.push_back(cam_widget->clicked.connect([]() { can->pause(!can->isPaused()); })); - connections_.push_back(cam_widget->availableStreamsUpdated.connect([this](std::set streams) { vipcAvailableStreamsUpdated(streams); })); - QObject::connect(camera_tab, &QTabBar::currentChanged, [this](int index) { - if (index != -1) cam_widget->setStreamType((VisionStreamType)camera_tab->tabData(index).toInt()); - }); - connections_.push_back(static_cast(can)->qLogLoaded.connect([this](std::shared_ptr qlog) { cam_widget->parseQLog(qlog); })); - slider->installEventFilter(this); - return w; -} - -void VideoWidget::vipcAvailableStreamsUpdated(std::set streams) { - static const QString stream_names[] = {"Road camera", "Driver camera", "Wide road camera"}; - for (int i = 0; i < streams.size(); ++i) { - if (camera_tab->count() <= i) { - camera_tab->addTab(QString()); - } - int type = *std::next(streams.begin(), i); - camera_tab->setTabText(i, stream_names[type]); - camera_tab->setTabData(i, type); - } - while (camera_tab->count() > streams.size()) { - camera_tab->removeTab(camera_tab->count() - 1); - } -} - -void VideoWidget::loopPlaybackClicked() { - bool is_looping = getReplay()->loop(); - getReplay()->setLoop(!is_looping); - qobject_cast(sender())->setIcon(utils::icon(!is_looping ? "repeat" : "repeat-1")); -} - -void VideoWidget::timeRangeChanged() { - const auto time_range = can->timeRange(); - if (can->liveStreaming()) { - skip_to_end_action->setEnabled(!time_range.has_value()); - return; - } - time_range ? slider->setTimeRange(time_range->first, time_range->second) - : slider->setTimeRange(can->minSeconds(), can->maxSeconds()); - updateState(); -} - -QString VideoWidget::formatTime(double sec, bool include_milliseconds) { - if (settings.absolute_time) - sec += std::chrono::duration(can->beginDateTime().time_since_epoch()).count(); - return QString::fromStdString(utils::formatSeconds(sec, include_milliseconds, settings.absolute_time)); -} - -void VideoWidget::updateState() { - if (slider) { - if (!slider->isSliderDown()) { - slider->setCurrentSecond(can->currentSec()); - } - if (camera_tab->count() == 0) { // No streams available - cam_widget->update(); // Manually refresh to show alert events - } - time_display_action->setText(QString("%1 / %2").arg(formatTime(can->currentSec(), true), - formatTime(slider->maximum() / slider->factor))); - } else { - time_display_action->setText(formatTime(can->currentSec(), true)); - } -} - -void VideoWidget::updatePlayBtnState() { - play_toggle_action->setIcon(utils::icon(can->isPaused() ? "play" : "pause")); - play_toggle_action->setToolTip(can->isPaused() ? tr("Play") : tr("Pause")); -} - -void VideoWidget::showThumbnail(double seconds) { - if (can->liveStreaming()) return; - - cam_widget->thumbnail_dispaly_time = seconds; - slider->thumbnail_dispaly_time = seconds; - cam_widget->update(); - slider->update(); -} - -void VideoWidget::showRouteInfo() { - RouteInfoDlg *route_info = new RouteInfoDlg(this); - route_info->setAttribute(Qt::WA_DeleteOnClose); - route_info->show(); -} - -bool VideoWidget::eventFilter(QObject *obj, QEvent *event) { - if (event->type() == QEvent::MouseMove) { - auto [min_sec, max_sec] = can->timeRange().value_or(std::make_pair(can->minSeconds(), can->maxSeconds())); - showThumbnail(min_sec + static_cast(event)->pos().x() * (max_sec - min_sec) / slider->width()); - } else if (event->type() == QEvent::Leave) { - showThumbnail(-1); - } - return false; -} - -// Slider -Slider::Slider(QWidget *parent) : QSlider(Qt::Horizontal, parent) { - setMouseTracking(true); -} - -void Slider::paintEvent(QPaintEvent *ev) { - QPainter p(this); - - QStyleOptionSlider opt; - initStyleOption(&opt); - QRect handle_rect = style()->subControlRect(QStyle::CC_Slider, &opt, QStyle::SC_SliderHandle, this); - QRect groove_rect = style()->subControlRect(QStyle::CC_Slider, &opt, QStyle::SC_SliderGroove, this); - - // Adjust groove height to match handle height - int handle_height = handle_rect.height(); - groove_rect.setHeight(handle_height * 0.5); - groove_rect.moveCenter(QPoint(groove_rect.center().x(), rect().center().y())); - - p.fillRect(groove_rect, timeline_colors[(int)TimelineType::None]); - - double min = minimum() / factor; - double max = maximum() / factor; - - auto fillRange = [&](double begin, double end, const QColor &color) { - if (begin > max || end < min) return; - - QRect r = groove_rect; - r.setLeft(((std::max(min, begin) - min) / (max - min)) * width()); - r.setRight(((std::min(max, end) - min) / (max - min)) * width()); - p.fillRect(r, color); - }; - - if (auto replay = getReplay()) { - for (const auto &entry : *replay->getTimeline()) { - fillRange(entry.start_time, entry.end_time, timeline_colors[(int)entry.type]); - } - - QColor empty_color = palette().color(QPalette::Window); - empty_color.setAlpha(160); - const auto event_data = replay->getEventData(); - for (const auto &[n, _] : replay->route().segments()) { - if (!event_data->isSegmentLoaded(n)) - fillRange(n * 60.0, (n + 1) * 60.0, empty_color); - } - } - - opt.minimum = minimum(); - opt.maximum = maximum(); - opt.subControls = QStyle::SC_SliderHandle; - opt.sliderPosition = value(); - style()->drawComplexControl(QStyle::CC_Slider, &opt, &p); - - if (thumbnail_dispaly_time >= 0) { - int left = (thumbnail_dispaly_time - min) * width() / (max - min) - 1; - QRect rc(left, rect().top() + 1, 2, rect().height() - 2); - p.setBrush(palette().highlight()); - p.setPen(Qt::NoPen); - p.drawRoundedRect(rc, 1.5, 1.5); - } -} - -void Slider::mousePressEvent(QMouseEvent *e) { - QSlider::mousePressEvent(e); - if (e->button() == Qt::LeftButton && !isSliderDown()) { - setValue(minimum() + ((maximum() - minimum()) * e->x()) / width()); - emit sliderReleased(); - } -} - -// StreamCameraView -StreamCameraView::StreamCameraView(std::string stream_name, VisionStreamType stream_type, QWidget *parent) - : CameraWidget(stream_name, stream_type, parent) { -} - -void StreamCameraView::parseQLog(std::shared_ptr qlog) { - std::mutex mutex; - const auto &events = qlog->events; - unsigned int num_threads = std::max(1u, std::thread::hardware_concurrency()); - size_t chunk = (events.size() + num_threads - 1) / num_threads; - std::vector threads; - for (unsigned int t = 0; t < num_threads && t * chunk < events.size(); ++t) { - size_t start = t * chunk; - size_t end = std::min(start + chunk, events.size()); - threads.emplace_back([this, &mutex, &events, start, end]() { - for (size_t i = start; i < end; ++i) { - const Event &e = events[i]; - if (e.which == cereal::Event::Which::THUMBNAIL) { - capnp::FlatArrayMessageReader reader(e.data); - auto thumb_data = reader.getRoot().getThumbnail(); - auto image_data = thumb_data.getThumbnail(); - if (QPixmap thumb; thumb.loadFromData(image_data.begin(), image_data.size(), "jpeg")) { - QPixmap generated_thumb = generateThumbnail(thumb, can->toSeconds(thumb_data.getTimestampEof())); - std::lock_guard lock(mutex); - thumbnails[thumb_data.getTimestampEof()] = generated_thumb; - big_thumbnails[thumb_data.getTimestampEof()] = thumb; - } - } - } - }); - } - for (auto &th : threads) th.join(); - update(); -} - -void StreamCameraView::paintEvent(QPaintEvent *event) { - CameraWidget::paintEvent(event); - - QPainter p(this); - bool scrubbing = false; - if (thumbnail_dispaly_time >= 0) { - scrubbing = can->isPaused(); - scrubbing ? drawScrubThumbnail(p) : drawThumbnail(p); - } - if (auto alert = getReplay()->findAlertAtTime(scrubbing ? thumbnail_dispaly_time : can->currentSec())) { - drawAlert(p, rect(), *alert); - } - - if (can->isPaused()) { - p.setPen(QColor(200, 200, 200, static_cast(255 * 0.7f))); - p.setFont(QFont(font().family(), 16, QFont::Bold)); - p.drawText(rect(), Qt::AlignCenter, tr("PAUSED")); - } -} - -QPixmap StreamCameraView::generateThumbnail(QPixmap thumb, double seconds) { - QPixmap scaled = thumb.scaledToHeight(MIN_VIDEO_HEIGHT - THUMBNAIL_MARGIN * 2, Qt::SmoothTransformation); - QPainter p(&scaled); - p.setPen(QPen(palette().color(QPalette::BrightText), 2)); - p.drawRect(scaled.rect()); - if (auto alert = getReplay()->findAlertAtTime(seconds)) { - p.setFont(QFont(font().family(), 10)); - drawAlert(p, scaled.rect(), *alert); - } - return scaled; -} - -void StreamCameraView::drawScrubThumbnail(QPainter &p) { - p.fillRect(rect(), Qt::black); - auto it = big_thumbnails.lower_bound(can->toMonoTime(thumbnail_dispaly_time)); - if (it != big_thumbnails.end()) { - QPixmap scaled_thumb = it->second.scaled(rect().size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); - QRect thumb_rect(rect().center() - scaled_thumb.rect().center(), scaled_thumb.size()); - p.drawPixmap(thumb_rect.topLeft(), scaled_thumb); - drawTime(p, thumb_rect, thumbnail_dispaly_time); - } -} - -void StreamCameraView::drawThumbnail(QPainter &p) { - auto it = thumbnails.lower_bound(can->toMonoTime(thumbnail_dispaly_time)); - if (it != thumbnails.end()) { - const QPixmap &thumb = it->second; - auto [min_sec, max_sec] = can->timeRange().value_or(std::make_pair(can->minSeconds(), can->maxSeconds())); - int pos = (thumbnail_dispaly_time - min_sec) * width() / (max_sec - min_sec); - int x = std::clamp(pos - thumb.width() / 2, THUMBNAIL_MARGIN, width() - thumb.width() - THUMBNAIL_MARGIN + 1); - int y = height() - thumb.height() - THUMBNAIL_MARGIN; - - p.drawPixmap(x, y, thumb); - drawTime(p, QRect{x, y, thumb.width(), thumb.height()}, thumbnail_dispaly_time); - } -} - -void StreamCameraView::drawTime(QPainter &p, const QRect &rect, double seconds) { - p.setPen(palette().color(QPalette::BrightText)); - p.setFont(QFont(font().family(), 10)); - p.drawText(rect.adjusted(0, 0, 0, -THUMBNAIL_MARGIN), Qt::AlignHCenter | Qt::AlignBottom, QString::number(seconds, 'f', 3)); -} - -void StreamCameraView::drawAlert(QPainter &p, const QRect &rect, const Timeline::Entry &alert) { - p.setPen(QPen(palette().color(QPalette::BrightText), 2)); - QColor color = timeline_colors[int(alert.type)]; - color.setAlphaF(0.5); - QString text = QString::fromStdString(alert.text1); - if (!alert.text2.empty()) text += "\n" + QString::fromStdString(alert.text2); - - QRect text_rect = rect.adjusted(1, 1, -1, -1); - QRect r = p.fontMetrics().boundingRect(text_rect, Qt::AlignTop | Qt::AlignHCenter | Qt::TextWordWrap, text); - p.fillRect(text_rect.left(), r.top(), text_rect.width(), r.height(), color); - p.drawText(text_rect, Qt::AlignTop | Qt::AlignHCenter | Qt::TextWordWrap, text); -} diff --git a/openpilot/tools/cabana/videowidget.h b/openpilot/tools/cabana/videowidget.h deleted file mode 100644 index 8416eece59..0000000000 --- a/openpilot/tools/cabana/videowidget.h +++ /dev/null @@ -1,82 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -#include "tools/cabana/cameraview.h" -#include "tools/cabana/utils/qtutil.h" -#include "tools/replay/logreader.h" -#include "tools/cabana/streams/replaystream.h" - -class Slider : public QSlider { - Q_OBJECT - -public: - Slider(QWidget *parent); - double currentSecond() const { return value() / factor; } - void setCurrentSecond(double sec) { setValue(sec * factor); } - void setTimeRange(double min, double max) { setRange(min * factor, max * factor); } - void mousePressEvent(QMouseEvent *e) override; - void paintEvent(QPaintEvent *ev) override; - const double factor = 1000.0; - double thumbnail_dispaly_time = -1; -}; - -class StreamCameraView : public CameraWidget { - Q_OBJECT - -public: - StreamCameraView(std::string stream_name, VisionStreamType stream_type, QWidget *parent = nullptr); - void paintEvent(QPaintEvent *event) override; - void parseQLog(std::shared_ptr qlog); - -private: - QPixmap generateThumbnail(QPixmap thumbnail, double seconds); - void drawAlert(QPainter &p, const QRect &rect, const Timeline::Entry &alert); - void drawThumbnail(QPainter &p); - void drawScrubThumbnail(QPainter &p); - void drawTime(QPainter &p, const QRect &rect, double seconds); - - std::map big_thumbnails; - std::map thumbnails; - double thumbnail_dispaly_time = -1; - friend class VideoWidget; -}; - -class VideoWidget : public QFrame { - Q_OBJECT - -public: - VideoWidget(QWidget *parnet = nullptr); - void showThumbnail(double seconds); - -protected: - bool eventFilter(QObject *obj, QEvent *event) override; - QString formatTime(double sec, bool include_milliseconds = false); - void timeRangeChanged(); - void updateState(); - void updatePlayBtnState(); - Connections connections_; - QWidget *createCameraWidget(); - void createPlaybackController(); - void createSpeedDropdown(QToolBar *toolbar); - void loopPlaybackClicked(); - void vipcAvailableStreamsUpdated(std::set streams); - void showRouteInfo(); - - StreamCameraView *cam_widget; - QAction *time_display_action = nullptr; - QAction *play_toggle_action = nullptr; - QToolButton *speed_btn = nullptr; - QAction *skip_to_end_action = nullptr; - Slider *slider = nullptr; - QTabBar *camera_tab = nullptr; -}; diff --git a/pyproject.toml b/pyproject.toml index c1dd803640..78fb53ed1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "tqdm", # cars (fw_versions.py) on start + many one-off uses # core - "scons==4.10.1", # 4.11 removed the qt3 tool still used to build Cabana + "scons", "pycapnp==2.1.0", # 2.2 introduces a memory leak due to cyclic references "numpy >=2.0", diff --git a/tools/op.sh b/tools/op.sh index 43b28e34c1..3d7d17a76b 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -445,7 +445,7 @@ function op_default() { echo -e "${BOLD}${UNDERLINE}Commands [Tooling]:${NC}" echo -e " ${BOLD}juggle${NC} Run PlotJuggler" echo -e " ${BOLD}replay${NC} Run Replay" - echo -e " ${BOLD}cabana${NC} Run Cabana (--legacy for the old Qt version)" + echo -e " ${BOLD}cabana${NC} Run Cabana" echo -e " ${BOLD}clip${NC} Run clip (linux only)" echo -e " ${BOLD}adb${NC} Run adb shell" echo -e " ${BOLD}ssh${NC} comma prime SSH helper" diff --git a/uv.lock b/uv.lock index 89b82b5452..dc9cb69845 100644 --- a/uv.lock +++ b/uv.lock @@ -675,7 +675,7 @@ requires-dist = [ { name = "rednose", marker = "extra == 'submodules'", editable = "rednose_repo" }, { name = "requests" }, { name = "ruff", marker = "extra == 'testing'" }, - { name = "scons", specifier = "==4.10.1" }, + { name = "scons" }, { name = "sentry-sdk" }, { name = "setproctitle" }, { name = "sounddevice" }, @@ -923,11 +923,11 @@ wheels = [ [[package]] name = "scons" -version = "4.10.1" +version = "4.11.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/c9/2f430bb39e4eccba32ce8008df4a3206df651276422204e177a09e12b30b/scons-4.10.1.tar.gz", hash = "sha256:99c0e94a42a2c1182fa6859b0be697953db07ba936ecc9817ae0d218ced20b15", size = 3258403, upload-time = "2025-11-16T22:43:39.258Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/b9/b7a5c88f348a0c34594d88100872c55fa1cae863ccb222c1c438341b5503/scons-4.11.1.tar.gz", hash = "sha256:4210d1a80a62e986029208117991b6347ccaaaab37b67463a3ff31ee065dc487", size = 3268963, upload-time = "2026-08-27T04:33:18.232Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/bf/931fb9fbb87234c32b8b1b1c15fba23472a10777c12043336675633809a7/scons-4.10.1-py3-none-any.whl", hash = "sha256:bd9d1c52f908d874eba92a8c0c0a8dcf2ed9f3b88ab956d0fce1da479c4e7126", size = 4136069, upload-time = "2025-11-16T22:43:35.933Z" }, + { url = "https://files.pythonhosted.org/packages/8e/43/d6285848e893c19682c06e92679dc1a07d37ff7ea148747b1df681ec496c/scons-4.11.1-py3-none-any.whl", hash = "sha256:454cef364348053422696e3d2ecb4fa593c96a624f955842eaaea64f95c8d11d", size = 4123659, upload-time = "2026-08-27T04:33:12.728Z" }, ] [[package]] @@ -1034,7 +1034,7 @@ provides-extras = ["dev"] [[package]] name = "tinygrad" -version = "0.13.0" +version = "0.14.0" source = { editable = "tinygrad_repo" } [package.metadata] @@ -1048,6 +1048,7 @@ requires-dist = [ { name = "hypothesis", marker = "extra == 'testing-minimal'", specifier = ">=6.148.9" }, { name = "influxdb3-python", marker = "extra == 'testing'" }, { name = "librosa", marker = "extra == 'testing'" }, + { name = "mako", marker = "extra == 'autogen'" }, { name = "markdown-callouts", marker = "extra == 'docs'" }, { name = "markdown-exec", extras = ["ansi"], marker = "extra == 'docs'" }, { name = "mkdocs", marker = "extra == 'docs'" }, @@ -1062,7 +1063,7 @@ requires-dist = [ { name = "numpy", marker = "extra == 'testing-minimal'" }, { name = "onnx", marker = "extra == 'testing'", specifier = "==1.19.0" }, { name = "onnx2torch", marker = "extra == 'testing'" }, - { name = "onnxruntime", marker = "extra == 'testing'" }, + { name = "onnxruntime", marker = "extra == 'testing'", specifier = "==1.24.1" }, { name = "openai", marker = "extra == 'testing-unit'" }, { name = "opencv-python", marker = "extra == 'testing'" }, { name = "pandas", marker = "extra == 'testing'" }, @@ -1073,6 +1074,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'testing-minimal'" }, { name = "pytest-split", marker = "extra == 'testing-minimal'" }, { name = "pytest-xdist", marker = "extra == 'testing-minimal'" }, + { name = "pyyaml", marker = "extra == 'autogen'" }, { name = "ruff", marker = "extra == 'linting'", specifier = "==0.14.10" }, { name = "safetensors", marker = "extra == 'testing-unit'" }, { name = "sentencepiece", marker = "extra == 'testing'" }, @@ -1088,7 +1090,7 @@ requires-dist = [ { name = "typing-extensions", marker = "extra == 'linting'" }, { name = "z3-solver", marker = "extra == 'testing-minimal'", specifier = "<4.15.4" }, ] -provides-extras = ["linting", "testing-minimal", "testing-unit", "testing", "docs", "mesa"] +provides-extras = ["linting", "testing-minimal", "testing-unit", "testing", "docs", "mesa", "autogen"] [[package]] name = "tqdm" From e3def37695339d5d2f5e76e815ebb7570bcfdbd5 Mon Sep 17 00:00:00 2001 From: YassineYousfi Date: Thu, 3 Sep 2026 09:45:51 -0700 Subject: [PATCH 006/122] revert 38742 (#38760) --- openpilot/common/params_keys.h | 1 - openpilot/selfdrive/modeld/helpers.py | 6 ------ openpilot/selfdrive/modeld/modeld.py | 24 +++--------------------- 3 files changed, 3 insertions(+), 28 deletions(-) diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 07a2283ece..ba6eae3dd5 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -135,6 +135,5 @@ inline static std::unordered_map keys = { {"UptimeOnroad", {PERSISTENT, FLOAT, "0.0"}}, {"ChestnutActive", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, {"ChestnutLoading", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, - {"ChestnutModelError", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, {"Version", {PERSISTENT, STRING}}, }; diff --git a/openpilot/selfdrive/modeld/helpers.py b/openpilot/selfdrive/modeld/helpers.py index 23eb6cbd21..d081050055 100644 --- a/openpilot/selfdrive/modeld/helpers.py +++ b/openpilot/selfdrive/modeld/helpers.py @@ -11,8 +11,6 @@ from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, USB_DEVICES_PATH MODELS_DIR = Path(__file__).resolve().parent / 'models' TG_INPUT_DEVICES_PATH = MODELS_DIR / 'tg_input_devices.json' -CHESTNUT_POWERED_VOLTAGE = 5000 -CHESTNUT_PCIE_READY = 0x78 def get_tg_input_devices(process_name: str, chestnut: bool): @@ -60,7 +58,3 @@ def chestnut_present() -> bool: def chestnut_compiled() -> bool: return Path(get_manifest_path(modeld_pkl_path(chestnut=True))).is_file() - - -def chestnut_ready(state) -> bool: - return state.supplyVoltage >= CHESTNUT_POWERED_VOLTAGE and not state.supplyFault and state.pcieLtssm == CHESTNUT_PCIE_READY diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 040c6b980f..175c729782 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -33,7 +33,7 @@ from openpilot.selfdrive.modeld.fill_model_msg import fill_model_msg, fill_drivi from openpilot.common.file_chunker import open_file_chunked from openpilot.common.hardware.usb import CHESTNUT_USB_IDS from openpilot.selfdrive.modeld.constants import ModelConstants, Plan -from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled, chestnut_ready, modeld_pkl_path, load_oob +from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled, modeld_pkl_path, load_oob SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') @@ -237,25 +237,12 @@ class ModelState: def main(demo=False): cloudlog.warning("modeld init") - chestnut_available = chestnut_present() and chestnut_compiled() - CHESTNUT = False - if chestnut_available: - poller = messaging.Poller() - sock = messaging.sub_sock("chestnutState", poller=poller, conflate=True) - deadline = time.monotonic() + 4. / SERVICE_LIST['deviceState'].frequency - while not CHESTNUT and (remaining := deadline - time.monotonic()) > 0.: - if not poller.poll(round(remaining * 1000)): - break - msg = messaging.recv_one_or_none(sock) - CHESTNUT = msg is not None and msg.valid and chestnut_ready(msg.chestnutState) + CHESTNUT = chestnut_present() and chestnut_compiled() if CHESTNUT: os.environ['HCQDEV_WAIT_TIMEOUT_MS'] = '3000' params = Params() params.put_bool("ChestnutLoading", CHESTNUT) - if chestnut_available and not CHESTNUT: - params.put_bool("ChestnutActive", False) - else: - params.remove("ChestnutActive") + params.remove("ChestnutActive") config_realtime_process(7, 54) @@ -299,11 +286,7 @@ def main(demo=False): loader.start() loader.join(BIG_MODEL_TIMEOUT) model = big_model - if model is None: - params.put_bool("ChestnutModelError", True) params.put_bool("ChestnutActive", model is not None) - if model is not None: - params.remove("ChestnutModelError") small_model = ModelState(vipc_client_main.width, vipc_client_main.height, False) if model is None or CHESTNUT else None if model is None: @@ -434,7 +417,6 @@ def main(demo=False): raise # fallback to small model cloudlog.exception("big model failed, fall back to small") - params.put_bool("ChestnutModelError", True) params.put_bool("ChestnutActive", False) model = small_model if chestnut_state is not None: From 675ff569818fdd3f0cb76cdf6c0ea1664869504b Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Fri, 4 Sep 2026 15:20:43 -0700 Subject: [PATCH 007/122] tools: add op docs command (#38770) --- .github/workflows/docs.yaml | 8 ++------ docs/README.md | 4 ++-- tools/op.sh | 7 +++++++ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index f4a8e7cb89..c7229190e7 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -26,16 +26,12 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: commaai/timeout@v1 - - uses: actions/checkout@v7 - with: - submodules: true + - run: ./tools/op.sh setup # Build - name: Build docs - run: | - git lfs pull - python docs/serve.py --build + run: ./tools/op.sh docs --build # Push to docs.comma.ai - uses: actions/checkout@v7 diff --git a/docs/README.md b/docs/README.md index d6a0126b39..dc6eee6905 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,10 +5,10 @@ The site is updated on pushes to master by this [workflow](../.github/workflows/ **1. Build the site** ``` bash -python docs/serve.py --build +op docs --build ``` **2. Run the site locally** (rebuilds on change) ``` bash -python docs/serve.py +op docs ``` diff --git a/tools/op.sh b/tools/op.sh index 3d7d17a76b..43c88a2a86 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -349,6 +349,11 @@ function op_clip() { op_run_command openpilot/tools/clip/run.py "$@" } +function op_docs() { + op_before_cmd + op_run_command python docs/serve.py "$@" +} + function op_check_agnos_update() { if [[ ! -f "/AGNOS" ]]; then return 0 @@ -447,6 +452,7 @@ function op_default() { echo -e " ${BOLD}replay${NC} Run Replay" echo -e " ${BOLD}cabana${NC} Run Cabana" echo -e " ${BOLD}clip${NC} Run clip (linux only)" + echo -e " ${BOLD}docs${NC} Build or serve the openpilot documentation" echo -e " ${BOLD}adb${NC} Run adb shell" echo -e " ${BOLD}ssh${NC} comma prime SSH helper" echo "" @@ -502,6 +508,7 @@ function _op() { test ) shift 1; op_test "$@" ;; replay ) shift 1; op_replay "$@" ;; clip ) shift 1; op_clip "$@" ;; + docs ) shift 1; op_docs "$@" ;; sim ) shift 1; op_sim "$@" ;; switch ) shift 1; op_switch "$@" ;; start ) shift 1; op_start "$@" ;; From 33790c855c7eac1c825a6b9f71cf9f58aa3462eb Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:30:45 -0700 Subject: [PATCH 008/122] op: support all the linuxes! (#38773) --- tools/op.sh | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/tools/op.sh b/tools/op.sh index 43c88a2a86..d4a0263973 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -123,23 +123,7 @@ function op_check_git() { function op_check_os() { echo "Checking for compatible os version..." if [[ "$OSTYPE" == "linux-gnu"* ]]; then - - if [ -f "/etc/os-release" ]; then - source /etc/os-release - case "$VERSION_CODENAME" in - "jammy" | "kinetic" | "noble" | "focal") - echo -e " ↳ [${GREEN}✔${NC}] Ubuntu $VERSION_CODENAME detected." - ;; - * ) - echo -e " ↳ [${RED}✗${NC}] Incompatible Ubuntu version $VERSION_CODENAME detected!" - return 1 - ;; - esac - else - echo -e " ↳ [${RED}✗${NC}] No /etc/os-release on your system. Make sure you're running on Ubuntu, or similar!" - return 1 - fi - + echo -e " ↳ [${GREEN}✔${NC}] Linux detected." elif [[ "$OSTYPE" == "darwin"* ]]; then echo -e " ↳ [${GREEN}✔${NC}] macOS detected." else @@ -222,6 +206,7 @@ EOF echo "Pulling git lfs files..." st="$(date +%s)" + git lfs install --local if ! retry 3 git lfs pull; then echo -e " ↳ [${RED}✗${NC}] Pulling git lfs files failed!" return 1 From 69ad376fefa74ba0335e607f2174309b9285ccc6 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:09:45 -0700 Subject: [PATCH 009/122] Revert "op: support all the linuxes!" (#38774) Revert "op: support all the linuxes! (#38773)" This reverts commit 33790c855c7eac1c825a6b9f71cf9f58aa3462eb. --- tools/op.sh | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/tools/op.sh b/tools/op.sh index d4a0263973..43c88a2a86 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -123,7 +123,23 @@ function op_check_git() { function op_check_os() { echo "Checking for compatible os version..." if [[ "$OSTYPE" == "linux-gnu"* ]]; then - echo -e " ↳ [${GREEN}✔${NC}] Linux detected." + + if [ -f "/etc/os-release" ]; then + source /etc/os-release + case "$VERSION_CODENAME" in + "jammy" | "kinetic" | "noble" | "focal") + echo -e " ↳ [${GREEN}✔${NC}] Ubuntu $VERSION_CODENAME detected." + ;; + * ) + echo -e " ↳ [${RED}✗${NC}] Incompatible Ubuntu version $VERSION_CODENAME detected!" + return 1 + ;; + esac + else + echo -e " ↳ [${RED}✗${NC}] No /etc/os-release on your system. Make sure you're running on Ubuntu, or similar!" + return 1 + fi + elif [[ "$OSTYPE" == "darwin"* ]]; then echo -e " ↳ [${GREEN}✔${NC}] macOS detected." else @@ -206,7 +222,6 @@ EOF echo "Pulling git lfs files..." st="$(date +%s)" - git lfs install --local if ! retry 3 git lfs pull; then echo -e " ↳ [${RED}✗${NC}] Pulling git lfs files failed!" return 1 From c8603fb3ce3ec73411ee3abd8666f58a86ee3e30 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:09:40 -0700 Subject: [PATCH 010/122] op: point git-lfs config at the vendored binary (#38775) git-lfs is vendored in the venv, but `git lfs install` writes filters and hooks that invoke a bare `git-lfs`, which is only on PATH inside the venv. With filter.lfs.required set, any checkout from a plain shell fails. git runs filters and hooks from the worktree root, so a relative path into the venv resolves from anywhere. --- tools/op.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tools/op.sh b/tools/op.sh index 43c88a2a86..a77c386e49 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -222,6 +222,12 @@ EOF echo "Pulling git lfs files..." st="$(date +%s)" + git config --local filter.lfs.clean ".venv/bin/git-lfs clean -- %f" + git config --local filter.lfs.smudge ".venv/bin/git-lfs smudge -- %f" + git config --local filter.lfs.process ".venv/bin/git-lfs filter-process" + git config --local filter.lfs.required true + printf '#!/bin/sh\nexec .venv/bin/git-lfs pre-push "$@"\n' > "$(git rev-parse --git-path hooks)/pre-push" + chmod +x "$(git rev-parse --git-path hooks)/pre-push" if ! retry 3 git lfs pull; then echo -e " ↳ [${RED}✗${NC}] Pulling git lfs files failed!" return 1 From 0ec3a082c7ca3302c171b03ff5cd43be61309f13 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:10:04 -0700 Subject: [PATCH 011/122] op: support all the linuxes! (#38776) --- tools/op.sh | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/tools/op.sh b/tools/op.sh index a77c386e49..f17714e620 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -123,23 +123,7 @@ function op_check_git() { function op_check_os() { echo "Checking for compatible os version..." if [[ "$OSTYPE" == "linux-gnu"* ]]; then - - if [ -f "/etc/os-release" ]; then - source /etc/os-release - case "$VERSION_CODENAME" in - "jammy" | "kinetic" | "noble" | "focal") - echo -e " ↳ [${GREEN}✔${NC}] Ubuntu $VERSION_CODENAME detected." - ;; - * ) - echo -e " ↳ [${RED}✗${NC}] Incompatible Ubuntu version $VERSION_CODENAME detected!" - return 1 - ;; - esac - else - echo -e " ↳ [${RED}✗${NC}] No /etc/os-release on your system. Make sure you're running on Ubuntu, or similar!" - return 1 - fi - + echo -e " ↳ [${GREEN}✔${NC}] Linux detected." elif [[ "$OSTYPE" == "darwin"* ]]; then echo -e " ↳ [${GREEN}✔${NC}] macOS detected." else From f9dacd0d6bb60bb1372d913bf929d7db8d036658 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:45:12 -0700 Subject: [PATCH 012/122] cabana: build binary directly, drop wrapper script (#38781) --- openpilot/tools/cabana/.gitignore | 2 +- openpilot/tools/cabana/SConscript | 2 +- openpilot/tools/cabana/cabana | 10 ---------- openpilot/tools/cabana/tests/test_cabana_ui.py | 2 +- 4 files changed, 3 insertions(+), 13 deletions(-) delete mode 100755 openpilot/tools/cabana/cabana diff --git a/openpilot/tools/cabana/.gitignore b/openpilot/tools/cabana/.gitignore index 788b286372..c59f393869 100644 --- a/openpilot/tools/cabana/.gitignore +++ b/openpilot/tools/cabana/.gitignore @@ -1,5 +1,5 @@ bootstrap_icons.cc -_cabana_ui +cabana dbc/car_fingerprint_to_dbc.json tests/test_cabana diff --git a/openpilot/tools/cabana/SConscript b/openpilot/tools/cabana/SConscript index 81fd8877c4..0249a019bb 100644 --- a/openpilot/tools/cabana/SConscript +++ b/openpilot/tools/cabana/SConscript @@ -51,7 +51,7 @@ if arch == "Darwin": ui_env['FRAMEWORKS'] = ['OpenGL', 'Cocoa', 'IOKit', 'CoreFoundation', 'CoreVideo', 'CoreMedia', 'Security', 'VideoToolbox'] else: ui_libs += ['GL', 'dl'] -cabana_ui = ui_env.Program('_cabana_ui', ui_objs, LIBS=ui_libs) +cabana_ui = ui_env.Program('cabana', ui_objs, LIBS=ui_libs) if GetOption('extras'): cabana_core_test_env = env.Clone() diff --git a/openpilot/tools/cabana/cabana b/openpilot/tools/cabana/cabana deleted file mode 100755 index bdec614366..0000000000 --- a/openpilot/tools/cabana/cabana +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -set -e - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" -ROOT="$(cd "$DIR/../../../" && pwd)" - -cd "$ROOT" -scons -u "openpilot/tools/cabana/_cabana_ui" openpilot/cereal/messaging/bridge - -exec "$DIR/_cabana_ui" "$@" diff --git a/openpilot/tools/cabana/tests/test_cabana_ui.py b/openpilot/tools/cabana/tests/test_cabana_ui.py index 2cb0475041..aecd7e6ed7 100644 --- a/openpilot/tools/cabana/tests/test_cabana_ui.py +++ b/openpilot/tools/cabana/tests/test_cabana_ui.py @@ -8,6 +8,6 @@ CABANA_DIR = Path(__file__).parent.parent class TestCabanaUi(OpenpilotTestCase): def test_help(self): - result = subprocess.run(["./_cabana_ui", "-h"], cwd=CABANA_DIR, capture_output=True, text=True) + result = subprocess.run(["./cabana", "-h"], cwd=CABANA_DIR, capture_output=True, text=True) assert result.returncode == 0, result.stderr assert "Usage:" in result.stderr From a989bc0b50db714c9fa4f4bdbc789662c40291b6 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:13:15 -0700 Subject: [PATCH 013/122] cabana: misc UI tweaks (#38782) --- openpilot/tools/cabana/tests/test_cabana.cc | 6 ++-- openpilot/tools/cabana/ui/chart/chart.cc | 2 +- openpilot/tools/cabana/ui/icons.h | 32 +++++++++---------- openpilot/tools/cabana/ui/mainwin.cc | 3 +- openpilot/tools/cabana/ui/style.cc | 12 +++---- .../tools/cabana/ui/widgets/historylog.cc | 4 +-- .../tools/cabana/ui/widgets/videowidget.cc | 13 ++++---- .../tools/cabana/ui/widgets/videowidget.h | 2 +- openpilot/tools/cabana/utils/strings.cc | 6 ++-- 9 files changed, 40 insertions(+), 40 deletions(-) diff --git a/openpilot/tools/cabana/tests/test_cabana.cc b/openpilot/tools/cabana/tests/test_cabana.cc index 09741cd578..3523a47e08 100644 --- a/openpilot/tools/cabana/tests/test_cabana.cc +++ b/openpilot/tools/cabana/tests/test_cabana.cc @@ -195,10 +195,10 @@ void test_format_seconds() { REQUIRE(utils::formatSeconds(0) == "00:00"); REQUIRE(utils::formatSeconds(59.4) == "00:59"); REQUIRE(utils::formatSeconds(-1) == "00:00"); - REQUIRE(utils::formatSeconds(61.234, true) == "01:01.234"); + REQUIRE(utils::formatSeconds(61.234, true) == "01:01.23"); REQUIRE(utils::formatSeconds(3599.9) == "59:59"); REQUIRE(utils::formatSeconds(3601) == "01:00:01"); - REQUIRE(utils::formatSeconds(3601.5, true) == "01:00:01.500"); + REQUIRE(utils::formatSeconds(3601.5, true) == "01:00:01.50"); const char *tz = getenv("TZ"); const bool had_tz = tz != nullptr; @@ -206,7 +206,7 @@ void test_format_seconds() { setenv("TZ", "UTC", 1); tzset(); REQUIRE(utils::formatSeconds(0, false, true) == "1970-01-01 00:00:00"); - REQUIRE(utils::formatSeconds(1700000000.123, true, true) == "2023-11-14 22:13:20.123"); + REQUIRE(utils::formatSeconds(1700000000.123, true, true) == "2023-11-14 22:13:20.12"); if (had_tz) { setenv("TZ", saved_tz.c_str(), 1); } else { diff --git a/openpilot/tools/cabana/ui/chart/chart.cc b/openpilot/tools/cabana/ui/chart/chart.cc index 4dedc9074c..5d1389822e 100644 --- a/openpilot/tools/cabana/ui/chart/chart.cc +++ b/openpilot/tools/cabana/ui/chart/chart.cc @@ -488,7 +488,7 @@ void ChartView::showTip(double sec) { x = tooltip_x_; } ImVec2 pt(x, layout_.plot_area.Min.y); - text_list.insert(text_list.begin(), TipLine{.name = formatNumber(secondsAtPoint({x, 0}), 3)}); + text_list.insert(text_list.begin(), TipLine{.name = formatNumber(secondsAtPoint({x, 0}), 2)}); tip_label_.showText(pt, text_list, visible_rect); } diff --git a/openpilot/tools/cabana/ui/icons.h b/openpilot/tools/cabana/ui/icons.h index 3a736fb3c9..5c97666da4 100644 --- a/openpilot/tools/cabana/ui/icons.h +++ b/openpilot/tools/cabana/ui/icons.h @@ -4,37 +4,37 @@ namespace icon { constexpr const char ARROW_CLOCKWISE[] = "\xef\x84\x96"; constexpr const char ARROW_COUNTERCLOCKWISE[] = "\xef\x84\x97"; -constexpr const char ARROW_DOWN_LEFT_SQUARE[] = "\xef\x84\x9d"; -constexpr const char ARROW_UP_RIGHT_SQUARE[] = "\xef\x85\x83"; +constexpr const char ARROW_DOWN_LEFT_SQUARE[] = "\xef\x84\x9c"; +constexpr const char ARROW_UP_RIGHT_SQUARE[] = "\xef\x85\x82"; constexpr const char CHEVRON_LEFT[] = "\xef\x8a\x84"; constexpr const char CHEVRON_RIGHT[] = "\xef\x8a\x85"; constexpr const char DASH[] = "\xef\x8b\xaa"; -constexpr const char DASH_SQUARE[] = "\xef\x8b\xa9"; -constexpr const char EXCLAMATION_TRIANGLE[] = "\xef\x8c\xbb"; -constexpr const char FAST_FORWARD[] = "\xef\x9f\xb4"; +constexpr const char DASH_SQUARE[] = "\xef\x8b\xa8"; +constexpr const char EXCLAMATION_TRIANGLE[] = "\xef\x8c\xba"; +constexpr const char FAST_FORWARD[] = "\xef\x9f\xb3"; constexpr const char FILETYPE_CSV[] = "\xef\x9d\x83"; -constexpr const char FOLDER[] = "\xef\x8f\x99"; -constexpr const char FILE_EARMARK[] = "\xef\x8e\x92"; -constexpr const char FILE_EARMARK_RULED[] = "\xef\x8e\x85"; -constexpr const char PLUS_SQUARE[] = "\xef\x93\xbd"; +constexpr const char FOLDER[] = "\xef\x8f\x91"; +constexpr const char FILE_EARMARK[] = "\xef\x8d\xa9"; +constexpr const char FILE_EARMARK_RULED[] = "\xef\x8e\x84"; +constexpr const char PLUS_SQUARE[] = "\xef\x93\xbc"; constexpr const char GRAPH_UP[] = "\xef\x8f\xb2"; constexpr const char GRIP_HORIZONTAL[] = "\xef\x8f\xbd"; -constexpr const char INFO_CIRCLE[] = "\xef\x90\xb1"; +constexpr const char INFO_CIRCLE[] = "\xef\x90\xb0"; constexpr const char LIST[] = "\xef\x91\xb9"; -constexpr const char PAUSE[] = "\xef\x93\x84"; +constexpr const char PAUSE[] = "\xef\x93\x83"; constexpr const char PENCIL[] = "\xef\x93\x8b"; -constexpr const char PLAY[] = "\xef\x93\xb5"; +constexpr const char PLAY[] = "\xef\x93\xb4"; constexpr const char PLUS[] = "\xef\x93\xbe"; constexpr const char RAQUO[] = "\xc2\xbb"; // U+00BB, not a bootstrap icon: the toolbar extension button constexpr const char REPEAT[] = "\xef\xa0\x93"; constexpr const char REPEAT_1[] = "\xef\xa0\x92"; -constexpr const char REWIND[] = "\xef\xa0\x99"; -constexpr const char SKIP_END[] = "\xef\x95\x98"; -constexpr const char STOPWATCH[] = "\xef\x96\x97"; +constexpr const char REWIND[] = "\xef\xa0\x98"; +constexpr const char SKIP_END[] = "\xef\x95\x97"; +constexpr const char STOPWATCH[] = "\xef\x96\x96"; constexpr const char THREE_DOTS[] = "\xef\x97\x94"; constexpr const char WINDOW_STACK[] = "\xef\x9b\x92"; constexpr const char X[] = "\xef\x98\xaa"; constexpr const char X_LG[] = "\xef\x99\x99"; -constexpr const char X_SQUARE[] = "\xef\x98\xa9"; +constexpr const char X_SQUARE[] = "\xef\x98\xa8"; constexpr const char ZOOM_OUT[] = "\xef\x98\xad"; } // namespace icon diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index 4a0aef7c79..80a6157d54 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -835,7 +835,8 @@ void MainWindow::drawVideoPanel() { const bool live = can->liveStreaming(); // the bordered child pads its content, so the heights the widget asks for grow by the padding const float video_padding = ImGui::GetStyle().WindowPadding.y * 2.0f; - const float default_h = video_widget_->defaultHeight(avail.x) + video_padding; + // the camera is as wide as the child's content region, not the panel + const float default_h = video_widget_->defaultHeight(avail.x - ImGui::GetStyle().WindowPadding.x * 2.0f) + video_padding; const float video_hint = video_splitter_ratio_ >= 0.0f ? avail.y * video_splitter_ratio_ : default_h; float video_h = charts_floating_ ? avail.y : std::clamp(video_hint, 0.0f, avail.y - 1.0f); if (live) video_h = default_h; // display video at minimum size. diff --git a/openpilot/tools/cabana/ui/style.cc b/openpilot/tools/cabana/ui/style.cc index 8cbedad837..a006dbc66d 100644 --- a/openpilot/tools/cabana/ui/style.cc +++ b/openpilot/tools/cabana/ui/style.cc @@ -100,9 +100,9 @@ void applyTheme(int theme) { colors[ImGuiCol_FrameBg] = c(DarkTheme::base); colors[ImGuiCol_FrameBgHovered] = colorRgb(0x1f, 0x1f, 0x1f); colors[ImGuiCol_FrameBgActive] = colorRgb(0x24, 0x24, 0x24); - colors[ImGuiCol_Button] = colorRgb(0x3a, 0x3a, 0x3a); - colors[ImGuiCol_ButtonHovered] = colorRgb(0x42, 0x42, 0x42); - colors[ImGuiCol_ButtonActive] = colorRgb(0x30, 0x30, 0x30); + colors[ImGuiCol_Button] = colorRgb(0x5e, 0x5e, 0x5e); + colors[ImGuiCol_ButtonHovered] = colorRgb(0x6a, 0x6a, 0x6a); + colors[ImGuiCol_ButtonActive] = colorRgb(0x52, 0x52, 0x52); colors[ImGuiCol_Header] = highlight; colors[ImGuiCol_HeaderHovered] = c(DarkTheme::highlight, 0.8f); colors[ImGuiCol_HeaderActive] = highlight; @@ -157,9 +157,9 @@ void applyTheme(int theme) { colors[ImGuiCol_FrameBg] = base; colors[ImGuiCol_FrameBgHovered] = colorRgb(0xf7, 0xf7, 0xf7); colors[ImGuiCol_FrameBgActive] = colorRgb(0xef, 0xef, 0xef); - colors[ImGuiCol_Button] = colorRgb(0xf5, 0xf5, 0xf5); - colors[ImGuiCol_ButtonHovered] = colorRgb(0xfa, 0xfa, 0xfa); - colors[ImGuiCol_ButtonActive] = colorRgb(0xd9, 0xd9, 0xd9); + colors[ImGuiCol_Button] = colorRgb(0xe4, 0xe4, 0xe4); + colors[ImGuiCol_ButtonHovered] = colorRgb(0xec, 0xec, 0xec); + colors[ImGuiCol_ButtonActive] = colorRgb(0xd0, 0xd0, 0xd0); colors[ImGuiCol_Header] = highlight; colors[ImGuiCol_HeaderHovered] = colorRgb(0x30, 0x8c, 0xc6, 0.8f); colors[ImGuiCol_HeaderActive] = highlight; diff --git a/openpilot/tools/cabana/ui/widgets/historylog.cc b/openpilot/tools/cabana/ui/widgets/historylog.cc index 0a3de51d70..ec2ef844ff 100644 --- a/openpilot/tools/cabana/ui/widgets/historylog.cc +++ b/openpilot/tools/cabana/ui/widgets/historylog.cc @@ -22,7 +22,7 @@ constexpr float COMPARE_WIDTH = 50.0f; std::string formatTime(uint64_t mono_time) { char buf[32] = {}; - snprintf(buf, sizeof(buf), "%.3f", can->toSeconds(mono_time)); + snprintf(buf, sizeof(buf), "%.2f", can->toSeconds(mono_time)); return buf; } @@ -184,7 +184,7 @@ std::string LogsWidget::headerText(int column) const { } ImVec2 LogsWidget::headerSize(int column, float viewport_width) const { - const ImVec2 time_text_size = ImGui::CalcTextSize("000000.000"); + const ImVec2 time_text_size = ImGui::CalcTextSize("000000.00"); const ImVec2 time_col_size(time_text_size.x + 10, time_text_size.y + 6); if (column == 0) return time_col_size; const int default_size = std::max(100, (int)((viewport_width - time_col_size.x) / (columnCount() - 1))); diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index fa6b9c7e6c..ed5683f74b 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -534,20 +534,19 @@ void StreamCameraView::draw(const ImVec2 &size, double thumbnail_time) { } } -const RgbImage *StreamCameraView::thumbnailAt(double sec, uint64_t *mono_time) { +const RgbImage *StreamCameraView::thumbnailAt(double sec) { auto it = big_thumbnails_.lower_bound(can->toMonoTime(sec)); if (it == big_thumbnails_.end()) return nullptr; if (big_thumbnail_texture_.id == 0 || big_thumbnail_texture_.key != it->first) { big_thumbnail_texture_.upload(it->second); big_thumbnail_texture_.key = it->first; } - if (mono_time) *mono_time = it->first; return &it->second; } void StreamCameraView::drawScrubThumbnail(ImDrawList *p, double sec) { p->AddRectFilled(rect().Min, rect().Max, IM_COL32(0, 0, 0, 255)); - if (const RgbImage *image = thumbnailAt(sec, nullptr)) { + if (const RgbImage *image = thumbnailAt(sec)) { // scale to the widget size, keeping the aspect ratio const float scale = std::min(width() / image->width, height() / image->height); const ImVec2 scaled_size(std::floor(image->width * scale), std::floor(image->height * scale)); @@ -560,8 +559,7 @@ void StreamCameraView::drawScrubThumbnail(ImDrawList *p, double sec) { } void StreamCameraView::drawThumbnail(ImDrawList *p, double sec) { - uint64_t mono_time = 0; - if (const RgbImage *image = thumbnailAt(sec, &mono_time)) { + if (const RgbImage *image = thumbnailAt(sec)) { // AddImage scales the stored image to the thumbnail height, keeping the aspect ratio const int h = MIN_VIDEO_HEIGHT - THUMBNAIL_MARGIN * 2; const int w = std::max(1, (int)std::lround((double)image->width * h / image->height)); @@ -574,7 +572,8 @@ void StreamCameraView::drawThumbnail(ImDrawList *p, double sec) { ImRect thumb_rect(ImVec2(rect().Min.x + x, rect().Min.y + y), ImVec2(rect().Min.x + x + w, rect().Min.y + y + h)); p->AddImage(big_thumbnail_texture_.ref(), thumb_rect.Min, thumb_rect.Max); p->AddRect(thumb_rect.Min, thumb_rect.Max, paletteBrightText(), 0.0f, 0, 2.0f); - if (auto alert = getReplay()->findAlertAtTime(can->toSeconds(mono_time))) { + // look up the alert at the hovered time, the thumbnail frame itself can be seconds away + if (auto alert = getReplay()->findAlertAtTime(sec)) { drawAlert(p, thumb_rect, *alert, POINT_10_FONT_SIZE); } drawTime(p, thumb_rect, sec); @@ -583,7 +582,7 @@ void StreamCameraView::drawThumbnail(ImDrawList *p, double sec) { void StreamCameraView::drawTime(ImDrawList *p, const ImRect &rect, double seconds) { char text[32]; - snprintf(text, sizeof(text), "%.3f", seconds); + snprintf(text, sizeof(text), "%.2f", seconds); ImFont *font = ImGui::GetFont(); const ImVec2 text_size = font->CalcTextSizeA(POINT_10_FONT_SIZE, FLT_MAX, 0.0f, text); // centered horizontally, above the bottom margin diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.h b/openpilot/tools/cabana/ui/widgets/videowidget.h index 44c3cd5312..d873c3ed10 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.h +++ b/openpilot/tools/cabana/ui/widgets/videowidget.h @@ -69,7 +69,7 @@ private: }; void collectThumbnails(); // moves the decoded thumbnails in once a parseQLog task is done // the first thumbnail at or after sec, uploaded to big_thumbnail_texture_; nullptr when there is none - const RgbImage *thumbnailAt(double sec, uint64_t *mono_time); + const RgbImage *thumbnailAt(double sec); void drawAlert(ImDrawList *p, const ImRect &rect, const Timeline::Entry &alert, float font_size); void drawThumbnail(ImDrawList *p, double sec); void drawScrubThumbnail(ImDrawList *p, double sec); diff --git a/openpilot/tools/cabana/utils/strings.cc b/openpilot/tools/cabana/utils/strings.cc index 5590b6078e..3a1191609e 100644 --- a/openpilot/tools/cabana/utils/strings.cc +++ b/openpilot/tools/cabana/utils/strings.cc @@ -21,7 +21,7 @@ std::string formatSeconds(double sec, bool include_milliseconds, bool absolute_t char buf[64] = {}; std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm); if (!include_milliseconds) return buf; - snprintf(out, sizeof(out), "%s.%03d", buf, millis); + snprintf(out, sizeof(out), "%s.%02d", buf, millis / 10); return out; } @@ -33,11 +33,11 @@ std::string formatSeconds(double sec, bool include_milliseconds, bool absolute_t const int seconds = (total_ms / 1000) % 60; const int millis = total_ms % 1000; if (show_hours && include_milliseconds) { - snprintf(out, sizeof(out), "%02d:%02d:%02d.%03d", hours, minutes, seconds, millis); + snprintf(out, sizeof(out), "%02d:%02d:%02d.%02d", hours, minutes, seconds, millis / 10); } else if (show_hours) { snprintf(out, sizeof(out), "%02d:%02d:%02d", hours, minutes, seconds); } else if (include_milliseconds) { - snprintf(out, sizeof(out), "%02d:%02d.%03d", minutes, seconds, millis); + snprintf(out, sizeof(out), "%02d:%02d.%02d", minutes, seconds, millis / 10); } else { snprintf(out, sizeof(out), "%02d:%02d", minutes, seconds); } From 444be0a649e47b15b2de2146a18949673c55689c Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:49:37 -0700 Subject: [PATCH 014/122] cabana: pace frames instead of relying on vsync (#38783) On Wayland a vsynced glfwSwapBuffers blocks until the compositor sends a frame callback, which it stops doing while the window is on another workspace or otherwise off-screen. The main thread never gets back to glfwPollEvents, so Hyprland reports cabana as not responding. Run with swap interval 0 and pace the loop to the monitor refresh rate. --- openpilot/tools/cabana/ui/app.cc | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/openpilot/tools/cabana/ui/app.cc b/openpilot/tools/cabana/ui/app.cc index aab2bfe742..ce4678f223 100644 --- a/openpilot/tools/cabana/ui/app.cc +++ b/openpilot/tools/cabana/ui/app.cc @@ -1,8 +1,10 @@ #include "tools/cabana/ui/app.h" #include +#include #include #include +#include #include #include "imgui.h" @@ -68,8 +70,23 @@ void glfwErrorCallback(int error, const char *description) { fprintf(stderr, "GLFW error %d: %s\n", error, description); } -// vsync paces the loop: glfwSwapBuffers blocks until the next refresh. Throttling on top of that beats -// against the refresh rate and makes the camera view stutter. +void paceFrame() { + using clock = std::chrono::steady_clock; + static clock::duration period = [] { + const GLFWvidmode *mode = glfwGetVideoMode(glfwGetPrimaryMonitor()); + int hz = (mode != nullptr && mode->refreshRate > 0) ? mode->refreshRate : 60; + return std::chrono::duration_cast(std::chrono::duration(1.0 / hz)); + }(); + static clock::time_point next = clock::now(); + next += period; + auto now = clock::now(); + if (next < now) { + next = now; // fell behind (slow frame or hidden window): don't try to catch up + return; + } + std::this_thread::sleep_until(next); +} + void renderFrame(GLFWwindow *window, MainWindow *win) { glfwPollEvents(); deliverPendingFocusLoss(); @@ -98,6 +115,7 @@ void renderFrame(GLFWwindow *window, MainWindow *win) { glfwMakeContextCurrent(backup_context); } glfwSwapBuffers(window); + paceFrame(); } class GlfwRuntime { @@ -120,7 +138,7 @@ public: throw std::runtime_error("glfwCreateWindow failed"); } glfwMakeContextCurrent(window_); - glfwSwapInterval(1); + glfwSwapInterval(0); } ~GlfwRuntime() { From a4f7c50d2a52a5865a40da2ebc5004c82929a0ef Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:54:17 -0700 Subject: [PATCH 015/122] cabana: fix sparkline edges and stroke rendering (#38784) cabana: preserve sparkline edges and improve stroke rendering --- openpilot/tools/cabana/ui/chart/sparkline.cc | 71 +++++++++----------- 1 file changed, 30 insertions(+), 41 deletions(-) diff --git a/openpilot/tools/cabana/ui/chart/sparkline.cc b/openpilot/tools/cabana/ui/chart/sparkline.cc index e5fe87182d..cf0482c197 100644 --- a/openpilot/tools/cabana/ui/chart/sparkline.cc +++ b/openpilot/tools/cabana/ui/chart/sparkline.cc @@ -61,7 +61,10 @@ void Sparkline::render(const CabanaColor &color, int range, ImVec2 sz, double wi } const double xscale = (sz.x - 1) / (double)range; - const double yscale = (sz.y - 3) / (max_val - min_val); + // Leave room for the stroke's antialiasing fringe and the 3x3 endpoint marker + // at both extrema, including the half-pixel offset applied by ImGui's strokes. + const double ypadding = std::min(3.0, sz.y / 2.0); + const double yscale = (sz.y - 2 * ypadding) / (max_val - min_val); const double span = points_.back().x - points_.front().x; bool draw_individual_points = (span * xscale / points_.size()) > 8.0; @@ -70,7 +73,7 @@ void Sparkline::render(const CabanaColor &color, int range, ImVec2 sz, double wi render_points_.clear(); if (draw_individual_points) { for (const auto &p : points_) { - render_points_.emplace_back(p.x * xscale, 1.0 + (max_val - p.y) * yscale); + render_points_.emplace_back(p.x * xscale, ypadding + (max_val - p.y) * yscale); } } else if (is_flat_line) { double y = sz.y / 2.0; @@ -78,7 +81,7 @@ void Sparkline::render(const CabanaColor &color, int range, ImVec2 sz, double wi render_points_.emplace_back(points_.back().x * xscale, y); } else { double prev_y = points_.front().y; - render_points_.emplace_back(points_.front().x * xscale, 1.0 + (max_val - prev_y) * yscale); + render_points_.emplace_back(points_.front().x * xscale, ypadding + (max_val - prev_y) * yscale); bool in_flat = false; for (size_t i = 1; i < points_.size(); ++i) { @@ -87,13 +90,13 @@ void Sparkline::render(const CabanaColor &color, int range, ImVec2 sz, double wi if (std::abs(y - prev_y) < 1e-6) { in_flat = true; } else { - if (in_flat) render_points_.emplace_back(points_[i - 1].x * xscale, 1.0 + (max_val - prev_y) * yscale); - render_points_.emplace_back(p.x * xscale, 1.0 + (max_val - y) * yscale); + if (in_flat) render_points_.emplace_back(points_[i - 1].x * xscale, ypadding + (max_val - prev_y) * yscale); + render_points_.emplace_back(p.x * xscale, ypadding + (max_val - y) * yscale); in_flat = false; } prev_y = y; } - if (in_flat) render_points_.emplace_back(points_.back().x * xscale, 1.0 + (max_val - prev_y) * yscale); + if (in_flat) render_points_.emplace_back(points_.back().x * xscale, ypadding + (max_val - prev_y) * yscale); } size = sz; @@ -135,43 +138,29 @@ void Sparkline::draw(ImDrawList *draw_list, ImVec2 pos, ImU32 color) const { // a point is a 3x3 square auto draw_point = [&](const ImVec2 &p) { draw_list->AddRectFilled(ImVec2(p.x - 1.5f, p.y - 1.5f), ImVec2(p.x + 1.5f, p.y + 1.5f), color); }; - if (draw_individual_points_) { - for (const auto &p : render_points_) { - draw_list->PathLineTo(point_at(p)); - draw_point(point_at(p)); - } - draw_list->PathStroke(color, ImDrawFlags_None, 1.5f); - } else { - // one sample per pixel column: several strokes in a column overlap into a blur, and a dense - // high-contrast texture scrolling by is hard on the eyes - std::vector pts; - pts.reserve(render_points_.size()); - float col = -1e9f; - for (const auto &p : render_points_) { - ImVec2 sp = point_at(p); - float c = snap(sp.x); - if (c != col) { - pts.push_back(sp); - col = c; + // Keep ordinary curves joined so short segments don't leave antialiasing seams. + // Split at turns sharper than 120 degrees: a joined narrow spike folds back on + // itself and loses its tip. Both paths retain the actual peak and every sample. + const float thickness = 1.5f; + for (size_t i = 0; i < render_points_.size(); ++i) { + const auto &p = render_points_[i]; + draw_list->PathLineTo(point_at(p)); + if (i > 0 && i + 1 < render_points_.size()) { + const auto &prev = render_points_[i - 1]; + const auto &next = render_points_[i + 1]; + const double ax = p.x - prev.x, ay = p.y - prev.y; + const double bx = next.x - p.x, by = next.y - p.y; + const double dot = ax * bx + ay * by; + if (dot < 0 && 4 * dot * dot > (ax * ax + ay * ay) * (bx * bx + by * by)) { + draw_list->PathStroke(color, ImDrawFlags_None, thickness); + draw_list->PathLineTo(point_at(p)); } } - - // antialiasing smooths the gentle slopes but smears the near-vertical segments of a spiky signal - // over neighboring columns, so those are drawn aliased. runs of one kind are stroked together and - // share their end points with the next run - auto steep = [&](size_t i) { return std::abs(pts[i + 1].y - pts[i].y) > 2.0f * std::abs(pts[i + 1].x - pts[i].x) + px; }; - const ImDrawListFlags saved = draw_list->Flags; - size_t i = 0; - while (i + 1 < pts.size()) { - const bool is_steep = steep(i); - size_t j = i + 1; - while (j + 1 < pts.size() && steep(j) == is_steep) ++j; - draw_list->Flags = is_steep ? (saved & ~ImDrawListFlags_AntiAliasedLines) : saved; - for (size_t n = i; n <= j; ++n) draw_list->PathLineTo(pts[n]); - draw_list->PathStroke(color, ImDrawFlags_None, 1.0f); - i = j; - } - draw_list->Flags = saved; + } + draw_list->PathStroke(color, ImDrawFlags_None, thickness); + if (draw_individual_points_) { + for (const auto &p : render_points_) draw_point(point_at(p)); + } else { draw_point(point_at(render_points_.back())); } draw_list->PopClipRect(); From cb0da74b414363f4cdb15c2897f8bb1202bc7e79 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:34:00 -0700 Subject: [PATCH 016/122] cabana: center speed dropdown label, add divider after loop button (#38785) --- openpilot/tools/cabana/ui/util.cc | 17 +++++---- openpilot/tools/cabana/ui/util.h | 2 +- .../tools/cabana/ui/widgets/videowidget.cc | 36 ++++++++++--------- 3 files changed, 31 insertions(+), 24 deletions(-) diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index bd1b2f8039..2e538e16f1 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -442,18 +442,21 @@ bool menuButton(const char *id, const std::string &text, const char *popup_id, b // on press; a press while it is open toggles it closed (imgui closes the popup at the end of the frame of // a click outside it, so only open when it is not already open) if (bold) pushBoldFont(); - ImGui::PushStyleColor(ImGuiCol_Button, popup_open ? style.Colors[ImGuiCol_ButtonActive] : ImVec4(0, 0, 0, 0)); - ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); - ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.0f, 0.5f)); - const bool clicked = ImGui::ButtonEx((text + "###" + id).c_str(), ImVec2(width, 0.0f), ImGuiButtonFlags_PressedOnClick); - ImGui::PopStyleVar(2); - ImGui::PopStyleColor(); const float text_width = ImGui::CalcTextSize(text.c_str(), nullptr, true).x; const float ascent = ImGui::GetFontBaked()->Ascent; + // the text and the arrow are centered as a group in the button + const float padding_x = std::max(style.FramePadding.x, (width - (text_width + MENU_ARROW_SPACING + MENU_ARROW_SIZE)) * 0.5f); + ImGui::PushStyleColor(ImGuiCol_Button, popup_open ? style.Colors[ImGuiCol_ButtonActive] : ImVec4(0, 0, 0, 0)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(padding_x, style.FramePadding.y)); + ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.0f, 0.5f)); + const bool clicked = ImGui::ButtonEx((text + "###" + id).c_str(), ImVec2(width, 0.0f), ImGuiButtonFlags_PressedOnClick); + ImGui::PopStyleVar(3); + ImGui::PopStyleColor(); if (bold) popBoldFont(); // a 6 px arrow right after the text, sitting on the text baseline const ImVec2 min = ImGui::GetItemRectMin(); - const float x = min.x + style.FramePadding.x + text_width + MENU_ARROW_SPACING; + const float x = min.x + padding_x + text_width + MENU_ARROW_SPACING; const float baseline = min.y + style.FramePadding.y + ascent; ImGui::GetWindowDrawList()->AddTriangleFilled(ImVec2(x, baseline - MENU_ARROW_SIZE * 0.5f), ImVec2(x + MENU_ARROW_SIZE, baseline - MENU_ARROW_SIZE * 0.5f), diff --git a/openpilot/tools/cabana/ui/util.h b/openpilot/tools/cabana/ui/util.h index 5739cfa3bc..d180c073b7 100644 --- a/openpilot/tools/cabana/ui/util.h +++ b/openpilot/tools/cabana/ui/util.h @@ -182,7 +182,7 @@ float toolbarWidth(const std::vector &items, size_t spacer_index); void drawToolbar(const std::vector &items, size_t spacer_index); // an auto-raise button that opens `popup_id` below itself, with a dropdown arrow after the text. width 0: -// sized to the text, otherwise the arrow sits at the right edge +// sized to the text, otherwise the text and the arrow are centered in the button float menuButtonWidth(const std::string &text, bool bold = false); bool menuButton(const char *id, const std::string &text, const char *popup_id, bool bold = false, float width = 0.0f); diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index ed5683f74b..9a54351ab6 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -143,7 +143,7 @@ static float toolbarHeight() { return TOOLBAR_MARGIN_Y + ImGui::GetFrameHeight() void VideoWidget::drawPlaybackController() { beginToolbar(); ImGui::SetCursorPosY(ImGui::GetCursorPosY() + TOOLBAR_MARGIN_Y); - const float speed_width = menuButtonWidth("0.05x ", true); + const float speed_width = menuButtonWidth("0.05x", true); const char *play_icon = can->isPaused() ? icon::PLAY : icon::PAUSE; const char *play_tooltip = can->isPaused() ? "Play" : "Pause"; @@ -186,21 +186,25 @@ void VideoWidget::drawPlaybackController() { } // the expanding spacer: the items after it are right aligned as long as everything fits const size_t spacer_index = items.size(); - if (!can->liveStreaming()) { - items.push_back({toolbarButtonWidth(loop_icon), [&]() { if (toolButton("loop", loop_icon, "Loop playback")) loopPlaybackClicked(); }, - "Loop playback", [this]() { loopPlaybackClicked(); }}); - } - items.push_back({speed_width, [&]() { drawSpeedDropdown(speed_width); }}); - if (!can->liveStreaming()) { - ToolbarItem separator{TOOLBAR_SEPARATOR_EXTENT, []() { + auto separator = []() { + ToolbarItem item{TOOLBAR_SEPARATOR_EXTENT, []() { // a 1 px separator line centered in TOOLBAR_SEPARATOR_EXTENT, inset from the top and bottom const ImVec2 min = ImGui::GetCursorScreenPos(); ImGui::Dummy(ImVec2(TOOLBAR_SEPARATOR_EXTENT, ImGui::GetFrameHeight())); const float x = std::floor(min.x + TOOLBAR_SEPARATOR_EXTENT * 0.5f); ImGui::GetWindowDrawList()->AddLine(ImVec2(x, min.y + 4.0f), ImVec2(x, min.y + ImGui::GetFrameHeight() - 4.0f), ImGui::GetColorU32(ImGuiCol_Separator)); }}; - separator.in_menu = false; - items.push_back(std::move(separator)); + item.in_menu = false; + return item; + }; + if (!can->liveStreaming()) { + items.push_back({toolbarButtonWidth(loop_icon), [&]() { if (toolButton("loop", loop_icon, "Loop playback")) loopPlaybackClicked(); }, + "Loop playback", [this]() { loopPlaybackClicked(); }}); + items.push_back(separator()); + } + items.push_back({speed_width, [&]() { drawSpeedDropdown(speed_width); }}); + if (!can->liveStreaming()) { + items.push_back(separator()); items.push_back({toolbarButtonWidth(icon::INFO_CIRCLE), [&]() { if (toolButton("route_info", icon::INFO_CIRCLE, "View route details")) showRouteInfo(); }, "View route details", [this]() { showRouteInfo(); }}); @@ -221,16 +225,16 @@ void VideoWidget::toggleTimeDisplay() { settings.absolute_time = !settings.absolute_time; } -static std::string speedText(float speed, const char *suffix) { +static std::string speedText(float speed) { char buf[32]; - snprintf(buf, sizeof(buf), "%gx%s", speed, suffix); + snprintf(buf, sizeof(buf), "%gx", speed); return buf; } void VideoWidget::createSpeedDropdown() { speed_index_ = NORMAL_SPEED_INDEX; can->setSpeed(speeds[speed_index_]); - speed_text_ = speedText(speeds[speed_index_], " "); + speed_text_ = speedText(speeds[speed_index_]); } void VideoWidget::drawSpeedDropdown(float width) { @@ -247,14 +251,14 @@ void VideoWidget::drawSpeedMenuItems() { const float indent = ImGui::GetFontSize(); float label_width = 0; for (int i = 0; i < (int)std::size(speeds); ++i) { - label_width = std::max(label_width, ImGui::CalcTextSize(speedText(speeds[i], "").c_str()).x); + label_width = std::max(label_width, ImGui::CalcTextSize(speedText(speeds[i]).c_str()).x); } for (int i = 0; i < (int)std::size(speeds); ++i) { const float speed = speeds[i]; - if (radioMenuItem(speedText(speed, "").c_str(), speed_index_ == i, indent + label_width + indent)) { + if (radioMenuItem(speedText(speed).c_str(), speed_index_ == i, indent + label_width + indent)) { speed_index_ = i; can->setSpeed(speed); - speed_text_ = speedText(speed, " "); + speed_text_ = speedText(speed); } } } From 3bc6701f34553a709d3d5c50be3b1265c77bb67d Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:02:05 -0700 Subject: [PATCH 017/122] cabana: fill video pane and reset video height (#38786) --- openpilot/tools/cabana/core/settings.h | 1 + openpilot/tools/cabana/settings.cc | 1 + openpilot/tools/cabana/ui/icons.h | 2 ++ openpilot/tools/cabana/ui/mainwin.cc | 1 + .../tools/cabana/ui/widgets/cameraview.cc | 16 +++------- .../tools/cabana/ui/widgets/cameraview.h | 31 +++++++++++++++++++ .../tools/cabana/ui/widgets/videowidget.cc | 22 +++++++------ .../tools/cabana/ui/widgets/videowidget.h | 1 + 8 files changed, 54 insertions(+), 21 deletions(-) diff --git a/openpilot/tools/cabana/core/settings.h b/openpilot/tools/cabana/core/settings.h index 1b9e2bcfc4..6032ee5665 100644 --- a/openpilot/tools/cabana/core/settings.h +++ b/openpilot/tools/cabana/core/settings.h @@ -21,6 +21,7 @@ struct CabanaSettingsState { bool multiple_lines_hex = false; bool log_livestream = true; bool suppress_defined_signals = false; + bool crop_video = true; std::string log_path; std::string last_dir; std::string last_route_dir; diff --git a/openpilot/tools/cabana/settings.cc b/openpilot/tools/cabana/settings.cc index 17a6611539..7f8e6473c2 100644 --- a/openpilot/tools/cabana/settings.cc +++ b/openpilot/tools/cabana/settings.cc @@ -488,6 +488,7 @@ void settingsOp(Store &s, SettingOperation op) { op(s, "log_path", settings.log_path); op(s, "drag_direction", (int &)settings.drag_direction); op(s, "suppress_defined_signals", settings.suppress_defined_signals); + op(s, "crop_video", settings.crop_video); op(s, "recent_dbc_file", settings.recent_dbc_file); op(s, "active_msg_id", settings.active_msg_id); op(s, "selected_msg_ids", settings.selected_msg_ids); diff --git a/openpilot/tools/cabana/ui/icons.h b/openpilot/tools/cabana/ui/icons.h index 5c97666da4..7a61d02c09 100644 --- a/openpilot/tools/cabana/ui/icons.h +++ b/openpilot/tools/cabana/ui/icons.h @@ -8,6 +8,8 @@ constexpr const char ARROW_DOWN_LEFT_SQUARE[] = "\xef\x84\x9c"; constexpr const char ARROW_UP_RIGHT_SQUARE[] = "\xef\x85\x82"; constexpr const char CHEVRON_LEFT[] = "\xef\x8a\x84"; constexpr const char CHEVRON_RIGHT[] = "\xef\x8a\x85"; +constexpr const char ASPECT_RATIO[] = "\xef\x85\x90"; +constexpr const char ASPECT_RATIO_FILL[] = "\xef\x85\x8f"; constexpr const char DASH[] = "\xef\x8b\xaa"; constexpr const char DASH_SQUARE[] = "\xef\x8b\xa8"; constexpr const char EXCLAMATION_TRIANGLE[] = "\xef\x8c\xba"; diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index 80a6157d54..a2969defcc 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -156,6 +156,7 @@ void MainWindow::drawMenuBar() { ImGui::Separator(); if (ImGui::MenuItem("Reset Window Layout")) { messages_visible_ = video_visible_ = true; + video_splitter_ratio_ = -1.0f; reset_layout_ = true; } ImGui::EndMenu(); diff --git a/openpilot/tools/cabana/ui/widgets/cameraview.cc b/openpilot/tools/cabana/ui/widgets/cameraview.cc index 78ca15116b..6ffd099fe9 100644 --- a/openpilot/tools/cabana/ui/widgets/cameraview.cc +++ b/openpilot/tools/cabana/ui/widgets/cameraview.cc @@ -9,6 +9,7 @@ #include "common/yuv.h" #include "tools/cabana/utils/util.h" +#include "tools/cabana/settings.h" namespace { constexpr GLenum GL_LINEAR_MIPMAP_LINEAR_ = 0x2703; @@ -107,21 +108,12 @@ void CameraWidget::paint() { frame_updated_ = false; } - // Scale for aspect ratio - float widget_ratio = (float)width() / height(); - float frame_ratio = (float)rgb_frame_.width / rgb_frame_.height; - int w = std::lround(width() * std::min(frame_ratio / widget_ratio, 1.0f)); - int h = std::lround(height() * std::min(widget_ratio / frame_ratio, 1.0f)); - ImVec2 video_min(rect_.Min.x + (int)(width() - w) / 2, rect_.Min.y + (int)(height() - h) / 2); - ImVec2 video_max(video_min.x + w, video_min.y + h); - - ImVec2 uv0(0, 0), uv1(1, 1); + VideoPlacement placement = videoPlacement(rect_, frameAspectRatio(), settings.crop_video); if (active_stream_type_ == VISION_STREAM_CABIN) { // mirror cabin camera horizontally - uv0.x = 1; - uv1.x = 0; + std::swap(placement.uv0.x, placement.uv1.x); } - p->AddImage(frame_texture_.ref(), video_min, video_max, uv0, uv1); + p->AddImage(frame_texture_.ref(), placement.min, placement.max, placement.uv0, placement.uv1); } void CameraWidget::vipcThread() { diff --git a/openpilot/tools/cabana/ui/widgets/cameraview.h b/openpilot/tools/cabana/ui/widgets/cameraview.h index 381e13dc50..2812aea86e 100644 --- a/openpilot/tools/cabana/ui/widgets/cameraview.h +++ b/openpilot/tools/cabana/ui/widgets/cameraview.h @@ -1,6 +1,8 @@ #pragma once +#include #include +#include #include #include #include @@ -16,6 +18,35 @@ #include "tools/cabana/core/observable.h" #include "msgq/visionipc/visionipc_client.h" +// Center-crop the source to fill the destination without stretching. +inline ImVec2 videoFillUv(const ImVec2 &size, float aspect_ratio) { + const float ratio = size.x / size.y / aspect_ratio; + return ImVec2(0.5f * (1.0f - std::min(ratio, 1.0f)), + 0.5f * (1.0f - std::min(1.0f / ratio, 1.0f))); +} + +struct VideoPlacement { + ImVec2 min; + ImVec2 max; + ImVec2 uv0; + ImVec2 uv1; +}; + +inline VideoPlacement videoPlacement(const ImRect &rect, float source_aspect_ratio, bool crop) { + VideoPlacement placement{rect.Min, rect.Max, ImVec2(0, 0), ImVec2(1, 1)}; + if (crop) { + placement.uv0 = videoFillUv(rect.GetSize(), source_aspect_ratio); + placement.uv1 = ImVec2(1.0f - placement.uv0.x, 1.0f - placement.uv0.y); + } else { + const float widget_aspect_ratio = rect.GetWidth() / rect.GetHeight(); + const int width = std::lround(rect.GetWidth() * std::min(source_aspect_ratio / widget_aspect_ratio, 1.0f)); + const int height = std::lround(rect.GetHeight() * std::min(widget_aspect_ratio / source_aspect_ratio, 1.0f)); + placement.min = ImVec2(rect.Min.x + (int)(rect.GetWidth() - width) / 2, rect.Min.y + (int)(rect.GetHeight() - height) / 2); + placement.max = ImVec2(placement.min.x + width, placement.min.y + height); + } + return placement; +} + // tightly packed RGBA pixels struct RgbImage { int width = 0; diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index 9a54351ab6..7cabac840a 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -197,10 +197,14 @@ void VideoWidget::drawPlaybackController() { item.in_menu = false; return item; }; + const char *aspect_ratio_icon = settings.crop_video ? icon::ASPECT_RATIO_FILL : icon::ASPECT_RATIO; + items.push_back({toolbarButtonWidth(aspect_ratio_icon), [&]() { + if (toolButton("crop_video", aspect_ratio_icon, "Crop to fill")) cropVideoClicked(); + }, "Crop to fill", [this]() { cropVideoClicked(); }}); if (!can->liveStreaming()) { + items.push_back(separator()); items.push_back({toolbarButtonWidth(loop_icon), [&]() { if (toolButton("loop", loop_icon, "Loop playback")) loopPlaybackClicked(); }, "Loop playback", [this]() { loopPlaybackClicked(); }}); - items.push_back(separator()); } items.push_back({speed_width, [&]() { drawSpeedDropdown(speed_width); }}); if (!can->liveStreaming()) { @@ -313,6 +317,11 @@ void VideoWidget::loopPlaybackClicked() { getReplay()->setLoop(!getReplay()->loop()); } +void VideoWidget::cropVideoClicked() { + settings.crop_video = !settings.crop_video; + settings.changed(); +} + void VideoWidget::timeRangeChanged() { const auto time_range = can->timeRange(); if (can->liveStreaming()) { @@ -551,14 +560,9 @@ const RgbImage *StreamCameraView::thumbnailAt(double sec) { void StreamCameraView::drawScrubThumbnail(ImDrawList *p, double sec) { p->AddRectFilled(rect().Min, rect().Max, IM_COL32(0, 0, 0, 255)); if (const RgbImage *image = thumbnailAt(sec)) { - // scale to the widget size, keeping the aspect ratio - const float scale = std::min(width() / image->width, height() / image->height); - const ImVec2 scaled_size(std::floor(image->width * scale), std::floor(image->height * scale)); - const ImVec2 center = rect().GetCenter(); - const ImVec2 thumb_min(center.x - (int)(scaled_size.x / 2), center.y - (int)(scaled_size.y / 2)); - ImRect thumb_rect(thumb_min, ImVec2(thumb_min.x + scaled_size.x, thumb_min.y + scaled_size.y)); - p->AddImage(big_thumbnail_texture_.ref(), thumb_rect.Min, thumb_rect.Max); - drawTime(p, thumb_rect, sec); + const VideoPlacement placement = videoPlacement(rect(), (float)image->width / image->height, settings.crop_video); + p->AddImage(big_thumbnail_texture_.ref(), placement.min, placement.max, placement.uv0, placement.uv1); + drawTime(p, rect(), sec); } } diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.h b/openpilot/tools/cabana/ui/widgets/videowidget.h index d873c3ed10..90058850c3 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.h +++ b/openpilot/tools/cabana/ui/widgets/videowidget.h @@ -105,6 +105,7 @@ private: void drawSpeedDropdown(float width); void drawSpeedMenuItems(); void loopPlaybackClicked(); + void cropVideoClicked(); void vipcAvailableStreamsUpdated(std::set streams); void showRouteInfo(); From c51e3e5a71d6ea25e59c0683d683c1092c7c3f69 Mon Sep 17 00:00:00 2001 From: YassineYousfi Date: Sat, 5 Sep 2026 19:03:12 -0700 Subject: [PATCH 018/122] =?UTF-8?q?cinque=20terre=20model=20=F0=9F=87=AE?= =?UTF-8?q?=F0=9F=87=B9=20=20(#38771)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a4c5f1d1-1f5d-4807-9593-54afa27099d5/12864 --- openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx b/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx index 7b51732275..af98beabcb 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx +++ b/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1791d5940b2c048d0639813426dd2cf1d6f2a6727ed51e17c8bcea8bbe754123 +oid sha256:e8d821733be15ebe9e27498bc27ad8bbbd741980ece37d77f377294010b8ff28 size 765950064 From 9d1f0d417111c89a8917d45180668f9ac644bbad Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:46:02 -0700 Subject: [PATCH 019/122] cabana: restyle (#38789) * cabana: connect palette theme, rounded cards, floating page switch Move the jotpluggler port's connect palette into a shared theme module (ui/theme.h/.cc) and apply it to all of cabana: Palette struct for dark and light, applyTheme fills ImGui and ImPlot styles, readableCurveColor and sectionTitle for reuse by the analysis workspace. Drop the old DarkTheme constants and per-theme color branches in widgets. Docked panels are borderless with content in rounded bordered cards; custom-drawn rects use the style radii. The detail widget's bottom tab bar is replaced by a floating Messages/Logs pill. * cabana: scrollbar and menu contrast, page switch below the page The scrollbar grab uses the border color on the window-colored track. Selection colors are stronger so an open menu reads as blue in both themes and the hovered item is visible in light mode. The Messages/Logs pill sits in a strip below the page instead of covering its last rows. * cabana: even spacing around the page switch, steady top menu highlight * cabana: inner spacing before the clear button of a clearable input * cabana: center the page switch below the page, space the signal row buttons * cabana: logs table cells padded and bordered like the messages table * cabana: recess the signals card in the window color * cabana: one button vocabulary Three button shapes, all one frame height with the style padding and rounding: ImGui::Button for framed text, iconButton for a framed square icon, toolButton for a flat square icon (or flat text). Adjacent buttons are ItemInnerSpacing apart, groups ItemSpacing apart. Tool bars drop their private padding and 1 px spacing, the signal row, chart header, detail header, logs export and signal selector buttons all use the shared sizes, and the page switch is built from the same style values. * cabana: center the glyph ink in square icon buttons * cabana: framed tool buttons and plain icon glyphs toolButton is now iconButton with an optional text, so every button in the app is framed. Boxed glyphs (plus-square, x-square, dash-square, arrow-*-square, window-stack) are replaced by their plain variants (plus-lg, trash, arrows-collapse, box-arrow-*, window-plus), the light x/plus/dash by their -lg forms, the chart menu by three-dots-vertical and the toolbar overflow by chevron-double-right. * cabana: draw square button glyphs at 80% so full bleed icons keep a margin * cabana: signal values in the mono font * cabana: center square button icons on their atlas ink, snapped to framebuffer pixels * cabana: draw square button glyph quads directly, AddText truncates the position * cabana: right align the messages and signals tool bars to the table edge, full size collapse button * cabana: detail header and tab scroll buttons flush with the right edge * cabana: header and footer strips, gap above the panels, video splitter as a separator The menu bar and the status bar are strips in the surface color with a separator line towards the panels, and the dockspace sits one item spacing below the menu bar, matching the spacing above the status bar. The video/charts splitter draws the same 1 px separator line as the dock splitters, centered in a gap as tall as the side padding, and a double click snaps the video back to its natural height. * cabana: top menus flush with the menu bar, 2 px video splitter without double click * cabana: dropdowns continue the menu bar, splitter in the border color, row button gap Top level menus draw no top border and square top corners, and the menu bar leaves its separator out under the open dropdown. The video splitter uses the border color like the dock splitters. The signal row buttons keep one inner spacing before the scrollbar. * cabana: dropdowns share the bar's 1 px border line, messages toolbar spacing matches the video card * cabana: tool bar groups, framed dropdown buttons, chart content flush with the tool bar Tool bar items are ItemSpacing apart and the items of a group (a button pair, a separator and its neighbor) ItemInnerSpacing apart, instead of a private 1 px spacing. The dropdown buttons are framed like the other buttons. The video time is a plain mono readout that toggles on click. Chart cards have no horizontal margin so their header and plot line up with the tool bar above. * cabana: chart list spacing: gap above the scroll area, charts clear of the scrollbar, even legend gaps * cabana: loop and speed buttons grouped like the playback buttons * cabana: survive narrow panels and small windows Stress tested by dragging the column and video splitters to their limits and shrinking the window to 640x480. Fixes: the messages tool bar and the message detail header overflow into the >> menu instead of clipping (the view button and the name stay); the binary view keeps a minimum cell width and scrolls sideways; the Messages/Logs switch drops to icons when the strip is too narrow; the camera keeps its last frame across reconnects, so a video restored from a collapsed splitter while paused is no longer black. * cabana: range slider spans the charts >> menu, remove all and float as menu entries * cabana: the charts card keeps its tool bar row or collapses, like the video at the other end * cabana: uniform >> menus: dropdowns become submenus, the time readout an action entry * cabana: video/charts splitter gap matches the column splitters * cabana: drop unused lambda capture in chart type menu Claude-Session: https://claude.ai/code/session_01XTFaJpf235LeHBmyFRgeJ1 * cabana: the menu bar and the status bar stay in full screen * cabana: simplify theme helpers and toolbar actions * cabana: preserve panel width for overflow edit dialog * cabana: keep message editor usable in narrow panels * cabana: group heatmap modes in a dropdown * cabana: trim redundant restyle comments * cabana: fix review findings from the restyle - set the caret and other unset colors so the light theme has no white caret - tolerate float error in the toolbar fit check to stop spurious overflow - restore the full screen chrome hiding - clear stale camera frames when the server changes, keep them across a collapse - cache icon glyph ink bounds instead of scanning the atlas every frame - drop dead plot_bg and ImPlot globals, add a badge palette role - add toolbarMenu and use toolbarAction for the remove message item - derive the signal view button size seed from the font and style * cabana: remove remaining redundant comments --- openpilot/tools/cabana/ui/chart/chart.cc | 60 ++-- .../tools/cabana/ui/chart/chartswidget.cc | 80 +++-- .../tools/cabana/ui/chart/signalselector.cc | 6 +- openpilot/tools/cabana/ui/chart/tiplabel.cc | 9 +- openpilot/tools/cabana/ui/helpoverlay.cc | 7 +- openpilot/tools/cabana/ui/icons.h | 22 +- openpilot/tools/cabana/ui/mainwin.cc | 102 +++++-- openpilot/tools/cabana/ui/style.cc | 284 ------------------ openpilot/tools/cabana/ui/theme.cc | 174 +++++++++++ openpilot/tools/cabana/ui/theme.h | 49 +++ openpilot/tools/cabana/ui/util.cc | 182 +++++++++-- openpilot/tools/cabana/ui/util.h | 55 +--- .../tools/cabana/ui/widgets/binaryview.cc | 13 +- .../tools/cabana/ui/widgets/cameraview.cc | 14 +- .../tools/cabana/ui/widgets/detailwidget.cc | 158 +++++----- .../tools/cabana/ui/widgets/detailwidget.h | 2 +- .../tools/cabana/ui/widgets/historylog.cc | 19 +- .../tools/cabana/ui/widgets/messagebytes.cc | 2 +- .../tools/cabana/ui/widgets/messageswidget.cc | 40 +-- .../cabana/ui/widgets/scrollabletabbar.cc | 2 +- .../tools/cabana/ui/widgets/signalview.cc | 62 ++-- .../tools/cabana/ui/widgets/videowidget.cc | 70 ++--- .../tools/cabana/ui/widgets/videowidget.h | 1 - openpilot/tools/cabana/utils/util.h | 14 - 24 files changed, 736 insertions(+), 691 deletions(-) delete mode 100644 openpilot/tools/cabana/ui/style.cc create mode 100644 openpilot/tools/cabana/ui/theme.cc create mode 100644 openpilot/tools/cabana/ui/theme.h diff --git a/openpilot/tools/cabana/ui/chart/chart.cc b/openpilot/tools/cabana/ui/chart/chart.cc index 5d1389822e..ea54fc819b 100644 --- a/openpilot/tools/cabana/ui/chart/chart.cc +++ b/openpilot/tools/cabana/ui/chart/chart.cc @@ -19,7 +19,8 @@ const int AXIS_X_TOP_MARGIN = 4; const int X_TICK_COUNT = 5; const double MIN_ZOOM_SECONDS = 0.01; // 10ms const double EPSILON = 1e-6; -constexpr ImVec4 LAYOUT_MARGINS{8, 6, 8, 6}; // left, top, right, bottom +constexpr ImVec4 LAYOUT_MARGINS{0, 6, 0, 6}; // left, top, right, bottom +constexpr int LEGEND_SPACING = 5; static inline bool xLessThan(const ImPlotPoint &p, double x) { return p.x < (x - EPSILON); } static inline bool isNull(const ImPlotPoint &p) { return p.x == 0 && p.y == 0; } @@ -70,10 +71,10 @@ void ChartView::drawMenuActions() { // the buttons and their menus are drawn every frame, at the rects updateLayout() placed them at void ChartView::createToolButtons() { ImGui::SetCursorScreenPos(layout_.close_btn_rect.Min); - bool close_clicked = toolButton("close_btn", icon::X, "Remove Chart"); + bool close_clicked = iconButton("close_btn", icon::X_LG, "Remove Chart"); ImGui::SetCursorScreenPos(layout_.manage_btn_rect.Min); - if (toolButton("manage_btn", icon::LIST, "")) ImGui::OpenPopup("manage_menu"); + if (iconButton("manage_btn", icon::THREE_DOTS_VERTICAL, "")) ImGui::OpenPopup("manage_menu"); if (ImGui::BeginPopup("manage_menu")) { drawMenuActions(); ImGui::EndPopup(); @@ -136,27 +137,25 @@ void ChartView::updateLayout() { const ImVec2 grip = ImGui::CalcTextSize(icon::GRIP_HORIZONTAL); const ImVec2 top_left = layout_.rect.Min + ImVec2(LAYOUT_MARGINS.x, LAYOUT_MARGINS.y); layout_.move_icon_rect = ImRect(top_left, top_left + grip); - const ImVec2 pad = ImGui::GetStyle().FramePadding * 2; - const ImVec2 close_size = ImGui::CalcTextSize(icon::X) + pad; - const ImVec2 manage_size = ImGui::CalcTextSize(icon::LIST) + pad; - const ImVec2 close_min(layout_.rect.Max.x - LAYOUT_MARGINS.z - close_size.x, top_left.y); - layout_.close_btn_rect = ImRect(close_min, close_min + close_size); - const ImVec2 manage_min(close_min.x - manage_size.x - ImGui::GetStyle().ItemSpacing.x, top_left.y); - layout_.manage_btn_rect = ImRect(manage_min, manage_min + manage_size); + const ImVec2 btn_size(iconButtonWidth(), iconButtonWidth()); + const ImVec2 close_min(layout_.rect.Max.x - LAYOUT_MARGINS.z - btn_size.x, top_left.y); + layout_.close_btn_rect = ImRect(close_min, close_min + btn_size); + const ImVec2 manage_min(close_min.x - btn_size.x - ImGui::GetStyle().ItemInnerSpacing.x, top_left.y); + layout_.manage_btn_rect = ImRect(manage_min, manage_min + btn_size); ImFont *bold = boldFont(); const float font_size = ImGui::GetFontSize(); const float fm_height = ImGui::GetTextLineHeight(); const int marker_size = markerSize(); const int row_height = std::max(marker_size, fm_height) + fm_height + 3; // + the signal value line - const int legend_left = layout_.move_icon_rect.Max.x + LAYOUT_MARGINS.x; + const int legend_left = layout_.move_icon_rect.Max.x + LEGEND_SPACING; const int legend_right = std::max(layout_.manage_btn_rect.Min.x - LAYOUT_MARGINS.z, legend_left + 10); // layout legend entries left-to-right, wrapping between the move icon and the buttons layout_.legend_rects.clear(); int x = legend_left, y = top_left.y; for (auto &s : sigs_) { - int w = marker_size + 5 + bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, s.sig->name.c_str()).x + + int w = marker_size + LEGEND_SPACING + bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, s.sig->name.c_str()).x + ImGui::CalcTextSize(msgLabel(s.msg_id).c_str()).x; w = std::min(w, legend_right - legend_left); // keep oversized entries clear of the header buttons if (x + w > legend_right && x > legend_left) { @@ -531,13 +530,13 @@ void ChartView::paint() { drawStaticLayer(); if (can_drop_) { - ImGui::GetWindowDrawList()->AddRect(layout_.rect.Min, layout_.rect.Max, ImGui::GetColorU32(ImGuiCol_Header), 0.0f, 0, 4.0f); + ImGui::GetWindowDrawList()->AddRect(layout_.rect.Min, layout_.rect.Max, ImGui::GetColorU32(ImGuiCol_Header), ImGui::GetStyle().ChildRounding, 0, 4.0f); } } void ChartView::drawStaticLayer() { ImDrawList *painter = ImGui::GetWindowDrawList(); - painter->AddRectFilled(layout_.rect.Min, layout_.rect.Max, ImGui::GetColorU32(ImGuiCol_ChildBg)); + painter->AddRectFilled(layout_.rect.Min, layout_.rect.Max, ImGui::GetColorU32(ImGuiCol_ChildBg), ImGui::GetStyle().ChildRounding); ImGui::SetCursorScreenPos(layout_.move_icon_rect.Min); ImGui::InvisibleButton("grip", layout_.move_icon_rect.GetSize()); if (ImGui::IsItemActivated()) charts_widget_->startChartDrag(this, ImGui::GetMousePos()); @@ -555,23 +554,11 @@ void ChartView::drawAxes() { ImPlot::PushStyleVar(ImPlotStyleVar_PlotPadding, ImVec2(LAYOUT_MARGINS.x, AXIS_X_TOP_MARGIN)); ImPlot::PushStyleColor(ImPlotCol_PlotBg, ImVec4(0, 0, 0, 0)); ImPlot::PushStyleColor(ImPlotCol_FrameBg, ImVec4(0, 0, 0, 0)); - // every tick is a 1 px line in the text color at alpha 50, the edge ticks close the box, no tick marks. - // that alpha washes out on the dark base, so the dark theme draws opaque guides in a mid gray instead. - const bool dark = isDarkTheme(); - ImVec4 grid_color; - if (dark) { - grid_color = colorRgb(DarkTheme::light.r, DarkTheme::light.g, DarkTheme::light.b); - } else { - grid_color = ImGui::GetStyleColorVec4(ImGuiCol_Text); - grid_color.w = 50.0f / 255.0f; - } - ImPlot::PushStyleColor(ImPlotCol_AxisGrid, grid_color); - ImPlot::PushStyleColor(ImPlotCol_PlotBorder, grid_color); + ImPlot::PushStyleColor(ImPlotCol_PlotBorder, palette().grid); ImPlot::PushStyleColor(ImPlotCol_AxisTick, ImVec4(0, 0, 0, 0)); ImPlot::PushStyleColor(ImPlotCol_AxisText, ImGui::GetStyleColorVec4(ImGuiCol_Text)); ImPlot::PushStyleVar(ImPlotStyleVar_MajorTickLen, ImVec2(0, 0)); - // MajorGridSize is the per-axis line thickness; thicker guides read better on the dark base - ImPlot::PushStyleVar(ImPlotStyleVar_MajorGridSize, dark ? ImVec2(2.0f, 2.0f) : ImVec2(1.0f, 1.0f)); + ImPlot::PushStyleVar(ImPlotStyleVar_MajorGridSize, ImVec2(1.0f, 1.0f)); const ImPlotFlags flags = ImPlotFlags_NoTitle | ImPlotFlags_NoLegend | ImPlotFlags_NoMenus | ImPlotFlags_NoMouseText | ImPlotFlags_NoBoxSelect | ImPlotFlags_NoInputs | ImPlotFlags_NoFrame; const ImPlotAxisFlags axis_flags = ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight | ImPlotAxisFlags_NoSideSwitch | ImPlotAxisFlags_Lock; @@ -599,7 +586,7 @@ void ChartView::drawAxes() { drawForeground(); ImPlot::EndPlot(); } - ImPlot::PopStyleColor(6); + ImPlot::PopStyleColor(5); ImPlot::PopStyleVar(3); } @@ -632,7 +619,7 @@ void ChartView::drawLegend() { drawColorMarker(painter, r.Min, toImU32(s.color)); } - float x = r.Min.x + marker_size + 5; + float x = r.Min.x + marker_size + LEGEND_SPACING; const float text_y = r.GetCenter().y - font_size / 2.0f; addTextEllipsis(painter, bold, title_color, ImVec2(x, text_y), r.Max.x, s.sig->name); float name_w = std::min(bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, s.sig->name.c_str()).x, r.Max.x - x); @@ -641,7 +628,7 @@ void ChartView::drawLegend() { addTextEllipsis(painter, normal, msg_color, ImVec2(x, text_y), r.Max.x, msg); if (!s.visible) { // strike out const float y = r.GetCenter().y; - painter->AddLine(ImVec2(r.Min.x + marker_size + 5, y), ImVec2(std::min(x + ImGui::CalcTextSize(msg.c_str()).x, r.Max.x), y), title_color); + painter->AddLine(ImVec2(r.Min.x + marker_size + LEGEND_SPACING, y), ImVec2(std::min(x + ImGui::CalcTextSize(msg.c_str()).x, r.Max.x), y), title_color); } } } @@ -722,19 +709,19 @@ void ChartView::drawRubberBandTimeRange() { ImDrawList *painter = ImPlot::GetPlotDrawList(); // ImGuiCol_Header is translucent, so the 1px selection outline is drawn at full alpha const ImU32 highlight = withAlpha(ImGui::GetColorU32(ImGuiCol_Header), 255); - painter->AddRectFilled(rubber_rect_.Min, rubber_rect_.Max, withAlpha(highlight, 50)); - painter->AddRect(rubber_rect_.Min, rubber_rect_.Max, highlight); + painter->AddRectFilled(rubber_rect_.Min, rubber_rect_.Max, withAlpha(highlight, 50), ImGui::GetStyle().FrameRounding); + painter->AddRect(rubber_rect_.Min, rubber_rect_.Max, highlight, ImGui::GetStyle().FrameRounding); // time labels at the bottom corners (below the plot, so clip to the widget instead of the plot) const ImU32 white = IM_COL32_WHITE; - const ImU32 gray = IM_COL32(0xa0, 0xa0, 0xa4, 0xff); + const ImU32 badge = ImGui::GetColorU32(palette().badge); painter = ImGui::GetWindowDrawList(); painter->PushClipRect(layout_.rect.Min, layout_.rect.Max); for (const auto &pt : {rubber_rect_.GetBL(), rubber_rect_.GetBR()}) { std::string sec = formatNumber(secondsAtPoint(pt), 2); ImVec2 size = ImGui::CalcTextSize(sec.c_str()) + ImVec2(12, AXIS_X_TOP_MARGIN * 2); ImVec2 top_left = pt.x == rubber_rect_.Min.x ? ImVec2(pt.x - size.x, pt.y + 2) : ImVec2(pt.x, pt.y + 2); - painter->AddRectFilled(top_left, top_left + size, gray); + painter->AddRectFilled(top_left, top_left + size, badge, ImGui::GetStyle().FrameRounding); painter->AddText(top_left + ImVec2(6, AXIS_X_TOP_MARGIN), white, sec.c_str()); } painter->PopClipRect(); @@ -748,8 +735,7 @@ void ChartView::drawTimeline() { std::string time_str = formatNumber(cur_sec_, 2); ImVec2 time_str_size = ImGui::CalcTextSize(time_str.c_str()) + ImVec2(8, 2); ImVec2 time_str_pos(x - time_str_size.x / 2.0f, layout_.plot_area.Max.y + AXIS_X_TOP_MARGIN); - const bool dark = isDarkTheme(); - painter->AddRectFilled(time_str_pos, time_str_pos + time_str_size, dark ? IM_COL32(0x80, 0x80, 0x80, 0xff) : IM_COL32(0xa0, 0xa0, 0xa4, 0xff), 3.0f); + painter->AddRectFilled(time_str_pos, time_str_pos + time_str_size, ImGui::GetColorU32(palette().badge), ImGui::GetStyle().FrameRounding); painter->AddText(time_str_pos + ImVec2(4, 1), IM_COL32_WHITE, time_str.c_str()); } diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.cc b/openpilot/tools/cabana/ui/chart/chartswidget.cc index 546705d395..d10300cbe8 100644 --- a/openpilot/tools/cabana/ui/chart/chartswidget.cc +++ b/openpilot/tools/cabana/ui/chart/chartswidget.cc @@ -17,7 +17,6 @@ const int MAX_COLUMN_COUNT = 4; const int CHART_SPACING = 4; const int START_DRAG_DISTANCE = 10; -const float LAYOUT_HORIZONTAL_SPACING = 6.0f; const float MIN_RANGE_SLIDER_WIDTH = 40.0f; bool LogSlider::draw(const char *label, float width) { @@ -161,52 +160,44 @@ void ChartsWidget::setIsDocked(bool docked) { } void ChartsWidget::drawToolBar() { - beginToolbar(); float slider_width = 150.0f; const bool is_zoomed = can->timeRange().has_value(); // the labels are captured by reference, they outlive the draw calls below std::vector items; - items.push_back({toolbarButtonWidth(icon::PLUS_SQUARE), [this]() { - if (toolButton("new_plot_btn", icon::PLUS_SQUARE, "New Chart")) newChart(); + items.push_back({iconButtonWidth(), [this]() { + if (iconButton("new_plot_btn", icon::PLUS_LG, "New Chart")) newChart(); }}); - items.push_back({toolbarButtonWidth(icon::WINDOW_STACK), [this]() { - if (toolButton("new_tab_btn", icon::WINDOW_STACK, "New Tab")) newTab(); + items.push_back({iconButtonWidth(), [this]() { + if (iconButton("new_tab_btn", icon::WINDOW_PLUS, "New Tab")) newTab(); }}); + items.back().tight = true; const std::string title_label = "Charts: " + std::to_string(charts_.size()); - items.push_back({ImGui::CalcTextSize(title_label.c_str()).x + LAYOUT_HORIZONTAL_SPACING, [&title_label]() { + items.push_back({ImGui::CalcTextSize(title_label.c_str()).x, [&title_label]() { ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(title_label.c_str()); - ImGui::SameLine(0.0f, LAYOUT_HORIZONTAL_SPACING); - ImGui::Dummy(ImVec2(0.0f, 0.0f)); }}); const int type_count = (int)std::size(SERIES_TYPE_NAMES); const std::string chart_type_text = std::string("Type: ") + SERIES_TYPE_NAMES[std::clamp(settings.chart_series_type, 0, type_count - 1)]; - items.push_back({menuButtonWidth(chart_type_text), [this, &chart_type_text]() { - menuButton("chart_type", chart_type_text, "chart_type_menu"); - if (ImGui::BeginPopup("chart_type_menu")) { - for (int i = 0; i < type_count; ++i) { - if (ImGui::MenuItem(SERIES_TYPE_NAMES[i])) { - settings.chart_series_type = i; - settingChanged(); - } + auto chart_type_items = [this]() { + for (int i = 0; i < type_count; ++i) { + if (ImGui::MenuItem(SERIES_TYPE_NAMES[i], nullptr, settings.chart_series_type == i)) { + settings.chart_series_type = i; + settingChanged(); } - ImGui::EndPopup(); } - }}); + }; + items.push_back(toolbarMenu("chart_type", chart_type_text, "Type", chart_type_items)); const std::string columns_action_text = "Columns: " + std::to_string(column_count_); if (columns_action_visible_) { - items.push_back({menuButtonWidth(columns_action_text), [this, &columns_action_text]() { - menuButton("columns", columns_action_text, "columns_menu"); - if (ImGui::BeginPopup("columns_menu")) { - for (int i = 0; i < MAX_COLUMN_COUNT; ++i) { - if (ImGui::MenuItem(std::to_string(i + 1).c_str())) setColumnCount(i + 1); - } - ImGui::EndPopup(); + auto column_items = [this]() { + for (int i = 0; i < MAX_COLUMN_COUNT; ++i) { + if (ImGui::MenuItem(std::to_string(i + 1).c_str(), nullptr, column_count_ == i + 1)) setColumnCount(i + 1); } - }}); + }; + items.push_back(toolbarMenu("columns", columns_action_text, "Columns", column_items)); } // the spacer right aligns the rest @@ -221,36 +212,35 @@ void ChartsWidget::drawToolBar() { }}); slider_index = items.size(); items.push_back({slider_width, [this, &slider_width]() { - if (range_slider_.draw("##range_slider", slider_width)) setMaxChartRange(range_slider_.value()); + // Restore the slider width in overflow; the toolbar may have shrunk it. + const bool in_menu = ImGui::GetCurrentWindow()->Flags & ImGuiWindowFlags_Popup; + const float width = in_menu ? std::max(ImGui::GetContentRegionAvail().x, 150.0f) : slider_width; + if (range_slider_.draw("##range_slider", width)) setMaxChartRange(range_slider_.value()); ImGui::SetItemTooltip("Set the chart range"); }}); } else { char buf[64]; snprintf(buf, sizeof(buf), "%.2f-%.2f", can->timeRange()->first, can->timeRange()->second); reset_zoom_text = buf; - items.push_back({toolbarButtonWidth(icon::ARROW_COUNTERCLOCKWISE), [this]() { + items.push_back({iconButtonWidth(), [this]() { ImGui::BeginDisabled(!zoom_undo_stack_.canUndo()); - if (toolButton("undo_zoom", icon::ARROW_COUNTERCLOCKWISE, "Undo Zoom")) zoom_undo_stack_.undo(); + if (iconButton("undo_zoom", icon::ARROW_COUNTERCLOCKWISE, "Undo Zoom")) zoom_undo_stack_.undo(); ImGui::EndDisabled(); }}); - items.push_back({toolbarButtonWidth(icon::ARROW_CLOCKWISE), [this]() { + items.push_back({iconButtonWidth(), [this]() { ImGui::BeginDisabled(!zoom_undo_stack_.canRedo()); - if (toolButton("redo_zoom", icon::ARROW_CLOCKWISE, "Redo Zoom")) zoom_undo_stack_.redo(); + if (iconButton("redo_zoom", icon::ARROW_CLOCKWISE, "Redo Zoom")) zoom_undo_stack_.redo(); ImGui::EndDisabled(); }}); items.push_back({toolbarButtonWidth(std::string(icon::ZOOM_OUT) + " " + reset_zoom_text), [this, &reset_zoom_text]() { - if (toolButton("reset_zoom_btn", icon::ZOOM_OUT, "Reset Zoom", reset_zoom_text.c_str())) zoomReset(); + if (ImGui::Button((std::string(icon::ZOOM_OUT) + " " + reset_zoom_text + "###reset_zoom_btn").c_str())) zoomReset(); + ImGui::SetItemTooltip("Reset Zoom"); }}); } - items.push_back({toolbarButtonWidth(icon::X_SQUARE), [this]() { - ImGui::BeginDisabled(charts_.empty()); - if (toolButton("remove_all_btn", icon::X_SQUARE, "Remove all charts")) removeAll(); - ImGui::EndDisabled(); - }}); - const char *dock_btn_icon = is_docked_ ? icon::ARROW_UP_RIGHT_SQUARE : icon::ARROW_DOWN_LEFT_SQUARE; - items.push_back({toolbarButtonWidth(dock_btn_icon), [this, dock_btn_icon]() { - if (toolButton("dock_btn", dock_btn_icon, is_docked_ ? "Float the charts window" : "Dock the charts window")) toggleChartsDocking(); - }}); + items.push_back(toolbarAction("remove_all_btn", icon::TRASH, "Remove all charts", [this]() { removeAll(); }, !charts_.empty())); + const char *dock_btn_icon = is_docked_ ? icon::BOX_ARROW_UP_RIGHT : icon::BOX_ARROW_IN_DOWN_LEFT; + const char *dock_label = is_docked_ ? "Float the charts window" : "Dock the charts window"; + items.push_back(toolbarAction("dock_btn", dock_btn_icon, dock_label, [this]() { toggleChartsDocking(); }, true, true)); // the slider shrinks first, the buttons stay pinned to the right edge if (slider_index != (size_t)-1) { @@ -261,7 +251,6 @@ void ChartsWidget::drawToolBar() { } } drawToolbar(items, spacer_index); - endToolbar(); } void ChartsWidget::settingChanged() { @@ -619,7 +608,8 @@ void ChartsWidget::draw() { void ChartsContainer::draw() { ImGuiWindow *window = ImGui::GetCurrentWindow(); const ImVec2 start = ImGui::GetCursorScreenPos(); - geometry_ = ImRect(start, start + ImVec2(window->InnerRect.GetWidth(), 0)); + const float width_avail = window->InnerRect.GetWidth() - (window->ScrollbarY ? ImGui::GetStyle().ItemInnerSpacing.x : 0.0f); + geometry_ = ImRect(start, start + ImVec2(width_avail, 0)); charts_widget_->updateLayout(); const int n = std::max(charts_widget_->current_column_count_, 1); @@ -653,7 +643,7 @@ void ChartsContainer::drawDropIndicator() { r.Max.y = r.Min.y + h; } - ImGui::GetWindowDrawList()->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_Header)); + ImGui::GetWindowDrawList()->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_Header), ImGui::GetStyle().FrameRounding); } } diff --git a/openpilot/tools/cabana/ui/chart/signalselector.cc b/openpilot/tools/cabana/ui/chart/signalselector.cc index f0f427222e..cab1de0816 100644 --- a/openpilot/tools/cabana/ui/chart/signalselector.cc +++ b/openpilot/tools/cabana/ui/chart/signalselector.cc @@ -32,7 +32,7 @@ bool SignalSelector::draw() { return false; } - const float btn_w = ImGui::GetFrameHeight() + 8.0f; + const float btn_w = iconButtonWidth(); const float column_w = (ImGui::GetContentRegionAvail().x - btn_w - ImGui::GetStyle().ItemSpacing.x * 2) / 2; // the selected list spans the combo row too; both lists end above the Ok/Cancel row const float lists_h = ImGui::GetContentRegionAvail().y - ImGui::GetFrameHeightWithSpacing() * 3; @@ -67,10 +67,10 @@ bool SignalSelector::draw() { ImGui::BeginGroup(); ImGui::Dummy(ImVec2(btn_w, (lists_h + ImGui::GetFrameHeightWithSpacing() * 2) / 2 - ImGui::GetFrameHeight())); ImGui::BeginDisabled(available_row_ == -1); - bool add_clicked = ImGui::Button(icon::CHEVRON_RIGHT, ImVec2(btn_w, 0)); + bool add_clicked = iconButton("add", icon::CHEVRON_RIGHT, "Add"); ImGui::EndDisabled(); ImGui::BeginDisabled(selected_row_ == -1); - bool remove_clicked = ImGui::Button(icon::CHEVRON_LEFT, ImVec2(btn_w, 0)); + bool remove_clicked = iconButton("remove", icon::CHEVRON_LEFT, "Remove"); ImGui::EndDisabled(); ImGui::EndGroup(); diff --git a/openpilot/tools/cabana/ui/chart/tiplabel.cc b/openpilot/tools/cabana/ui/chart/tiplabel.cc index 896abb7479..68889b7886 100644 --- a/openpilot/tools/cabana/ui/chart/tiplabel.cc +++ b/openpilot/tools/cabana/ui/chart/tiplabel.cc @@ -59,11 +59,8 @@ void TipLabel::draw() { if (!visible_) return; ImDrawList *p = ImGui::GetForegroundDrawList(); - const bool dark = isDarkTheme(); - const ImU32 bg = dark ? ImGui::GetColorU32(ImGuiCol_PopupBg) : ImGui::GetColorU32(ImGuiCol_ChildBg); - const ImU32 fg = dark ? ImGui::GetColorU32(ImGuiCol_Text) : IM_COL32(0x40, 0x40, 0x44, 0xff); // filled panel with a 1px frame - p->AddRectFilled(pos_, pos_ + size_, bg); - p->AddRect(pos_, pos_ + size_, ImGui::GetColorU32(ImGuiCol_Border)); - layoutLines(p, pos_ + ImVec2(MARGIN, MARGIN), fg); + p->AddRectFilled(pos_, pos_ + size_, ImGui::GetColorU32(ImGuiCol_PopupBg), ImGui::GetStyle().PopupRounding); + p->AddRect(pos_, pos_ + size_, ImGui::GetColorU32(ImGuiCol_Border), ImGui::GetStyle().PopupRounding); + layoutLines(p, pos_ + ImVec2(MARGIN, MARGIN), ImGui::GetColorU32(ImGuiCol_Text)); } diff --git a/openpilot/tools/cabana/ui/helpoverlay.cc b/openpilot/tools/cabana/ui/helpoverlay.cc index 5ae3dab6f6..1fe061a382 100644 --- a/openpilot/tools/cabana/ui/helpoverlay.cc +++ b/openpilot/tools/cabana/ui/helpoverlay.cc @@ -170,9 +170,8 @@ void HelpOverlay::draw() { if (!work_rect.Contains(center)) continue; // a torn off panel is in another viewport const ImVec2 min(center.x - size.x * 0.5f - 8.0f, center.y - size.y * 0.5f - 8.0f); const ImVec2 max(center.x + size.x * 0.5f + 8.0f, center.y + size.y * 0.5f + 8.0f); - // pale yellow in the light theme - const ImU32 tooltip_base = isDarkTheme() ? ImGui::GetColorU32(ImGuiCol_PopupBg) : IM_COL32(255, 255, 220, 255); - dl->AddRectFilled(min, max, tooltip_base); + dl->AddRectFilled(min, max, ImGui::GetColorU32(ImGuiCol_PopupBg), ImGui::GetStyle().PopupRounding); + dl->AddRect(min, max, ImGui::GetColorU32(ImGuiCol_Border), ImGui::GetStyle().PopupRounding); float y = min.y + 8.0f; for (const auto &line : lines) { float x = min.x + 8.0f; @@ -182,7 +181,7 @@ void HelpOverlay::draw() { if (r.swatch) { dl->AddRectFilled(ImVec2(x + 2, y + 3), ImVec2(x + font_size - 2, y + font_size - 1), color); } else { - if (r.chip) dl->AddRectFilled(ImVec2(x, y), ImVec2(x + w, y + font_size), IM_COL32(211, 211, 211, 255)); // lightGray + if (r.chip) dl->AddRectFilled(ImVec2(x, y), ImVec2(x + w, y + font_size), ImGui::GetColorU32(ImGuiCol_Button), 3.0f); dl->AddText(r.bold ? bold_font : font, font_size, ImVec2(x, y), color, r.text.c_str()); } x += w; diff --git a/openpilot/tools/cabana/ui/icons.h b/openpilot/tools/cabana/ui/icons.h index 7a61d02c09..89b8300862 100644 --- a/openpilot/tools/cabana/ui/icons.h +++ b/openpilot/tools/cabana/ui/icons.h @@ -1,42 +1,40 @@ #pragma once -// bootstrap icon glyphs, merged into the fonts by style.cc +// Bootstrap Icons codepoints; loadFonts() merges the icon font into each text font. namespace icon { constexpr const char ARROW_CLOCKWISE[] = "\xef\x84\x96"; constexpr const char ARROW_COUNTERCLOCKWISE[] = "\xef\x84\x97"; -constexpr const char ARROW_DOWN_LEFT_SQUARE[] = "\xef\x84\x9c"; -constexpr const char ARROW_UP_RIGHT_SQUARE[] = "\xef\x85\x82"; constexpr const char CHEVRON_LEFT[] = "\xef\x8a\x84"; constexpr const char CHEVRON_RIGHT[] = "\xef\x8a\x85"; constexpr const char ASPECT_RATIO[] = "\xef\x85\x90"; constexpr const char ASPECT_RATIO_FILL[] = "\xef\x85\x8f"; -constexpr const char DASH[] = "\xef\x8b\xaa"; -constexpr const char DASH_SQUARE[] = "\xef\x8b\xa8"; constexpr const char EXCLAMATION_TRIANGLE[] = "\xef\x8c\xba"; constexpr const char FAST_FORWARD[] = "\xef\x9f\xb3"; constexpr const char FILETYPE_CSV[] = "\xef\x9d\x83"; constexpr const char FOLDER[] = "\xef\x8f\x91"; constexpr const char FILE_EARMARK[] = "\xef\x8d\xa9"; constexpr const char FILE_EARMARK_RULED[] = "\xef\x8e\x84"; -constexpr const char PLUS_SQUARE[] = "\xef\x93\xbc"; constexpr const char GRAPH_UP[] = "\xef\x8f\xb2"; constexpr const char GRIP_HORIZONTAL[] = "\xef\x8f\xbd"; constexpr const char INFO_CIRCLE[] = "\xef\x90\xb0"; -constexpr const char LIST[] = "\xef\x91\xb9"; constexpr const char PAUSE[] = "\xef\x93\x83"; constexpr const char PENCIL[] = "\xef\x93\x8b"; constexpr const char PLAY[] = "\xef\x93\xb4"; -constexpr const char PLUS[] = "\xef\x93\xbe"; -constexpr const char RAQUO[] = "\xc2\xbb"; // U+00BB, not a bootstrap icon: the toolbar extension button constexpr const char REPEAT[] = "\xef\xa0\x93"; constexpr const char REPEAT_1[] = "\xef\xa0\x92"; constexpr const char REWIND[] = "\xef\xa0\x98"; constexpr const char SKIP_END[] = "\xef\x95\x97"; constexpr const char STOPWATCH[] = "\xef\x96\x96"; constexpr const char THREE_DOTS[] = "\xef\x97\x94"; -constexpr const char WINDOW_STACK[] = "\xef\x9b\x92"; -constexpr const char X[] = "\xef\x98\xaa"; constexpr const char X_LG[] = "\xef\x99\x99"; -constexpr const char X_SQUARE[] = "\xef\x98\xa8"; constexpr const char ZOOM_OUT[] = "\xef\x98\xad"; +constexpr const char PLUS_LG[] = "\xef\x99\x8d"; +constexpr const char DASH_LG[] = "\xef\x98\xbb"; +constexpr const char TRASH[] = "\xef\x9e\x8b"; +constexpr const char ARROWS_COLLAPSE[] = "\xef\x85\x8b"; +constexpr const char BOX_ARROW_UP_RIGHT[] = "\xef\x87\x85"; +constexpr const char BOX_ARROW_IN_DOWN_LEFT[] = "\xef\x86\xba"; +constexpr const char WINDOW_PLUS[] = "\xef\x9b\x90"; +constexpr const char CHEVRON_DOUBLE_RIGHT[] = "\xef\x8a\x80"; +constexpr const char THREE_DOTS_VERTICAL[] = "\xef\x97\x93"; } // namespace icon diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index a2969defcc..e5490432a2 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -132,14 +132,46 @@ void MainWindow::drawFileMenu() { if (ImGui::MenuItem("Exit", "Ctrl+Q")) close(); } +namespace { +bool beginTopMenu(const char *label, bool enabled = true) { + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImGui::GetColorU32(ImGuiCol_Header)); + const bool open = ImGui::BeginMenu(label, enabled); + ImGui::PopStyleColor(); + if (open) { + // Erase the popup's rounded top border so it joins the menu bar's separator. + const ImGuiStyle &style = ImGui::GetStyle(); + const ImGuiWindow *w = ImGui::GetCurrentWindow(); + const float r = style.PopupRounding, b = style.PopupBorderSize; + const ImVec2 min = w->Pos, max(w->Pos.x + w->Size.x, w->Pos.y + w->Size.y); + const ImU32 bg = ImGui::GetColorU32(ImGuiCol_PopupBg), border = ImGui::GetColorU32(ImGuiCol_Border); + ImDrawList *dl = w->DrawList; + dl->PushClipRect(min, max, false); // the window's own clip rect excludes its border + dl->AddRectFilled(min, ImVec2(max.x, min.y + r), bg); + dl->AddRectFilled(ImVec2(min.x, min.y), ImVec2(min.x + b, min.y + r), border); + dl->AddRectFilled(ImVec2(max.x - b, min.y), ImVec2(max.x, min.y + r), border); + dl->PopClipRect(); + } + return open; +} +} // namespace + void MainWindow::drawMenuBar() { - if (!ImGui::BeginMainMenuBar()) return; - if (ImGui::BeginMenu("File")) { + // Avoid a double border with the separator drawn below. + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + const bool open = ImGui::BeginMainMenuBar(); + ImGui::PopStyleVar(); + if (!open) return; + { + const ImVec2 min = ImGui::GetWindowPos(); + const ImVec2 max(min.x + ImGui::GetWindowWidth(), min.y + ImGui::GetWindowHeight()); + ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(min.x, max.y - 1.0f), max, ImGui::GetColorU32(ImGuiCol_Border)); + } + if (beginTopMenu("File")) { drawFileMenu(); ImGui::EndMenu(); } - if (ImGui::BeginMenu("Edit")) { + if (beginTopMenu("Edit")) { auto stack = UndoStack::instance(); const std::string undo_text = stack->canUndo() ? "Undo " + stack->undoText() : "Undo"; const std::string redo_text = stack->canRedo() ? "Redo " + stack->redoText() : "Redo"; @@ -148,7 +180,7 @@ void MainWindow::drawMenuBar() { ImGui::EndMenu(); } - if (ImGui::BeginMenu("View")) { + if (beginTopMenu("View")) { if (ImGui::MenuItem("Full Screen", "Ctrl+F11")) toggleFullScreen(); ImGui::Separator(); ImGui::MenuItem(messages_widget_ ? messages_widget_->title().c_str() : "MESSAGES", nullptr, &messages_visible_); @@ -162,13 +194,13 @@ void MainWindow::drawMenuBar() { ImGui::EndMenu(); } - if (ImGui::BeginMenu("Tools", hasStream())) { + if (beginTopMenu("Tools", hasStream())) { if (ImGui::MenuItem("Find Similar Bits")) findSimilarBits(); if (ImGui::MenuItem("Find Signal")) findSignal(); ImGui::EndMenu(); } - if (ImGui::BeginMenu("Help")) { + if (beginTopMenu("Help")) { if (ImGui::MenuItem("Help", "F1")) toggleHelp(); ImGui::EndMenu(); } @@ -710,6 +742,8 @@ void MainWindow::handleShortcuts() { void MainWindow::drawStatusBar() { ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::GetStyle().Colors[ImGuiCol_MenuBarBg]); ImGui::BeginChild("status_bar", ImVec2(0, ImGui::GetFrameHeight()), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar); + const ImVec2 min = ImGui::GetWindowPos(); + ImGui::GetWindowDrawList()->AddRectFilled(min, ImVec2(min.x + ImGui::GetWindowWidth(), min.y + 1.0f), ImGui::GetColorU32(ImGuiCol_Border)); // a borderless child gets no WindowPadding, so both ends sit flush against the edge and clip. Inset by // WindowPadding.x, which lines the text up with the content of the docked panels above (the messages table). const float width = ImGui::GetContentRegionAvail().x; @@ -771,6 +805,8 @@ void MainWindow::drawDockspace() { // the status bar sits below the dockspace: reserve its height plus the item spacing between the two, // otherwise the host window is a few pixels taller than the viewport and scrolls const float status_height = full_screen_ ? 0.0f : ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.y; + const float top_gap = full_screen_ ? 0.0f : ImGui::GetStyle().ItemSpacing.y; + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + top_gap); const ImVec2 dock_size(ImGui::GetContentRegionAvail().x, ImGui::GetContentRegionAvail().y - status_height); const ImGuiID dock_id = ImGui::GetID("cabana_dockspace"); if (reset_layout_ || ImGui::DockBuilderGetNode(dock_id) == nullptr) { @@ -810,12 +846,19 @@ void setNextPanelClass() { window_class.DockNodeFlagsOverrideSet = ImGuiDockNodeFlags_NoWindowMenuButton; ImGui::SetNextWindowClass(&window_class); } + +bool beginPanel(const char *name, bool *open, ImGuiWindowFlags flags = 0) { + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + const bool visible = ImGui::Begin(name, open, flags); + ImGui::PopStyleVar(); + return visible; +} } // namespace void MainWindow::drawMessagesPanel() { const std::string name = messages_widget_->title() + MESSAGES_PANEL_ID; setNextPanelClass(); - if (ImGui::Begin(name.c_str(), &messages_visible_)) { + if (beginPanel(name.c_str(), &messages_visible_)) { help_overlay_.add(messages_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); messages_widget_->draw(); } @@ -827,7 +870,7 @@ void MainWindow::drawMessagesPanel() { void MainWindow::drawVideoPanel() { const std::string name = video_dock_title_ + VIDEO_PANEL; setNextPanelClass(); - const bool video_open = ImGui::Begin(name.c_str(), &video_visible_); + const bool video_open = beginPanel(name.c_str(), &video_visible_); const bool floating = floatingOut(); if (!video_open) { video_widget_->setVisible(false); // the dock is collapsed or tabbed behind another one, like hideEvent @@ -841,11 +884,23 @@ void MainWindow::drawVideoPanel() { const float video_hint = video_splitter_ratio_ >= 0.0f ? avail.y * video_splitter_ratio_ : default_h; float video_h = charts_floating_ ? avail.y : std::clamp(video_hint, 0.0f, avail.y - 1.0f); if (live) video_h = default_h; // display video at minimum size. - // dragging below half of the minimum size collapses the video, it never shrinks below it otherwise + // Collapse panes below half their minimum height to keep partially clipped controls out of view. + bool charts_collapsed = false; + const float splitter_h = ImGui::GetStyle().WindowPadding.x * 2.0f + 2.0f; if (!charts_floating_ && !live) { const float min_h = std::min(video_widget_->sizeHintHeight() + video_padding, avail.y - 1.0f); video_h = video_h < min_h / 2 ? 0.0f : std::max(video_h, min_h); + const float charts_min_h = ImGui::GetFrameHeight() + video_padding + ImGui::GetStyle().ChildBorderSize * 2.0f; + const float charts_h = avail.y - video_h - splitter_h; + if (charts_h < charts_min_h / 2) { + charts_collapsed = true; + video_h = avail.y - splitter_h; + } else if (charts_h < charts_min_h) { + video_h = avail.y - splitter_h - charts_min_h; + } } + // The splitter provides the gap; extra ItemSpacing would leave an undraggable strip. + if (!charts_floating_) ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); if (video_h > 0.0f) { ImGui::BeginChild("video", ImVec2(0, video_h), ImGuiChildFlags_Borders); help_overlay_.add(video_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); @@ -855,21 +910,26 @@ void MainWindow::drawVideoPanel() { video_widget_->setVisible(false); // the splitter collapsed the video: stop the vipc thread } if (!charts_floating_) { - // the gap between the video and the charts is the same as the padding at the sides - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); - ImGui::InvisibleButton("##splitter", ImVec2(-1.0f, ImGui::GetStyle().WindowPadding.x)); - if (ImGui::IsItemActive() && !live) { + ImGui::InvisibleButton("##splitter", ImVec2(-1.0f, splitter_h)); + const bool splitter_hovered = ImGui::IsItemHovered() && !live, splitter_active = ImGui::IsItemActive() && !live; + if (splitter_active) { // the size of the video is the position of the handle inside the splitter const float top = ImGui::GetWindowPos().y + ImGui::GetCursorStartPos().y; video_splitter_ratio_ = std::clamp((ImGui::GetMousePos().y - top) / avail.y, 0.0f, 1.0f); } - if (ImGui::IsItemHovered() && !live) ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); - // the chart list scrolls in its own child, the container itself never scrolls - ImGui::BeginChild("charts", ImVec2(0, 0), ImGuiChildFlags_Borders, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + if (splitter_hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); + const ImRect splitter(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + const float line_y = std::floor(splitter.GetCenter().y) - 1.0f; + ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(splitter.Min.x, line_y), ImVec2(splitter.Max.x, line_y + 2.0f), + ImGui::GetColorU32(splitter_active ? ImGuiCol_SeparatorActive : splitter_hovered ? ImGuiCol_SeparatorHovered : ImGuiCol_Border)); ImGui::PopStyleVar(); - help_overlay_.add(charts_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); - charts_widget_->draw(); - ImGui::EndChild(); + if (!charts_collapsed) { + // the chart list scrolls in its own child, the container itself never scrolls + ImGui::BeginChild("charts", ImVec2(0, 0), ImGuiChildFlags_Borders, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + help_overlay_.add(charts_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); + charts_widget_->draw(); + ImGui::EndChild(); + } } } ImGui::End(); @@ -893,11 +953,13 @@ void MainWindow::draw() { drawDockspace(); // the central widget has no scrollbars of its own (the views inside scroll) - if (ImGui::Begin(CENTER_PANEL, nullptr, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { + if (beginPanel(CENTER_PANEL, nullptr, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { + ImGui::BeginChild("center", ImVec2(0, 0), ImGuiChildFlags_Borders, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); center_widget_.draw(); if (auto *detail = center_widget_.getDetailWidget(); detail && help_overlay_.visible()) { for (const auto &[text, rect] : detail->helpRects()) help_overlay_.add(text, rect); } + ImGui::EndChild(); } ImGui::End(); if (messages_widget_ && messages_visible_) drawMessagesPanel(); diff --git a/openpilot/tools/cabana/ui/style.cc b/openpilot/tools/cabana/ui/style.cc deleted file mode 100644 index a006dbc66d..0000000000 --- a/openpilot/tools/cabana/ui/style.cc +++ /dev/null @@ -1,284 +0,0 @@ -#include "tools/cabana/ui/app.h" - -#include -#include -#include - -#include "implot.h" -#include "tools/cabana/core/settings.h" -#include "tools/cabana/settings.h" -#include "tools/cabana/ui/util.h" -#include "tools/cabana/utils/util.h" - -namespace fs = std::filesystem; - -namespace { -bool g_dark = false; -ImFont *g_ui_font = nullptr; -ImFont *g_bold_font = nullptr; -ImFont *g_mono_font = nullptr; -ImFont *g_large_font = nullptr; - -void addIconFont(float size, ImFont *base) { - ImFontConfig cfg; - cfg.MergeMode = base != nullptr; - cfg.GlyphMinAdvanceX = size; - if (base != nullptr) { - ImFontBaked *baked = base->GetFontBaked(size); - const float center = baked != nullptr ? (baked->Ascent + baked->Descent) * 0.5f : size * 0.5f; - cfg.GlyphOffset.y = std::round(size * 0.5f - center); - } - static const ImWchar ranges[] = {0xF000, 0xF8FF, 0}; - ImGui::GetIO().Fonts->AddFontFromFileTTF(BOOTSTRAP_ICONS_TTF, size, &cfg, ranges); -} - -ImFont *addFont(const fs::path &path, float size) { - ImFontConfig cfg; - cfg.OversampleH = 2; - cfg.OversampleV = 2; - ImFont *font = ImGui::GetIO().Fonts->AddFontFromFileTTF(path.c_str(), size, &cfg); - if (font != nullptr) addIconFont(size, font); - return font; -} -} // namespace - -void loadFonts() { - ImGuiIO &io = ImGui::GetIO(); - const fs::path fonts = fs::path(CABANA_FONTS_DIR); - g_ui_font = addFont(fonts / "Inter-Regular.ttf", 16.0f); - g_bold_font = addFont(fonts / "Inter-SemiBold.ttf", 16.0f); - g_mono_font = addFont(fonts / "JetBrainsMono-Medium.ttf", 15.0f); - g_large_font = addFont(fonts / "Inter-Bold.ttf", 50.0f); - if (g_ui_font != nullptr) io.FontDefault = g_ui_font; - if (g_bold_font == nullptr) g_bold_font = g_ui_font; - if (g_mono_font == nullptr) g_mono_font = g_ui_font; - if (g_large_font == nullptr) g_large_font = g_bold_font; -} - -void applyTheme(int theme) { - const bool dark = theme == DARK_THEME; - g_dark = dark; - if (dark) { - ImGui::StyleColorsDark(); - ImPlot::StyleColorsDark(); - } else { - ImGui::StyleColorsLight(); - ImPlot::StyleColorsLight(); - } - - ImGuiStyle &style = ImGui::GetStyle(); - style.WindowRounding = 0.0f; - style.ChildRounding = 0.0f; - style.PopupRounding = 0.0f; - style.FrameRounding = 2.0f; - style.GrabRounding = 2.0f; - style.ScrollbarRounding = 2.0f; - style.TabRounding = 2.0f; - style.WindowBorderSize = 1.0f; - style.FrameBorderSize = 1.0f; - style.TabBorderSize = 1.0f; - style.WindowPadding = ImVec2(8.0f, 7.0f); - style.FramePadding = ImVec2(6.0f, 3.0f); - style.ItemSpacing = ImVec2(8.0f, 5.0f); - style.ScrollbarSize = 14.0f; - style.GrabMinSize = 13.0f; - - auto c = [](const CabanaColor &col, float a = 1.0f) { return colorRgb(col.r, col.g, col.b, a); }; - ImVec4 *colors = style.Colors; - if (dark) { - const ImVec4 highlight = c(DarkTheme::highlight); - const ImVec4 outline = colorRgb(0x26, 0x26, 0x26); - colors[ImGuiCol_WindowBg] = c(DarkTheme::window); - colors[ImGuiCol_ChildBg] = c(DarkTheme::base); - colors[ImGuiCol_PopupBg] = c(DarkTheme::window); - colors[ImGuiCol_MenuBarBg] = c(DarkTheme::window); - colors[ImGuiCol_DockingEmptyBg] = c(DarkTheme::window); - colors[ImGuiCol_Text] = c(DarkTheme::text); - colors[ImGuiCol_TextDisabled] = c(DarkTheme::disabled_text); - colors[ImGuiCol_Border] = outline; - colors[ImGuiCol_BorderShadow] = colorRgb(0, 0, 0, 0.0f); - colors[ImGuiCol_FrameBg] = c(DarkTheme::base); - colors[ImGuiCol_FrameBgHovered] = colorRgb(0x1f, 0x1f, 0x1f); - colors[ImGuiCol_FrameBgActive] = colorRgb(0x24, 0x24, 0x24); - colors[ImGuiCol_Button] = colorRgb(0x5e, 0x5e, 0x5e); - colors[ImGuiCol_ButtonHovered] = colorRgb(0x6a, 0x6a, 0x6a); - colors[ImGuiCol_ButtonActive] = colorRgb(0x52, 0x52, 0x52); - colors[ImGuiCol_Header] = highlight; - colors[ImGuiCol_HeaderHovered] = c(DarkTheme::highlight, 0.8f); - colors[ImGuiCol_HeaderActive] = highlight; - colors[ImGuiCol_CheckMark] = c(DarkTheme::bright_text); - colors[ImGuiCol_SliderGrab] = colorRgb(0x6a, 0x6a, 0x6a); - colors[ImGuiCol_SliderGrabActive] = colorRgb(0x80, 0x80, 0x80); - colors[ImGuiCol_ScrollbarBg] = c(DarkTheme::window); - colors[ImGuiCol_ScrollbarGrab] = colorRgb(0x5a, 0x5a, 0x5a); - colors[ImGuiCol_ScrollbarGrabHovered] = colorRgb(0x6a, 0x6a, 0x6a); - colors[ImGuiCol_ScrollbarGrabActive] = colorRgb(0x7a, 0x7a, 0x7a); - colors[ImGuiCol_Separator] = outline; - colors[ImGuiCol_SeparatorHovered] = c(DarkTheme::highlight, 0.6f); - colors[ImGuiCol_SeparatorActive] = highlight; - colors[ImGuiCol_ResizeGrip] = colorRgb(0, 0, 0, 0.0f); - colors[ImGuiCol_ResizeGripHovered] = c(DarkTheme::highlight, 0.6f); - colors[ImGuiCol_ResizeGripActive] = highlight; - colors[ImGuiCol_Tab] = colorRgb(0x2c, 0x2c, 0x2c); - colors[ImGuiCol_TabHovered] = colorRgb(0x3a, 0x3a, 0x3a); - colors[ImGuiCol_TabSelected] = c(DarkTheme::base); - colors[ImGuiCol_TabSelectedOverline] = highlight; - colors[ImGuiCol_TabDimmed] = colorRgb(0x2c, 0x2c, 0x2c); - colors[ImGuiCol_TabDimmedSelected] = c(DarkTheme::base); - colors[ImGuiCol_TabDimmedSelectedOverline] = colorRgb(0, 0, 0, 0.0f); - colors[ImGuiCol_TitleBg] = c(DarkTheme::window); - colors[ImGuiCol_TitleBgActive] = c(DarkTheme::window); - colors[ImGuiCol_TitleBgCollapsed] = c(DarkTheme::window); - colors[ImGuiCol_TableHeaderBg] = c(DarkTheme::window); - colors[ImGuiCol_TableBorderStrong] = outline; - colors[ImGuiCol_TableBorderLight] = colorRgb(0x2c, 0x2c, 0x2c); - colors[ImGuiCol_TableRowBg] = colorRgb(0, 0, 0, 0.0f); - colors[ImGuiCol_TableRowBgAlt] = colorRgb(0xff, 0xff, 0xff, 0.06f); - colors[ImGuiCol_TextSelectedBg] = c(DarkTheme::highlight, 0.6f); - colors[ImGuiCol_DockingPreview] = c(DarkTheme::highlight, 0.5f); - colors[ImGuiCol_NavCursor] = highlight; - colors[ImGuiCol_PlotLines] = c(DarkTheme::text); - colors[ImGuiCol_PlotHistogram] = highlight; - colors[ImGuiCol_DragDropTarget] = highlight; - } else { - const ImVec4 window = colorRgb(0xef, 0xef, 0xef); - const ImVec4 base = colorRgb(0xff, 0xff, 0xff); - const ImVec4 outline = colorRgb(0xab, 0xab, 0xab); - const ImVec4 highlight = colorRgb(0x30, 0x8c, 0xc6); - colors[ImGuiCol_WindowBg] = window; - colors[ImGuiCol_ChildBg] = base; - colors[ImGuiCol_PopupBg] = colorRgb(0xf8, 0xf8, 0xf8); - colors[ImGuiCol_MenuBarBg] = window; - colors[ImGuiCol_DockingEmptyBg] = window; - colors[ImGuiCol_Text] = colorRgb(0x00, 0x00, 0x00); - colors[ImGuiCol_TextDisabled] = colorRgb(0xbe, 0xbe, 0xbe); - colors[ImGuiCol_Border] = outline; - colors[ImGuiCol_BorderShadow] = colorRgb(0, 0, 0, 0.0f); - colors[ImGuiCol_FrameBg] = base; - colors[ImGuiCol_FrameBgHovered] = colorRgb(0xf7, 0xf7, 0xf7); - colors[ImGuiCol_FrameBgActive] = colorRgb(0xef, 0xef, 0xef); - colors[ImGuiCol_Button] = colorRgb(0xe4, 0xe4, 0xe4); - colors[ImGuiCol_ButtonHovered] = colorRgb(0xec, 0xec, 0xec); - colors[ImGuiCol_ButtonActive] = colorRgb(0xd0, 0xd0, 0xd0); - colors[ImGuiCol_Header] = highlight; - colors[ImGuiCol_HeaderHovered] = colorRgb(0x30, 0x8c, 0xc6, 0.8f); - colors[ImGuiCol_HeaderActive] = highlight; - colors[ImGuiCol_CheckMark] = colorRgb(0x3b, 0x3b, 0x3b); - colors[ImGuiCol_SliderGrab] = colorRgb(0xd8, 0xd8, 0xd8); - colors[ImGuiCol_SliderGrabActive] = colorRgb(0xc4, 0xc4, 0xc4); - colors[ImGuiCol_ScrollbarBg] = window; - colors[ImGuiCol_ScrollbarGrab] = colorRgb(0xc8, 0xc8, 0xc8); - colors[ImGuiCol_ScrollbarGrabHovered] = colorRgb(0xb4, 0xb4, 0xb4); - colors[ImGuiCol_ScrollbarGrabActive] = colorRgb(0xa0, 0xa0, 0xa0); - colors[ImGuiCol_Separator] = outline; - colors[ImGuiCol_SeparatorHovered] = colorRgb(0x30, 0x8c, 0xc6, 0.6f); - colors[ImGuiCol_SeparatorActive] = highlight; - colors[ImGuiCol_ResizeGrip] = colorRgb(0, 0, 0, 0.0f); - colors[ImGuiCol_ResizeGripHovered] = colorRgb(0x30, 0x8c, 0xc6, 0.6f); - colors[ImGuiCol_ResizeGripActive] = highlight; - colors[ImGuiCol_Tab] = colorRgb(0xdc, 0xdc, 0xdc); - colors[ImGuiCol_TabHovered] = colorRgb(0xf5, 0xf5, 0xf5); - colors[ImGuiCol_TabSelected] = base; - colors[ImGuiCol_TabSelectedOverline] = highlight; - colors[ImGuiCol_TabDimmed] = colorRgb(0xdc, 0xdc, 0xdc); - colors[ImGuiCol_TabDimmedSelected] = base; - colors[ImGuiCol_TabDimmedSelectedOverline] = colorRgb(0, 0, 0, 0.0f); - colors[ImGuiCol_TitleBg] = window; - colors[ImGuiCol_TitleBgActive] = window; - colors[ImGuiCol_TitleBgCollapsed] = window; - colors[ImGuiCol_TableHeaderBg] = colorRgb(0xf2, 0xf2, 0xf2); - colors[ImGuiCol_TableBorderStrong] = outline; - colors[ImGuiCol_TableBorderLight] = colorRgb(0xc7, 0xc7, 0xc7); - colors[ImGuiCol_TableRowBg] = colorRgb(0, 0, 0, 0.0f); - colors[ImGuiCol_TableRowBgAlt] = colorRgb(0, 0, 0, 0.03f); - colors[ImGuiCol_TextSelectedBg] = colorRgb(0x30, 0x8c, 0xc6, 0.35f); - colors[ImGuiCol_DockingPreview] = colorRgb(0x30, 0x8c, 0xc6, 0.5f); - colors[ImGuiCol_NavCursor] = highlight; - colors[ImGuiCol_PlotLines] = colorRgb(0x3b, 0x3b, 0x3b); - colors[ImGuiCol_PlotHistogram] = highlight; - colors[ImGuiCol_DragDropTarget] = highlight; - } - // imgui fades the modal dim in over several frames, which reads as the dialog lagging - colors[ImGuiCol_ModalWindowDimBg] = ImVec4(0, 0, 0, 0); - colors[ImGuiCol_NavWindowingDimBg] = ImVec4(0, 0, 0, 0); -} - -bool isDarkTheme() { return g_dark; } - -CabanaColor signalFillColor(const CabanaColor &c) { - if (!g_dark) return c; - auto [h, s, v] = c.hsv(); - return CabanaColor::fromHsv(h, std::min(1.0f, s * 1.4f), v * 0.8f, c.a / 255.0f); -} - -ImU32 highlightedTextColor() { - return g_dark ? IM_COL32(DarkTheme::window_text.r, DarkTheme::window_text.g, DarkTheme::window_text.b, 255) - : IM_COL32(255, 255, 255, 255); -} - -ImU32 paletteBrightText() { - return g_dark ? IM_COL32(DarkTheme::bright_text.r, DarkTheme::bright_text.g, DarkTheme::bright_text.b, 255) - : IM_COL32(255, 255, 255, 255); -} - -void drawSliderHandle(ImDrawList *p, const ImRect &r) { - const bool dark = isDarkTheme(); - const ImU32 top = dark ? IM_COL32(0x41, 0x41, 0x41, 255) : IM_COL32(255, 255, 255, 255); - const ImU32 bottom = dark ? IM_COL32(0x36, 0x36, 0x36, 255) : IM_COL32(0xf0, 0xf0, 0xf0, 255); - // the top/left edge is one step lighter than the bottom/right edge - const ImU32 outline_top = dark ? IM_COL32(0x5c, 0x5c, 0x5c, 255) : IM_COL32(0xab, 0xab, 0xab, 255); - const ImU32 outline_bottom = dark ? IM_COL32(0x26, 0x26, 0x26, 255) : IM_COL32(0xa4, 0xa4, 0xa4, 255); - p->AddRectFilled(r.Min, r.Max, top, 2.0f); - p->AddRectFilled(ImVec2(r.Min.x, r.GetCenter().y), r.Max, bottom, 2.0f, ImDrawFlags_RoundCornersBottom); - p->AddRect(r.Min, r.Max, outline_bottom, 2.0f, 0, 1.0f); - // the straight edges are drawn as crisp 1 px rects: an antialiased outline washes out to a much lighter grey - const float c = 2.0f; // corner radius - p->AddRectFilled(ImVec2(r.Min.x + c, r.Min.y), ImVec2(r.Max.x - c, r.Min.y + 1.0f), outline_top); - p->AddRectFilled(ImVec2(r.Min.x, r.Min.y + c), ImVec2(r.Min.x + 1.0f, r.Max.y - c), outline_top); - p->AddRectFilled(ImVec2(r.Min.x + c, r.Max.y - 1.0f), ImVec2(r.Max.x - c, r.Max.y), outline_bottom); - p->AddRectFilled(ImVec2(r.Max.x - 1.0f, r.Min.y + c), ImVec2(r.Max.x, r.Max.y - c), outline_bottom); -} - -bool fusionSliderInt(const char *label, int *v, int min, int max, float width) { - // a grey groove over the full width with the part left of the handle filled, and a 13x13 handle on top - const ImU32 groove_col = isDarkTheme() ? IM_COL32(0x2b, 0x2b, 0x2b, 255) : IM_COL32(0xc4, 0xc4, 0xc4, 255); - const ImU32 fill_col = ImGui::GetColorU32(ImGuiCol_Header); - ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32_BLACK_TRANS); - ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, IM_COL32_BLACK_TRANS); - ImGui::PushStyleColor(ImGuiCol_FrameBgActive, IM_COL32_BLACK_TRANS); - ImGui::PushStyleColor(ImGuiCol_SliderGrab, IM_COL32_BLACK_TRANS); - ImGui::PushStyleColor(ImGuiCol_SliderGrabActive, IM_COL32_BLACK_TRANS); - ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); // the slider has no frame - ImGui::SetNextItemWidth(width); - bool changed = ImGui::SliderInt(label, v, min, max, "", ImGuiSliderFlags_NoInput); - ImGui::PopStyleVar(); - ImGui::PopStyleColor(5); - - const ImVec2 bb_min = ImGui::GetItemRectMin(), bb_max = ImGui::GetItemRectMax(); - const float cy = (bb_min.y + bb_max.y) * 0.5f; - const float groove_h = SLIDER_THICKNESS * 0.5f; - const float handle_h = std::min(SLIDER_THICKNESS, bb_max.y - bb_min.y); - const float x0 = bb_min.x + SLIDER_LENGTH * 0.5f, x1 = bb_max.x - SLIDER_LENGTH * 0.5f; - const float t = max > min ? (float)(*v - min) / (float)(max - min) : 0.0f; - const float hx = x0 + (x1 - x0) * t; - ImDrawList *dl = ImGui::GetWindowDrawList(); - const float groove_y0 = cy - groove_h * 0.5f, groove_y1 = cy + groove_h * 0.5f; - dl->AddRectFilled(ImVec2(bb_min.x, groove_y0), ImVec2(bb_max.x, groove_y1), groove_col, groove_h * 0.5f); - dl->AddRectFilled(ImVec2(bb_min.x, groove_y0), ImVec2(hx, groove_y1), fill_col, groove_h * 0.5f); - drawSliderHandle(dl, ImRect(ImVec2(hx - SLIDER_LENGTH * 0.5f, cy - handle_h * 0.5f), - ImVec2(hx + SLIDER_LENGTH * 0.5f, cy + handle_h * 0.5f))); - return changed; -} - -ImFont *boldFont() { return g_bold_font; } -ImFont *monoFont() { return g_mono_font; } - -void pushMonoFont(float size) { - if (!g_mono_font) return; - size > 0.0f ? ImGui::PushFont(g_mono_font, size) : ImGui::PushFont(g_mono_font); -} -void popMonoFont() { if (g_mono_font) ImGui::PopFont(); } -void pushBoldFont() { if (g_bold_font) ImGui::PushFont(g_bold_font); } -void popBoldFont() { if (g_bold_font) ImGui::PopFont(); } -void pushLargeFont() { if (g_large_font) ImGui::PushFont(g_large_font); } -void popLargeFont() { if (g_large_font) ImGui::PopFont(); } diff --git a/openpilot/tools/cabana/ui/theme.cc b/openpilot/tools/cabana/ui/theme.cc new file mode 100644 index 0000000000..ff02ca24c7 --- /dev/null +++ b/openpilot/tools/cabana/ui/theme.cc @@ -0,0 +1,174 @@ +#include "tools/cabana/ui/theme.h" + +#include +#include +#include + +#include "implot.h" +#include "tools/cabana/core/settings.h" + +namespace fs = std::filesystem; + +namespace { + +constexpr Palette DARK_PALETTE = { + .text = rgb(0xf8f9f9), .text_disabled = rgb(0xb8c0c4), + .window = rgb(0x1d2225), .surface = rgb(0x30373b), + .frame = rgb(0x1e2224), .frame_hovered = rgb(0x394044), .frame_active = rgb(0x424a4f), + .button = rgb(0x424a4f), .button_hovered = rgb(0x535f64), .button_active = rgb(0x175886), + .header = rgb(0x175886), .header_hovered = rgb(0x24455e), .header_active = rgb(0x1c6ea8), + .accent = rgb(0x57a9e3), + .border = rgb(0x65737a), .separator = rgb(0x4b5559), + .tab = rgb(0x272c2f), .tab_hovered = rgb(0x424a4f), .table_header = rgb(0x424a4f), + .grid = rgb(0x65737a, 0.45f), .badge = rgb(0x808080), +}; + +constexpr Palette LIGHT_PALETTE = { + .text = rgb(0x1e2224), .text_disabled = rgb(0x535f64), + .window = rgb(0xeeeff0), .surface = rgb(0xffffff), + .frame = rgb(0xf8f9f9), .frame_hovered = rgb(0xeeeff0), .frame_active = rgb(0xddeef9), + .button = rgb(0xe3e6e8), .button_hovered = rgb(0xd8dcdf), .button_active = rgb(0xbcddf4), + .header = rgb(0xbcddf4), .header_hovered = rgb(0xddeef9), .header_active = rgb(0x9fcbec), + .accent = rgb(0x1c6ea8), + .border = rgb(0x98a3a9), .separator = rgb(0xcdd3d6), + .tab = rgb(0xe3e6e8), .tab_hovered = rgb(0xddeef9), .table_header = rgb(0xd8dcdf), + .grid = rgb(0x98a3a9, 0.4f), .badge = rgb(0xa0a0a4), +}; + +bool g_dark = false; +const Palette *g_palette = &LIGHT_PALETTE; +ImFont *g_ui_font = nullptr; +ImFont *g_bold_font = nullptr; +ImFont *g_mono_font = nullptr; +ImFont *g_large_font = nullptr; + +void addIconFont(float size, ImFont *base) { + ImFontConfig cfg; + cfg.MergeMode = base != nullptr; + cfg.GlyphMinAdvanceX = size; + if (base != nullptr) { + ImFontBaked *baked = base->GetFontBaked(size); + const float center = baked != nullptr ? (baked->Ascent + baked->Descent) * 0.5f : size * 0.5f; + cfg.GlyphOffset.y = std::round(size * 0.5f - center); + } + static const ImWchar ranges[] = {0xF000, 0xF8FF, 0}; + ImGui::GetIO().Fonts->AddFontFromFileTTF(BOOTSTRAP_ICONS_TTF, size, &cfg, ranges); +} + +ImFont *addFont(const fs::path &path, float size) { + ImFontConfig cfg; + cfg.OversampleH = 2; + cfg.OversampleV = 2; + ImFont *font = ImGui::GetIO().Fonts->AddFontFromFileTTF(path.c_str(), size, &cfg); + if (font != nullptr) addIconFont(size, font); + return font; +} + +ImVec4 alpha(ImVec4 c, float a) { return ImVec4(c.x, c.y, c.z, a); } + +} // namespace + +void loadFonts() { + ImGuiIO &io = ImGui::GetIO(); + const fs::path fonts = fs::path(CABANA_FONTS_DIR); + g_ui_font = addFont(fonts / "Inter-Regular.ttf", UI_FONT_SIZE); + g_bold_font = addFont(fonts / "Inter-SemiBold.ttf", UI_FONT_SIZE); + g_mono_font = addFont(fonts / "JetBrainsMono-Medium.ttf", 15.0f); + g_large_font = addFont(fonts / "Inter-Bold.ttf", 50.0f); + if (g_ui_font != nullptr) io.FontDefault = g_ui_font; + if (g_bold_font == nullptr) g_bold_font = g_ui_font; + if (g_mono_font == nullptr) g_mono_font = g_ui_font; + if (g_large_font == nullptr) g_large_font = g_bold_font; +} + +void applyTheme(int theme) { + g_dark = theme == DARK_THEME; + g_palette = g_dark ? &DARK_PALETTE : &LIGHT_PALETTE; + const Palette &p = *g_palette; + const ImVec4 none(0, 0, 0, 0); + + ImGuiStyle &style = ImGui::GetStyle(); + style = ImGuiStyle(); + style.WindowRounding = 6.0f; // dialogs and tooltips; docked panels and os windows ignore it + style.ChildRounding = 6.0f; + style.PopupRounding = 6.0f; + style.FrameRounding = 4.0f; + style.GrabRounding = 4.0f; + style.ScrollbarRounding = 4.0f; + style.TabRounding = 4.0f; + style.WindowBorderSize = 1.0f; + style.FrameBorderSize = 1.0f; + style.TabBorderSize = 1.0f; + style.WindowPadding = ImVec2(12.0f, 10.0f); + style.FramePadding = ImVec2(9.0f, 5.0f); + style.ItemSpacing = ImVec2(10.0f, 8.0f); + style.CellPadding = ImVec2(6.0f, 4.0f); + style.ScrollbarSize = 14.0f; + style.GrabMinSize = 13.0f; + + ImVec4 *c = style.Colors; + c[ImGuiCol_Text] = p.text; + c[ImGuiCol_TextDisabled] = p.text_disabled; + c[ImGuiCol_WindowBg] = c[ImGuiCol_ScrollbarBg] = c[ImGuiCol_DockingEmptyBg] = p.window; + c[ImGuiCol_MenuBarBg] = p.surface; + c[ImGuiCol_TitleBg] = c[ImGuiCol_TitleBgActive] = c[ImGuiCol_TitleBgCollapsed] = p.window; + c[ImGuiCol_ChildBg] = c[ImGuiCol_PopupBg] = p.surface; + c[ImGuiCol_Border] = c[ImGuiCol_TableBorderStrong] = p.border; + c[ImGuiCol_Separator] = c[ImGuiCol_TableBorderLight] = p.separator; + c[ImGuiCol_BorderShadow] = c[ImGuiCol_ResizeGrip] = c[ImGuiCol_TableRowBg] = none; + c[ImGuiCol_FrameBg] = p.frame; + c[ImGuiCol_FrameBgHovered] = p.frame_hovered; + c[ImGuiCol_FrameBgActive] = p.frame_active; + c[ImGuiCol_Button] = p.button; + c[ImGuiCol_ButtonHovered] = c[ImGuiCol_SliderGrab] = p.button_hovered; + c[ImGuiCol_ScrollbarGrab] = p.border; + c[ImGuiCol_ScrollbarGrabHovered] = p.text_disabled; + c[ImGuiCol_ButtonActive] = p.button_active; + c[ImGuiCol_Header] = p.header; + c[ImGuiCol_HeaderHovered] = p.header_hovered; + c[ImGuiCol_HeaderActive] = p.header_active; + c[ImGuiCol_CheckMark] = c[ImGuiCol_NavCursor] = c[ImGuiCol_SliderGrabActive] = c[ImGuiCol_ScrollbarGrabActive] = p.accent; + c[ImGuiCol_SeparatorActive] = c[ImGuiCol_ResizeGripActive] = c[ImGuiCol_DragDropTarget] = p.accent; + c[ImGuiCol_TabSelectedOverline] = c[ImGuiCol_PlotHistogram] = p.accent; + c[ImGuiCol_SeparatorHovered] = c[ImGuiCol_ResizeGripHovered] = alpha(p.accent, 0.6f); + c[ImGuiCol_DockingPreview] = alpha(p.accent, 0.5f); + c[ImGuiCol_TextSelectedBg] = alpha(p.accent, 0.35f); + c[ImGuiCol_Tab] = c[ImGuiCol_TabDimmed] = p.tab; + c[ImGuiCol_TabHovered] = p.tab_hovered; + c[ImGuiCol_TabSelected] = c[ImGuiCol_TabDimmedSelected] = p.surface; + c[ImGuiCol_TabDimmedSelectedOverline] = none; + c[ImGuiCol_TableHeaderBg] = p.table_header; + c[ImGuiCol_TableRowBgAlt] = g_dark ? ImVec4(1, 1, 1, 0.065f) : ImVec4(0, 0, 0, 0.045f); + c[ImGuiCol_PlotLines] = p.text; + // ImGuiStyle() seeds every slot from the dark theme: set the rest so the light theme does not keep a white caret. + c[ImGuiCol_InputTextCursor] = c[ImGuiCol_UnsavedMarker] = p.text; + c[ImGuiCol_TextLink] = c[ImGuiCol_PlotLinesHovered] = c[ImGuiCol_PlotHistogramHovered] = c[ImGuiCol_NavWindowingHighlight] = p.accent; + c[ImGuiCol_TreeLines] = p.separator; + c[ImGuiCol_DragDropTargetBg] = alpha(p.accent, 0.2f); + // Disable the modal dim fade to make dialogs appear immediately. + c[ImGuiCol_ModalWindowDimBg] = c[ImGuiCol_NavWindowingDimBg] = none; + + // ChartView::drawAxes() pushes the other plot colors it needs; the rest are auto colors derived from the ImGui style. + ImPlot::GetStyle().Colors[ImPlotCol_AxisGrid] = p.grid; +} + +bool isDarkTheme() { return g_dark; } +const Palette &palette() { return *g_palette; } + +CabanaColor signalFillColor(const CabanaColor &c) { + if (!g_dark) return c; + auto [h, s, v] = c.hsv(); + return CabanaColor::fromHsv(h, std::min(1.0f, s * 1.4f), v * 0.8f, c.a / 255.0f); +} + +ImFont *boldFont() { return g_bold_font; } + +void pushMonoFont(float size) { + if (!g_mono_font) return; + size > 0.0f ? ImGui::PushFont(g_mono_font, size) : ImGui::PushFont(g_mono_font); +} +void popMonoFont() { if (g_mono_font) ImGui::PopFont(); } +void pushBoldFont() { if (g_bold_font) ImGui::PushFont(g_bold_font); } +void popBoldFont() { if (g_bold_font) ImGui::PopFont(); } +void pushLargeFont() { if (g_large_font) ImGui::PushFont(g_large_font); } +void popLargeFont() { if (g_large_font) ImGui::PopFont(); } diff --git a/openpilot/tools/cabana/ui/theme.h b/openpilot/tools/cabana/ui/theme.h new file mode 100644 index 0000000000..91f2a0740f --- /dev/null +++ b/openpilot/tools/cabana/ui/theme.h @@ -0,0 +1,49 @@ +#pragma once + +#include "imgui.h" +#include "imgui_internal.h" + +#include "tools/cabana/core/color.h" + +// Palette source: commaai/connect src/{colors,theme}.js at 7091050. +// Dark colors follow connect; light colors use its lightGrey and lightBlue families. +struct Palette { + ImVec4 text, text_disabled; + ImVec4 window; // the background behind panels and docked windows + ImVec4 surface; // panels, popups, table bodies: what content is drawn on + ImVec4 frame, frame_hovered, frame_active; + ImVec4 button, button_hovered, button_active; + ImVec4 header, header_hovered, header_active; // selections + ImVec4 accent; + ImVec4 border, separator; + ImVec4 tab, tab_hovered, table_header; + ImVec4 grid; + ImVec4 badge; // the fill behind the time labels drawn over a chart +}; + +constexpr ImVec4 rgb(unsigned hex, float alpha = 1.0f) { + return ImVec4(((hex >> 16) & 255) / 255.0f, ((hex >> 8) & 255) / 255.0f, (hex & 255) / 255.0f, alpha); +} +inline ImVec4 colorRgb(int r, int g, int b, float alpha = 1.0f) { + return ImVec4(r / 255.0f, g / 255.0f, b / 255.0f, alpha); +} +inline ImU32 toImU32(const CabanaColor &c) { return IM_COL32(c.r, c.g, c.b, c.a); } +inline ImVec4 toImVec4(const CabanaColor &c) { return ImVec4(c.r / 255.0f, c.g / 255.0f, c.b / 255.0f, c.a / 255.0f); } +inline ImU32 withAlpha(ImU32 c, int alpha) { return (c & ~IM_COL32_A_MASK) | ((ImU32)alpha << IM_COL32_A_SHIFT); } + +constexpr float UI_FONT_SIZE = 16.0f; + +void loadFonts(); +void applyTheme(int theme); // Safe to call at runtime. +bool isDarkTheme(); +const Palette &palette(); + +CabanaColor signalFillColor(const CabanaColor &c); + +ImFont *boldFont(); +void pushMonoFont(float size = 0.0f); +void popMonoFont(); +void pushBoldFont(); +void popBoldFont(); +void pushLargeFont(); +void popLargeFont(); diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index 2e538e16f1..f646f33da4 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -3,7 +3,10 @@ #include #include #include +#include +#include #include +#include #include #include "imgui.h" @@ -22,6 +25,10 @@ void objc_msgSend(void); #include "tools/cabana/ui/icons.h" +namespace { +ImU32 u32(const ImVec4 &c) { return ImGui::ColorConvertFloat4ToU32(c); } +} // namespace + int inputCallback(ImGuiInputTextCallbackData *data) { auto *ctx = static_cast(data->UserData); if (data->EventFlag == ImGuiInputTextFlags_CallbackCharFilter) { @@ -58,9 +65,9 @@ bool inputTextMultiline(const char *label, std::string *s, const ImVec2 &size, I bool clearableInput(const char *label, std::string *s, const char *hint, ImGuiInputTextCallback validator) { bool changed = validatedInput(label, s, validator, hint); if (!s->empty()) { - ImGui::SameLine(0.0f, 0.0f); + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); ImGui::PushID(label); - if (toolButton("clear", icon::X)) { + if (iconButton("clear", icon::X_LG)) { s->clear(); changed = true; } @@ -128,14 +135,68 @@ int nonWhitespaceValidator(ImGuiInputTextCallbackData *data) { return (data->EventChar < 128 && std::isspace((int)data->EventChar)) ? 1 : 0; } -bool toolButton(const char *id, const char *icon, const char *tooltip, const char *text) { - std::string label = text && *text ? std::string(icon) + " " + text + "###" + id : std::string(icon) + "###" + id; - // no frame, transparent until hovered - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0)); - ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); - bool clicked = ImGui::Button(label.c_str()); - ImGui::PopStyleVar(); - ImGui::PopStyleColor(); +float iconButtonWidth() { return ImGui::GetFrameHeight(); } + +namespace { +constexpr float ICON_BUTTON_GLYPH_SCALE = 0.8f; + +// Exclude rasterization padding when centering icons; it varies between glyphs. +struct GlyphInk { float x0, y0, x1, y1; }; +GlyphInk glyphInk(const ImFontGlyph *g) { + ImTextureData *tex = ImGui::GetIO().Fonts->TexData; + const int px0 = (int)std::lround(g->U0 * tex->Width), px1 = (int)std::lround(g->U1 * tex->Width); + const int py0 = (int)std::lround(g->V0 * tex->Height), py1 = (int)std::lround(g->V1 * tex->Height); + int ix0 = px1, iy0 = py1, ix1 = px0, iy1 = py0; + for (int y = py0; y < py1; ++y) { + for (int x = px0; x < px1; ++x) { + const unsigned char *p = (const unsigned char *)tex->GetPixelsAt(x, y); + const unsigned char alpha = tex->Format == ImTextureFormat_Alpha8 ? p[0] : p[3]; + if (alpha < 64) continue; + ix0 = std::min(ix0, x); ix1 = std::max(ix1, x + 1); + iy0 = std::min(iy0, y); iy1 = std::max(iy1, y + 1); + } + } + if (ix0 >= ix1 || py1 <= py0 || px1 <= px0) return {g->X0, g->Y0, g->X1, g->Y1}; + const float sx = (g->X1 - g->X0) / (px1 - px0), sy = (g->Y1 - g->Y0) / (py1 - py0); + return {g->X0 + (ix0 - px0) * sx, g->Y0 + (iy0 - py0) * sy, g->X0 + (ix1 - px0) * sx, g->Y0 + (iy1 - py0) * sy}; +} + +// The scan reads the atlas pixels: do it once per icon, and again when the atlas repacked the glyph. +const GlyphInk &cachedGlyphInk(const ImFontGlyph *g, float size, unsigned int codepoint) { + struct Entry { ImVec4 uv; GlyphInk ink; }; + static std::unordered_map cache; + const uint64_t key = ((uint64_t)(uint32_t)size << 32) | codepoint; + const ImVec4 uv(g->U0, g->V0, g->U1, g->V1); + auto it = cache.find(key); + if (it == cache.end() || memcmp(&it->second.uv, &uv, sizeof(uv)) != 0) it = cache.insert_or_assign(key, Entry{uv, glyphInk(g)}).first; + return it->second.ink; +} + +bool squareIconButton(const char *id, const char *icon) { + const bool clicked = ImGui::Button((std::string("###") + id).c_str(), ImVec2(iconButtonWidth(), 0.0f)); + const ImRect r(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + unsigned int codepoint = 0; + ImTextCharFromUtf8(&codepoint, icon, nullptr); + // Leave a margin even for icons that fill the glyph bounds. + const float size = std::round(ImGui::GetFontSize() * ICON_BUTTON_GLYPH_SCALE); + ImFontBaked *baked = ImGui::GetFont()->GetFontBaked(size); + if (const ImFontGlyph *g = baked->FindGlyph((ImWchar)codepoint)) { + const GlyphInk ink = cachedGlyphInk(g, size, codepoint); + // Preserve half-logical-pixel positions on HiDPI displays. + const float snap = std::max(1.0f, ImGui::GetIO().DisplayFramebufferScale.x); + auto snapped = [snap](float v) { return std::round(v * snap) / snap; }; + const ImVec2 pos(snapped(r.GetCenter().x - (ink.x0 + ink.x1) * 0.5f), snapped(r.GetCenter().y - (ink.y0 + ink.y1) * 0.5f)); + // AddText truncates to whole logical pixels, undoing the framebuffer snapping above. + ImGui::GetWindowDrawList()->AddImage(ImGui::GetIO().Fonts->TexRef, ImVec2(pos.x + g->X0, pos.y + g->Y0), + ImVec2(pos.x + g->X1, pos.y + g->Y1), ImVec2(g->U0, g->V0), ImVec2(g->U1, g->V1), + ImGui::GetColorU32(ImGuiCol_Text)); + } + return clicked; +} +} // namespace + +bool iconButton(const char *id, const char *icon, const char *tooltip) { + const bool clicked = squareIconButton(id, icon); if (tooltip && *tooltip) ImGui::SetItemTooltip("%s", tooltip); return clicked; } @@ -240,7 +301,7 @@ bool viewSelectable(const char *label, bool selected, ImGuiSelectableFlags flags } bool checkBox(const char *label, bool *v) { - const float box = 16.0f; + const float box = CHECKBOX_SIZE; ImGuiWindow *window = ImGui::GetCurrentWindow(); if (window->SkipItems) return false; const ImGuiStyle &style = ImGui::GetStyle(); @@ -345,21 +406,41 @@ bool beginDialog(const char *id, PopupOwner *owner, const ImVec2 &size, ImGuiWin // tool bar -void beginToolbar() { - // the items sit next to each other, the buttons only carry the auto raise margin - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(TOOLBAR_ITEM_SPACING, ImGui::GetStyle().ItemSpacing.y)); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(TOOLBAR_BUTTON_PADDING, ImGui::GetStyle().FramePadding.y)); +ToolbarItem toolbarAction(const char *id, const char *icon, const char *label, std::function trigger, bool enabled, bool tight) { + return {iconButtonWidth(), [=]() { + ImGui::BeginDisabled(!enabled); + if (iconButton(id, icon)) trigger(); + ImGui::EndDisabled(); + disabledItemTooltip(label); + }, label, trigger, enabled, true, tight}; } -void endToolbar() { ImGui::PopStyleVar(2); } +ToolbarItem toolbarMenu(const char *id, const std::string &text, const char *label, std::function items, bool bold, bool tight, float width) { + if (width <= 0.0f) width = menuButtonWidth(text, bold); + ToolbarItem item{width, [id, text, items, bold, width]() { + const std::string popup_id = std::string(id) + "_menu"; + menuButton(id, text, popup_id.c_str(), bold, width); + if (ImGui::BeginPopup(popup_id.c_str())) { + items(); + ImGui::EndPopup(); + } + }, label}; + item.tight = tight; + item.submenu = std::move(items); + return item; +} float toolbarButtonWidth(const std::string &label) { return ImGui::CalcTextSize(label.c_str(), nullptr, true).x + ImGui::GetStyle().FramePadding.x * 2; } +static float toolbarSpacing(const ToolbarItem &item) { + return item.tight ? ImGui::GetStyle().ItemInnerSpacing.x : ImGui::GetStyle().ItemSpacing.x; +} + static float toolbarGroupWidth(const std::vector &items, size_t begin, size_t end) { float w = 0; - for (size_t i = begin; i < end; ++i) w += items[i].width + (i > begin ? ImGui::GetStyle().ItemSpacing.x : 0); + for (size_t i = begin; i < end; ++i) w += items[i].width + (i > begin ? toolbarSpacing(items[i]) : 0); return w; } @@ -370,24 +451,25 @@ float toolbarWidth(const std::vector &items, size_t spacer_index) { return w; } -void drawToolbar(const std::vector &items, size_t spacer_index) { +void drawToolbar(const std::vector &items, size_t spacer_index, float width) { const ImGuiStyle &style = ImGui::GetStyle(); spacer_index = std::min(spacer_index, items.size()); const float right_width = toolbarGroupWidth(items, spacer_index, items.size()); const float start_x = ImGui::GetCursorPosX(); - const float avail = ImGui::GetContentRegionAvail().x; + const float avail = width < 0.0f ? ImGui::GetContentRegionAvail().x : width; const float right_edge = start_x + avail; - const float extension_width = toolbarButtonWidth(icon::RAQUO); + const float extension_width = iconButtonWidth(); // when everything fits the spacer takes the slack, otherwise the extension button is reserved at the // right edge and the items are packed from the left until the next one does not fit - const bool fits = toolbarWidth(items, spacer_index) <= avail; + // a caller may size a flexible item from the same available width: allow for the float error of the round trip + const bool fits = toolbarWidth(items, spacer_index) <= avail + 0.5f; size_t visible = items.size(); if (!fits) { const float usable = avail - (extension_width + style.ItemSpacing.x); float used = 0; for (visible = 0; visible < items.size(); ++visible) { - const float w = items[visible].width + (visible ? style.ItemSpacing.x : 0); + const float w = items[visible].width + (visible ? toolbarSpacing(items[visible]) : 0); if (used + w > usable) break; used += w; } @@ -396,7 +478,7 @@ void drawToolbar(const std::vector &items, size_t spacer_index) { for (size_t i = 0; i < visible; ++i) { if (i == 0) ImGui::SetCursorPosX(start_x); else if (fits && i == spacer_index) ImGui::SameLine(right_edge - right_width); - else ImGui::SameLine(); + else ImGui::SameLine(0.0f, toolbarSpacing(items[i])); items[i].draw(); } @@ -404,9 +486,7 @@ void drawToolbar(const std::vector &items, size_t spacer_index) { // the extension button sits fully inside the toolbar: its right edge is the content region right edge const float extension_x = std::max(start_x, right_edge - extension_width); visible == 0 ? ImGui::SetCursorPosX(extension_x) : ImGui::SameLine(extension_x); - if (ImGui::Button((std::string(icon::RAQUO) + "###toolbar_extension").c_str(), ImVec2(extension_width, 0))) - ImGui::OpenPopup("toolbar_extension_menu"); - ImGui::SetItemTooltip("More"); + if (iconButton("toolbar_extension", icon::CHEVRON_DOUBLE_RIGHT, "More")) ImGui::OpenPopup("toolbar_extension_menu"); // the popup opens inward: its right edge is aligned with the button so it stays inside the window ImGui::SetNextWindowPos(ImVec2(ImGui::GetItemRectMax().x, ImGui::GetItemRectMax().y), ImGuiCond_Always, ImVec2(1, 0)); if (ImGui::BeginPopup("toolbar_extension_menu")) { @@ -414,6 +494,11 @@ void drawToolbar(const std::vector &items, size_t spacer_index) { if (!items[i].in_menu) continue; if (items[i].menu_label.empty()) { items[i].draw(); + } else if (items[i].submenu) { + if (ImGui::BeginMenu(items[i].menu_label.c_str(), items[i].enabled)) { + items[i].submenu(); + ImGui::EndMenu(); + } } else if (ImGui::MenuItem(items[i].menu_label.c_str(), nullptr, false, items[i].enabled)) { items[i].trigger(); } @@ -438,20 +523,17 @@ bool menuButton(const char *id, const std::string &text, const char *popup_id, b const ImGuiStyle &style = ImGui::GetStyle(); const bool popup_open = ImGui::IsPopupOpen(popup_id); if (width <= 0.0f) width = menuButtonWidth(text, bold); - // no frame, transparent until hovered; the button is drawn pressed while the menu is open. The menu opens - // on press; a press while it is open toggles it closed (imgui closes the popup at the end of the frame of - // a click outside it, so only open when it is not already open) + // ImGui closes popups at frame end on outside clicks. Only open a closed popup so a second press toggles it off. if (bold) pushBoldFont(); const float text_width = ImGui::CalcTextSize(text.c_str(), nullptr, true).x; const float ascent = ImGui::GetFontBaked()->Ascent; // the text and the arrow are centered as a group in the button const float padding_x = std::max(style.FramePadding.x, (width - (text_width + MENU_ARROW_SPACING + MENU_ARROW_SIZE)) * 0.5f); - ImGui::PushStyleColor(ImGuiCol_Button, popup_open ? style.Colors[ImGuiCol_ButtonActive] : ImVec4(0, 0, 0, 0)); - ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_Button, popup_open ? style.Colors[ImGuiCol_ButtonActive] : style.Colors[ImGuiCol_Button]); ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(padding_x, style.FramePadding.y)); ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.0f, 0.5f)); const bool clicked = ImGui::ButtonEx((text + "###" + id).c_str(), ImVec2(width, 0.0f), ImGuiButtonFlags_PressedOnClick); - ImGui::PopStyleVar(3); + ImGui::PopStyleVar(2); ImGui::PopStyleColor(); if (bold) popBoldFont(); // a 6 px arrow right after the text, sitting on the text baseline @@ -467,3 +549,39 @@ bool menuButton(const char *id, const std::string &text, const char *popup_id, b ImGui::SetNextWindowPos(ImVec2(min.x, ImGui::GetItemRectMax().y), ImGuiCond_Always); return clicked; } + +void drawSliderHandle(ImDrawList *p, const ImRect &r) { + const Palette &pal = palette(); + p->AddRectFilled(r.Min, r.Max, u32(pal.button_hovered), 2.0f); + p->AddRectFilled(ImVec2(r.Min.x, r.GetCenter().y), r.Max, u32(pal.button), 2.0f, ImDrawFlags_RoundCornersBottom); + p->AddRect(r.Min, r.Max, u32(pal.border), 2.0f, 0, 1.0f); +} + +bool fusionSliderInt(const char *label, int *v, int min, int max, float width) { + // Keep ImGui slider input handling, but replace its frame and grab with custom drawing. + ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_FrameBgActive, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_SliderGrab, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_SliderGrabActive, IM_COL32_BLACK_TRANS); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + ImGui::SetNextItemWidth(width); + bool changed = ImGui::SliderInt(label, v, min, max, "", ImGuiSliderFlags_NoInput); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(5); + + const ImVec2 bb_min = ImGui::GetItemRectMin(), bb_max = ImGui::GetItemRectMax(); + const float cy = (bb_min.y + bb_max.y) * 0.5f; + const float groove_h = SLIDER_THICKNESS * 0.5f; + const float handle_h = std::min(SLIDER_THICKNESS, bb_max.y - bb_min.y); + const float x0 = bb_min.x + SLIDER_LENGTH * 0.5f, x1 = bb_max.x - SLIDER_LENGTH * 0.5f; + const float t = max > min ? (float)(*v - min) / (float)(max - min) : 0.0f; + const float hx = x0 + (x1 - x0) * t; + ImDrawList *dl = ImGui::GetWindowDrawList(); + const float groove_y0 = cy - groove_h * 0.5f, groove_y1 = cy + groove_h * 0.5f; + dl->AddRectFilled(ImVec2(bb_min.x, groove_y0), ImVec2(bb_max.x, groove_y1), u32(palette().separator), groove_h * 0.5f); + dl->AddRectFilled(ImVec2(bb_min.x, groove_y0), ImVec2(hx, groove_y1), u32(palette().accent), groove_h * 0.5f); + drawSliderHandle(dl, ImRect(ImVec2(hx - SLIDER_LENGTH * 0.5f, cy - handle_h * 0.5f), + ImVec2(hx + SLIDER_LENGTH * 0.5f, cy + handle_h * 0.5f))); + return changed; +} diff --git a/openpilot/tools/cabana/ui/util.h b/openpilot/tools/cabana/ui/util.h index d180c073b7..87afb28f74 100644 --- a/openpilot/tools/cabana/ui/util.h +++ b/openpilot/tools/cabana/ui/util.h @@ -4,22 +4,11 @@ #include #include -#include "imgui.h" -#include "imgui_internal.h" - -#include "tools/cabana/core/color.h" +#include "tools/cabana/ui/theme.h" #include "tools/cabana/utils/util.h" struct GLFWwindow; -inline ImVec4 colorRgb(int r, int g, int b, float alpha = 1.0f) { - return ImVec4(r / 255.0f, g / 255.0f, b / 255.0f, alpha); -} - -inline ImU32 toImU32(const CabanaColor &c) { return IM_COL32(c.r, c.g, c.b, c.a); } -inline ImVec4 toImVec4(const CabanaColor &c) { return ImVec4(c.r / 255.0f, c.g / 255.0f, c.b / 255.0f, c.a / 255.0f); } -inline ImU32 withAlpha(ImU32 c, int alpha) { return (c & ~IM_COL32_A_MASK) | ((ImU32)alpha << IM_COL32_A_SHIFT); } - // the dock window identity of the messages panel (the visible title changes, the part after ### is the id) constexpr const char *MESSAGES_PANEL_ID = "###MessagesPanel"; @@ -78,8 +67,9 @@ int doubleValidator(ImGuiInputTextCallbackData *data); int ipValidator(ImGuiInputTextCallbackData *data); int nonWhitespaceValidator(ImGuiInputTextCallbackData *data); -// auto-raise icon button with a tooltip -bool toolButton(const char *id, const char *icon, const char *tooltip = nullptr, const char *text = nullptr); +// Use ItemInnerSpacing between related buttons and ItemSpacing between groups. +bool iconButton(const char *id, const char *icon, const char *tooltip = nullptr); +float iconButtonWidth(); // tooltip for the last item that also shows while the item is disabled void disabledItemTooltip(const char *text); @@ -121,6 +111,7 @@ bool viewSelectable(const char *label, bool selected, ImGuiSelectableFlags flags // a 16px box vertically centered in the frame height so rows keep their layout; ImGui::Checkbox draws a // frame height (22 px) square. bool checkBox(const char *label, bool *v); +constexpr float CHECKBOX_SIZE = 16.0f; // the next items on the line are right aligned as a block `width` wide void alignRight(float width); @@ -134,14 +125,6 @@ void drawElidedText(ImDrawList *dl, const ImRect &rect, const std::string &text, float markerSize(); void drawColorMarker(ImDrawList *dl, const ImVec2 &pos, ImU32 col); -void loadFonts(); -void applyTheme(int theme); // safe to call at runtime -bool isDarkTheme(); // the theme applyTheme() resolved -CabanaColor signalFillColor(const CabanaColor &c); - -ImU32 highlightedTextColor(); -ImU32 paletteBrightText(); - // the next window is a real OS window instead of being drawn inside the main one void setNextWindowFloatsOut(); #ifdef __APPLE__ @@ -158,8 +141,6 @@ void setNextDialogWindow(const ImVec2 &size); // centered modal dialog. false when the popup is not submitted this frame. bool beginDialog(const char *id, PopupOwner *owner, const ImVec2 &size, ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize); -const float TOOLBAR_ITEM_SPACING = 1.0f; -const float TOOLBAR_BUTTON_PADDING = 4.0f; // auto raise button horizontal margin const float SLIDER_LENGTH = 13.0f; const float SLIDER_THICKNESS = 13.0f; @@ -172,31 +153,27 @@ struct ToolbarItem { std::function trigger; bool enabled = true; bool in_menu = true; // false: left out of the ">>" menu (a separator) + bool tight = false; // true: ItemInnerSpacing before it, it belongs to the previous item's group + std::function submenu; // set: the ">>" entry is a submenu with these items instead of an action }; -void beginToolbar(); // item spacing and button padding of a tool bar, until endToolbar() -void endToolbar(); +ToolbarItem toolbarAction(const char *id, const char *icon, const char *label, std::function trigger, + bool enabled = true, bool tight = false); +// A drop-down button that opens `items` in a popup; in the overflow menu they become a submenu. +// width 0: sized to the text. +ToolbarItem toolbarMenu(const char *id, const std::string &text, const char *label, std::function items, + bool bold = false, bool tight = false, float width = 0.0f); float toolbarButtonWidth(const std::string &label); // the width of every item plus the spacing between neighbors and the two groups float toolbarWidth(const std::vector &items, size_t spacer_index); // items before spacer_index sit at the left, the rest are right aligned; the overflow goes into the ">>" menu -void drawToolbar(const std::vector &items, size_t spacer_index); +// width < 0 uses the available content width. +void drawToolbar(const std::vector &items, size_t spacer_index, float width = -1.0f); -// an auto-raise button that opens `popup_id` below itself, with a dropdown arrow after the text. width 0: +// Opens `popup_id` below the button on press. width 0: // sized to the text, otherwise the text and the arrow are centered in the button float menuButtonWidth(const std::string &text, bool bold = false); bool menuButton(const char *id, const std::string &text, const char *popup_id, bool bold = false, float width = 0.0f); -// a 13x13 handle filled with a subtle vertical gradient and a mid grey outline void drawSliderHandle(ImDrawList *p, const ImRect &r); -// full width groove, filled left of the handle, 13x13 handle (style.cc) bool fusionSliderInt(const char *label, int *v, int min, int max, float width); - -ImFont *boldFont(); -ImFont *monoFont(); -void pushMonoFont(float size = 0.0f); // 0: the size the font was loaded at -void popMonoFont(); -void pushBoldFont(); -void popBoldFont(); -void pushLargeFont(); -void popLargeFont(); diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.cc b/openpilot/tools/cabana/ui/widgets/binaryview.cc index 7f9ac8b7b2..4469273607 100644 --- a/openpilot/tools/cabana/ui/widgets/binaryview.cc +++ b/openpilot/tools/cabana/ui/widgets/binaryview.cc @@ -296,8 +296,10 @@ void BinaryView::draw() { } const int rows = row_count_; - const float width = ImGui::GetContentRegionAvail().x; - column_width_ = std::max(1.0f, (width - VERTICAL_HEADER_WIDTH) / COLUMN_COUNT); + // Keep hex bytes readable in narrow panels by scrolling instead of shrinking further. + const float min_column_width = std::ceil(ImGui::CalcTextSize("FF").x) + 10.0f; + const float width = std::max(ImGui::GetContentRegionAvail().x, VERTICAL_HEADER_WIDTH + min_column_width * COLUMN_COUNT); + column_width_ = std::max(min_column_width, (width - VERTICAL_HEADER_WIDTH) / COLUMN_COUNT); grid_pos_ = ImGui::GetCursorScreenPos(); ImGui::InvisibleButton("##binary_view", ImVec2(std::max(width, 1.0f), std::max(static_cast(rows * CELL_HEIGHT), 1.0f))); ImDrawList *painter = ImGui::GetWindowDrawList(); @@ -448,9 +450,8 @@ void BinaryView::paintCell(ImDrawList *painter, const ImRect &rect, const Binary painter->AddRectFilled(rect.Min, rect.Max, toImU32(item->bg_color)); } } else if (isSelected(index)) { - auto color = resize_sig_ ? toImU32(resize_sig_->color) : paletteHighlight(); - painter->AddRectFilled(rect.Min, rect.Max, color); - pen = paletteBrightText(); + painter->AddRectFilled(rect.Min, rect.Max, resize_sig_ ? toImU32(resize_sig_->color) : paletteHighlight()); + if (resize_sig_) pen = IM_COL32_WHITE; } else if (!hasSelection() || std::find(item->sigs.begin(), item->sigs.end(), resize_sig_) == item->sigs.end()) { // not resizing if (item->sigs.size() > 0) { for (auto &s : item->sigs) { @@ -465,7 +466,7 @@ void BinaryView::paintCell(ImDrawList *painter, const ImRect &rect, const Binary if (item->bg_color.alpha() > 0) painter->AddRectFilled(rect.Min, rect.Max, toImU32(item->bg_color)); } bool bright = std::find(item->sigs.begin(), item->sigs.end(), hovered_sig_) != item->sigs.end(); - pen = bright ? paletteBrightText() : paletteText(is_message_active_); + pen = bright ? IM_COL32_WHITE : paletteText(is_message_active_); } if (item->sigs.size() > 1) { diff --git a/openpilot/tools/cabana/ui/widgets/cameraview.cc b/openpilot/tools/cabana/ui/widgets/cameraview.cc index 6ffd099fe9..1f730344d5 100644 --- a/openpilot/tools/cabana/ui/widgets/cameraview.cc +++ b/openpilot/tools/cabana/ui/widgets/cameraview.cc @@ -3,6 +3,7 @@ #include #include #include +#include #include #include "imgui_impl_opengl3_loader.h" @@ -62,7 +63,7 @@ CameraWidget::~CameraWidget() { void CameraWidget::startVipcThread() { if (!vipc_thread_.joinable()) { - clearFrames(); + // Preserve the last frame when restoring a collapsed video; paused replay sends no replacement. vipc_exit_ = false; vipc_thread_ = std::thread(&CameraWidget::vipcThread, this); } @@ -99,7 +100,7 @@ float CameraWidget::frameAspectRatio() const { void CameraWidget::paint() { ImDrawList *p = ImGui::GetWindowDrawList(); - p->AddRectFilled(rect_.Min, rect_.Max, bg_); + p->AddRectFilled(rect_.Min, rect_.Max, bg_, ImGui::GetStyle().ChildRounding); std::lock_guard lk(frame_lock_); if (rgb_frame_.isNull()) return; @@ -113,24 +114,26 @@ void CameraWidget::paint() { // mirror cabin camera horizontally std::swap(placement.uv0.x, placement.uv1.x); } - p->AddImage(frame_texture_.ref(), placement.min, placement.max, placement.uv0, placement.uv1); + p->AddImageRounded(frame_texture_.ref(), placement.min, placement.max, placement.uv0, placement.uv1, IM_COL32_WHITE, ImGui::GetStyle().ChildRounding); } void CameraWidget::vipcThread() { VisionStreamType cur_stream = requested_stream_type_; std::unique_ptr vipc_client; VisionIpcBufExtra frame_meta = {}; + bool was_connected = false; while (!vipc_exit_) { if (!vipc_client || cur_stream != requested_stream_type_) { - clearFrames(); + if (cur_stream != requested_stream_type_) clearFrames(); cur_stream = requested_stream_type_; vipc_client.reset(new VisionIpcClient(stream_name_, cur_stream, false)); } active_stream_type_ = cur_stream; if (!vipc_client->connected) { - clearFrames(); + // the server changed (a new route): the last frame is stale. A fresh thread keeps it, see startVipcThread(). + if (std::exchange(was_connected, false)) clearFrames(); auto streams = VisionIpcClient::getAvailableStreams(stream_name_, false); if (streams.empty()) { std::this_thread::sleep_for(std::chrono::milliseconds(100)); @@ -142,6 +145,7 @@ void CameraWidget::vipcThread() { std::this_thread::sleep_for(std::chrono::milliseconds(100)); continue; } + was_connected = true; } if (VisionBuf *buf = vipc_client->recv(&frame_meta, 100)) { diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc index ebb74ffa4a..00ed44b09d 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.cc +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -74,41 +74,40 @@ DetailWidget::DetailWidget(ChartsWidget *charts) : charts_(charts) { void DetailWidget::drawToolBar() { const ImGuiStyle &style = ImGui::GetStyle(); - auto radio_width = [&](const char *label) { return ImGui::GetFrameHeight() + style.ItemInnerSpacing.x + ImGui::CalcTextSize(label).x; }; - auto button_width = [&](const char *label) { return ImGui::CalcTextSize(label).x + style.FramePadding.x * 2; }; - const float right_width = ImGui::CalcTextSize("Heatmap:").x + style.ItemSpacing.x + radio_width("Live") + style.ItemSpacing.x + - radio_width(heatmap_all_text_.c_str()) + style.ItemSpacing.x * 3 + 1.0f + - button_width(icon::PENCIL) + style.ItemSpacing.x + button_width(icon::X_LG); - const float avail = ImGui::GetContentRegionAvail().x; + std::vector items; + float name_width = 0.0f; + items.push_back({0.0f, [this, &name_width]() { + ImGui::AlignTextToFramePadding(); + pushBoldFont(); + name_label_.draw(name_width); + popBoldFont(); + }}); + items.back().in_menu = false; + const size_t spacer_index = items.size(); + const std::string heatmap_text = "Heatmap: " + (heatmap_live_ ? std::string("Live") : heatmap_all_text_); + auto heatmap_items = [this]() { + if (ImGui::MenuItem("Live", nullptr, heatmap_live_) && !heatmap_live_) { + heatmap_live_ = true; + binary_view_->setHeatmapLiveMode(true); + } + if (ImGui::MenuItem(heatmap_all_text_.c_str(), nullptr, !heatmap_live_) && heatmap_live_) { + heatmap_live_ = false; + binary_view_->setHeatmapLiveMode(false); + } + }; + items.push_back(toolbarMenu("heatmap", heatmap_text, "Heatmap", heatmap_items)); + items.push_back({1.0f, []() { ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); }}); + items.back().in_menu = false; + // Capture the panel width before the action can run inside the overflow popup. + const float panel_width = ImGui::GetWindowWidth(); + items.push_back(toolbarAction("edit_msg", icon::PENCIL, "Edit Message", [this, panel_width]() { editMsg(panel_width); })); + items.push_back(toolbarAction("remove_msg", icon::TRASH, "Remove Message", + [this]() { UndoStack::instance()->push(new RemoveMsgCommand(msg_id_)); }, action_remove_msg_enabled_, true)); - ImGui::AlignTextToFramePadding(); - pushBoldFont(); - name_label_.draw(std::max(1.0f, avail - right_width - style.ItemSpacing.x)); - popBoldFont(); - - alignRight(right_width); - ImGui::TextUnformatted("Heatmap:"); - ImGui::SameLine(); - if (ImGui::RadioButton("Live##heatmap_live_", heatmap_live_) && !heatmap_live_) { - heatmap_live_ = true; - binary_view_->setHeatmapLiveMode(true); - } - ImGui::SameLine(); - if (ImGui::RadioButton((heatmap_all_text_ + "##heatmap_all").c_str(), !heatmap_live_) && heatmap_live_) { - heatmap_live_ = false; - binary_view_->setHeatmapLiveMode(false); - } - - ImGui::SameLine(); - ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); - ImGui::SameLine(); - if (ImGui::Button(icon::PENCIL)) editMsg(); - ImGui::SetItemTooltip("Edit Message"); - ImGui::SameLine(); - ImGui::BeginDisabled(!action_remove_msg_enabled_); - if (ImGui::Button(icon::X_LG)) UndoStack::instance()->push(new RemoveMsgCommand(msg_id_)); - ImGui::EndDisabled(); - disabledItemTooltip("Remove Message"); + const float right_width = toolbarWidth(items, spacer_index) - style.ItemSpacing.x; + name_width = std::max(ImGui::CalcTextSize("MMMMMM").x, ImGui::GetContentRegionAvail().x - right_width - style.ItemSpacing.x); + items[0].width = name_width; + drawToolbar(items, spacer_index); } void DetailWidget::showTabBarContextMenu(int index) { @@ -203,34 +202,32 @@ void DetailWidget::updateState(const std::set *msgs) { history_log_->updateState(); } -void DetailWidget::editMsg() { +void DetailWidget::editMsg(float parent_width) { auto msg = dbc()->msg(msg_id_); int size = msg ? msg->size : can->lastMessage(msg_id_).dat.size(); - edit_dlg_ = std::make_unique(msg_id_, msgName(msg_id_), size, ImGui::GetWindowWidth()); + edit_dlg_ = std::make_unique(msg_id_, msgName(msg_id_), size, parent_width); } void DetailWidget::drawTabWidget() { - // the pages first, the tab bar below them - const float tab_height = ImGui::GetFrameHeight(); - const float content_height = ImGui::GetContentRegionAvail().y - tab_height - ImGui::GetStyle().ItemSpacing.y; - ImGui::BeginChild("tab_widget", ImVec2(0, std::max(content_height, 1.0f)), ImGuiChildFlags_None, + const ImGuiStyle &style = ImGui::GetStyle(); + const float pad = style.ItemInnerSpacing.x, pill_height = ImGui::GetFrameHeight() + pad * 2; + ImGui::BeginChild("tab_widget", ImVec2(0, 0), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + const ImRect page_rect = ImGui::GetCurrentWindow()->Rect(); + const float gap = style.WindowPadding.y; + ImGui::BeginChild("page", ImVec2(0, std::max(page_rect.GetHeight() - pill_height - gap, 1.0f)), + ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); if (tab_widget_index_ == 0) { // binary_view_ keeps its size hint, signal_view_ takes the rest const float min_height = binary_view_->minimumSizeHint().y; const float avail = ImGui::GetContentRegionAvail().y; const float max_height = std::max(avail - 6.0f - ImGui::GetStyle().ItemSpacing.y * 2 - 1.0f, 1.0f); const float height = std::clamp(min_height, 1.0f, max_height); - ImGui::BeginChild("binary_view", ImVec2(0, height)); + ImGui::BeginChild("binary_view", ImVec2(0, height), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar); binary_view_rect_ = ImGui::GetCurrentWindow()->Rect(); binary_view_->draw(); ImGui::EndChild(); ImGui::Dummy(ImVec2(0.0f, 6.0f)); - const float spacing = ImGui::GetStyle().ItemSpacing.y; - const ImRect child_rect = ImGui::GetCurrentWindow()->Rect(); - ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(child_rect.Min.x, ImGui::GetItemRectMin().y - spacing), - ImVec2(child_rect.Max.x, ImGui::GetItemRectMax().y + spacing), - ImGui::GetColorU32(ImGuiCol_WindowBg)); ImGui::BeginChild("signal_view", ImVec2(0, 0)); signal_view_rect_ = ImGui::GetCurrentWindow()->Rect(); signal_view_->draw(); @@ -240,33 +237,44 @@ void DetailWidget::drawTabWidget() { } ImGui::EndChild(); - const std::string labels[] = {std::string(icon::FILE_EARMARK_RULED) + " Messages", std::string(icon::STOPWATCH) + " Logs"}; - // the tabs are centered in the bar: the bar itself starts at the first tab, so its separator only spans the - // tabs and the full width one is drawn underneath it - const ImGuiStyle &style = ImGui::GetStyle(); - float tabs_width = 0.0f; + std::string labels[] = {std::string(icon::FILE_EARMARK_RULED) + " Messages", std::string(icon::STOPWATCH) + " Logs"}; + auto pill_width = [&]() { + float w = pad; + for (const auto &label : labels) w += ImGui::CalcTextSize(label.c_str()).x + style.FramePadding.x * 2 + pad; + return w; + }; + float width = pill_width(); + if (width > page_rect.GetWidth()) { + labels[0] = icon::FILE_EARMARK_RULED; + labels[1] = icon::STOPWATCH; + width = pill_width(); + } + const ImVec2 size(width, pill_height); + const ImVec2 min(std::round(page_rect.GetCenter().x - width * 0.5f), page_rect.Max.y - size.y); + ImGui::SetNextWindowPos(min); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(pad, pad)); + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::GetColorU32(ImGuiCol_PopupBg)); + ImGui::BeginChild("page_switch", size, ImGuiChildFlags_Borders | ImGuiChildFlags_AlwaysUseWindowPadding, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(pad, 0.0f)); for (int i = 0; i < 2; ++i) { - tabs_width += ImGui::TabItemCalcSize(labels[i].c_str(), false).x + (i ? style.ItemInnerSpacing.x : 0.0f); - } - ImGuiWindow *window = ImGui::GetCurrentWindow(); - const float separator_y = ImGui::GetCursorScreenPos().y + ImGui::GetFrameHeight() - 1.0f; - window->DrawList->AddLine(ImVec2(window->WorkRect.Min.x, separator_y), ImVec2(window->WorkRect.Max.x, separator_y), - ImGui::GetColorU32(ImGuiCol_TabSelected), style.TabBarBorderSize); - ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (ImGui::GetContentRegionAvail().x - tabs_width) * 0.5f)); - - if (ImGui::BeginTabBar("tab_widget_tabs")) { - for (int i = 0; i < 2; ++i) { - if (ImGui::BeginTabItem(labels[i].c_str())) { - if (tab_widget_index_ != i) { - tab_widget_index_ = i; - if (i == 1) history_log_->onShown(); - updateState(); - } - ImGui::EndTabItem(); - } + const bool selected = tab_widget_index_ == i; + ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetColorU32(selected ? ImGuiCol_Header : ImGuiCol_Button, selected ? 1.0f : 0.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::GetColorU32(selected ? ImGuiCol_HeaderActive : ImGuiCol_ButtonHovered)); + if (i) ImGui::SameLine(); + if (ImGui::Button(labels[i].c_str()) && !selected) { + tab_widget_index_ = i; + if (i == 1) history_log_->onShown(); + updateState(); } - ImGui::EndTabBar(); + ImGui::PopStyleColor(2); } + ImGui::PopStyleVar(2); + ImGui::EndChild(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); + ImGui::EndChild(); } void DetailWidget::draw() { @@ -321,8 +329,11 @@ bool EditMessageDialog::draw() { ImGui::OpenPopup(window_title_.c_str()); opened_ = true; } - setNextDialogWindow(ImVec2(0.0f, 0.0f)); - ImGui::SetNextWindowSize(ImVec2(width_, 0.0f), ImGuiCond_Always); // fixed width, the height fits the form + // The form needs room for message names and comments even when its panel is narrow. + const float max_width = std::max(1.0f, ImGui::GetMainViewport()->WorkSize.x - ImGui::GetStyle().WindowPadding.x * 2); + const float min_width = std::min(600.0f, max_width); + ImGui::SetNextWindowSizeConstraints(ImVec2(min_width, 0.0f), ImVec2(max_width, FLT_MAX)); + setNextDialogWindow(ImVec2(std::clamp(width_, min_width, max_width), 0.0f)); bool open = true; if (ImGui::BeginPopupModal(window_title_.c_str(), &open)) { const float label_width = ImGui::CalcTextSize("Comment").x + ImGui::GetStyle().ItemSpacing.x * 2; @@ -401,9 +412,6 @@ void CenterWidget::draw() { } void CenterWidget::drawWelcomeWidget() { - const ImVec2 win_pos = ImGui::GetWindowPos(), win_size = ImGui::GetWindowSize(); - ImGui::GetWindowDrawList()->AddRectFilled(win_pos, ImVec2(win_pos.x + win_size.x, win_pos.y + win_size.y), ImGui::GetColorU32(ImGuiCol_ChildBg)); - const ImVec2 avail = ImGui::GetContentRegionAvail(); const ImVec2 origin = ImGui::GetCursorPos(); auto centered = [&](const char *text, float y) { diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.h b/openpilot/tools/cabana/ui/widgets/detailwidget.h index 08d3d7db41..7f8efde99c 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.h +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.h @@ -73,7 +73,7 @@ private: void drawTabWidget(); int findOrAddTab(const MessageId& message_id); void showTabBarContextMenu(int index); - void editMsg(); + void editMsg(float parent_width); void updateState(const std::set *msgs = nullptr); MessageId msg_id_; diff --git a/openpilot/tools/cabana/ui/widgets/historylog.cc b/openpilot/tools/cabana/ui/widgets/historylog.cc index ec2ef844ff..ba51c4d6ee 100644 --- a/openpilot/tools/cabana/ui/widgets/historylog.cc +++ b/openpilot/tools/cabana/ui/widgets/historylog.cc @@ -133,9 +133,9 @@ void LogsWidget::draw() { const ImGuiStyle &style = ImGui::GetStyle(); // toolbar: the export button is right aligned and never clipped, the value input shrinks first - const float export_w = ImGui::CalcTextSize(icon::FILETYPE_CSV).x + style.FramePadding.x * 2; + const float export_w = iconButtonWidth(); if (!sigs_.empty()) { - const float clear_w = value_edit_.empty() ? 0.0f : ImGui::CalcTextSize(icon::X).x + style.FramePadding.x * 2; + const float clear_w = value_edit_.empty() ? 0.0f : iconButtonWidth(); const float fixed = DISPLAY_TYPE_WIDTH + SIGNALS_WIDTH + COMPARE_WIDTH + clear_w + style.ItemSpacing.x * 4 + export_w; const float value_w = std::clamp(ImGui::GetContentRegionAvail().x - fixed, 30.0f, 120.0f); @@ -164,9 +164,9 @@ void LogsWidget::draw() { filterChanged(); } } - alignRight(export_w); + alignRight(iconButtonWidth()); ImGui::BeginDisabled(!export_btn_enabled_); - if (ImGui::Button(icon::FILETYPE_CSV)) exportToCSV(); + if (iconButton("export_csv", icon::FILETYPE_CSV)) exportToCSV(); ImGui::EndDisabled(); disabledItemTooltip("Export to CSV file..."); @@ -196,10 +196,10 @@ void LogsWidget::drawHeaderCell(ImDrawList *dl, const ImRect &rect, int column) if (column > 0 && !hexMode()) { CabanaColor bg = sigs_[column - 1]->color; bg.a = 128; - dl->AddRectFilled(rect.Min, rect.Max, toImU32(bg)); + dl->AddRectFilled(rect.Min, rect.Max, toImU32(bg), ImGui::GetStyle().FrameRounding); } const std::string text = headerText(column); - const ImU32 color = isDarkTheme() ? toImU32(DarkTheme::bright_text) : ImGui::GetColorU32(ImGuiCol_Text); + const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text); // right aligned and word wrapped, one line at a time const ImRect r(rect.Min.x + 5, rect.Min.y + 3, rect.Max.x - 5, rect.Max.y - 3); ImFont *font = ImGui::GetFont(); @@ -243,7 +243,7 @@ void LogsWidget::drawTable() { // fixed section sizes and a horizontal scrollbar, no alternating row colors; the grid is drawn between // rows and columns - ImGuiTableFlags flags = ImGuiTableFlags_ScrollY | ImGuiTableFlags_ScrollX | ImGuiTableFlags_BordersInner | + ImGuiTableFlags flags = ImGuiTableFlags_ScrollY | ImGuiTableFlags_ScrollX | ImGuiTableFlags_Borders | ImGuiTableFlags_SizingFixedFit; // an empty viewport draws no grid if (messages_.empty()) flags &= ~ImGuiTableFlags_BordersInnerV; @@ -279,13 +279,14 @@ void LogsWidget::drawTable() { if (!ImGui::TableSetColumnIndex(col)) continue; // cells are selected, not rows; there is no hover highlight, only the selection background const bool cell_selected = selected_row_ == row && selected_col_ == col; + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const ImRect rect(pos, ImVec2(pos.x + ImGui::GetContentRegionAvail().x, pos.y + row_height - style.CellPadding.y * 2)); ImGui::PushID(col); - if (viewSelectable("##cell", cell_selected, ImGuiSelectableFlags_AllowOverlap, ImVec2(0, row_height - style.CellPadding.y * 2))) { + if (viewSelectable("##cell", cell_selected, ImGuiSelectableFlags_AllowOverlap, ImVec2(0, rect.GetHeight()))) { selected_row_ = row; selected_col_ = col; } ImGui::PopID(); - const ImRect rect = ImGui::TableGetCellBgRect(table, col); if (col == 0) { drawTextCell(painter, rect, formatTime(m.mono_time), cell_selected, false); } else if (hexMode()) { diff --git a/openpilot/tools/cabana/ui/widgets/messagebytes.cc b/openpilot/tools/cabana/ui/widgets/messagebytes.cc index 9052949eef..97261039ee 100644 --- a/openpilot/tools/cabana/ui/widgets/messagebytes.cc +++ b/openpilot/tools/cabana/ui/widgets/messagebytes.cc @@ -24,7 +24,7 @@ ImVec2 bytesCellSize(int n, bool multiple_lines) { } ImU32 cellTextColor(bool selected, bool inactive) { - if (selected) return inactive ? withAlpha(highlightedTextColor(), 100) : highlightedTextColor(); + if (selected && inactive) return withAlpha(ImGui::GetColorU32(ImGuiCol_Text), 100); return ImGui::GetColorU32(inactive ? ImGuiCol_TextDisabled : ImGuiCol_Text); } diff --git a/openpilot/tools/cabana/ui/widgets/messageswidget.cc b/openpilot/tools/cabana/ui/widgets/messageswidget.cc index 102960b9a9..6de1735d79 100644 --- a/openpilot/tools/cabana/ui/widgets/messageswidget.cc +++ b/openpilot/tools/cabana/ui/widgets/messageswidget.cc @@ -240,26 +240,30 @@ std::string MessagesWidget::whatsThis() const { } void MessagesWidget::drawToolBar() { - ImGui::Dummy(ImVec2(0, std::max(0.0f, 9 - ImGui::GetStyle().ItemSpacing.y))); - if (ImGui::Button("Suppress Highlighted")) suppressHighlighted(true); - ImGui::SameLine(); - ImGui::BeginDisabled(!suppress_clear_enabled_); - const std::string clear_label = suppress_clear_text_ + "##suppress_clear"; - if (ImGui::Button(clear_label.c_str())) suppressHighlighted(false); - ImGui::EndDisabled(); - disabledItemTooltip("Clear suppressed"); - const ImGuiStyle &style = ImGui::GetStyle(); - const float checkbox_width = ImGui::CalcTextSize("Suppress Signals").x + ImGui::GetFrameHeight() + style.ItemInnerSpacing.x; - const float view_button_width = ImGui::CalcTextSize(icon::THREE_DOTS).x + style.FramePadding.x * 2; - alignRight(checkbox_width + style.ItemSpacing.x + view_button_width); + // Reserve space for View so it remains accessible when other controls overflow. + const std::string clear_label = suppress_clear_text_ + "##suppress_clear"; + std::vector items; + items.push_back({toolbarButtonWidth("Suppress Highlighted"), [this]() { + if (ImGui::Button("Suppress Highlighted")) suppressHighlighted(true); + }, "Suppress Highlighted", [this]() { suppressHighlighted(true); }}); + items.push_back({toolbarButtonWidth(suppress_clear_text_), [this, &clear_label]() { + ImGui::BeginDisabled(!suppress_clear_enabled_); + if (ImGui::Button(clear_label.c_str())) suppressHighlighted(false); + ImGui::EndDisabled(); + disabledItemTooltip("Clear suppressed"); + }, suppress_clear_text_, [this]() { suppressHighlighted(false); }, suppress_clear_enabled_}); + const size_t spacer_index = items.size(); + items.push_back({ImGui::CalcTextSize("Suppress Signals").x + CHECKBOX_SIZE + style.ItemInnerSpacing.x, []() { + bool suppress_defined_signals = settings.suppress_defined_signals; + if (checkBox("Suppress Signals", &suppress_defined_signals)) can->suppressDefinedSignals(suppress_defined_signals); + ImGui::SetItemTooltip("Suppress defined signals"); + }}); - bool suppress_defined_signals = settings.suppress_defined_signals; - if (checkBox("Suppress Signals", &suppress_defined_signals)) can->suppressDefinedSignals(suppress_defined_signals); - ImGui::SetItemTooltip("Suppress defined signals"); + const float reserved = iconButtonWidth() + style.ItemSpacing.x; + drawToolbar(items, spacer_index, std::max(0.0f, ImGui::GetContentRegionAvail().x - reserved)); ImGui::SameLine(); - - if (toolButton("view_btn", icon::THREE_DOTS, "View...")) ImGui::OpenPopup("menu"); + if (iconButton("view_btn", icon::THREE_DOTS, "View...")) ImGui::OpenPopup("menu"); } void MessagesWidget::updateTitle() { @@ -430,7 +434,7 @@ void MessagesWidget::drawHeader() { } // the filter editors under the header - const float clear_width = ImGui::CalcTextSize(icon::X).x + ImGui::GetStyle().FramePadding.x * 2; + const float clear_width = iconButtonWidth(); ImGui::TableNextRow(); for (int i = 0; i < MessageList::COLUMN_COUNT; i++) { if (!ImGui::TableSetColumnIndex(i)) continue; diff --git a/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc b/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc index feda7ce34d..75d7c42788 100644 --- a/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc +++ b/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc @@ -9,7 +9,7 @@ namespace { float scrollButtonsWidth() { const ImGuiStyle &style = ImGui::GetStyle(); - return ImGui::GetFrameHeight() * 2.0f + style.ItemInnerSpacing.x + style.ItemSpacing.x * 2.0f; + return style.ItemSpacing.x + ImGui::GetFrameHeight() * 2.0f + style.ItemInnerSpacing.x; } void drawScrollButtons(ImGuiTabBar *tab_bar) { diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index 1eff979b02..c0ed091568 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -24,7 +24,6 @@ constexpr float SIGNAL_ROW_EXTRA = 5.0f; // the tool button in the row makes it constexpr float SIGNAL_ROW_SCALE = 1.25f; constexpr float FILTER_WIDTH = 160.0f; constexpr float SPARKLINE_SLIDER_WIDTH = 120.0f; -constexpr float COLLAPSE_ICON_SIZE = 12.0f; // WARNING: increasing the maximum range can result in severe performance degradation. // 30s is a reasonable value at present. constexpr int SPARKLINE_RANGE_MAX = 30; @@ -261,13 +260,13 @@ void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const ImRect rect(option_rect.Min.x + h_margin, option_rect.Min.y + v_margin, option_rect.Max.x - h_margin, option_rect.Max.y - v_margin); // selection background is painted by the row's Selectable - const ImU32 text_color = selected ? highlightedTextColor() : ImGui::GetColorU32(ImGuiCol_Text); + const ImU32 text_color = ImGui::GetColorU32(ImGuiCol_Text); if (column == 0) { if (item->type == SignalModel::Item::Sig) { // color label ImRect icon_rect(rect.Min.x, rect.Min.y, rect.Min.x + COLOR_LABEL_WIDTH, rect.Max.y); - painter->AddRectFilled(icon_rect.Min, icon_rect.Max, toImU32(signalFillColor(item->sig->color).darker(item->highlight ? 125 : 0)), 3.0f); + painter->AddRectFilled(icon_rect.Min, icon_rect.Max, toImU32(signalFillColor(item->sig->color).darker(item->highlight ? 125 : 0)), ImGui::GetStyle().FrameRounding); drawText(painter, icon_rect, std::to_string(item->row() + 1).c_str(), item->highlight ? IM_COL32_WHITE : IM_COL32_BLACK, nullptr, LABEL_FONT); @@ -276,7 +275,7 @@ void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const if (item->sig->type != cabana::Signal::Type::Normal) { const std::string indicator = multiplexIndicator(item->sig); ImRect indicator_rect(rect.Min.x, rect.Min.y, rect.Min.x + ImGui::CalcTextSize(indicator.c_str()).x, rect.Max.y); - painter->AddRectFilled(indicator_rect.Min, indicator_rect.Max, IM_COL32(160, 160, 164, 255), 3.0f); + painter->AddRectFilled(indicator_rect.Min, indicator_rect.Max, IM_COL32(160, 160, 164, 255), ImGui::GetStyle().FrameRounding); drawElidedText(painter, indicator_rect, indicator, IM_COL32_WHITE, false); rect.Min.x = indicator_rect.Max.x + h_margin * 2; } @@ -313,13 +312,12 @@ void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const } // signal value rect.Min.x += value_adjust; - rect.Max.x -= button_size_.x; - if (rect.GetWidth() > 0) drawElidedText(painter, rect, text, text_color, true); - } else { - // no sparkline yet: the value still belongs against the buttons, where it sits once there is one - rect.Max.x -= button_size_.x; - if (rect.GetWidth() > 0) drawElidedText(painter, rect, text, text_color, true); } + // Monospaced digits prevent the value width from changing during playback. + rect.Max.x -= button_size_.x; + pushMonoFont(ImGui::GetFontSize()); + if (rect.GetWidth() > 0) drawElidedText(painter, rect, text, text_color, true); + popMonoFont(); } } @@ -385,7 +383,7 @@ void SignalView::drawEditor(SignalModel::Item *item) { const bool clicked = ImGui::Selectable("##editor", false, 0, ImVec2(0, rowHeight())); ImGui::PopStyleColor(); drawElidedText(ImGui::GetWindowDrawList(), ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()), model_.valueText(item), - highlightedTextColor(), false); + ImGui::GetColorU32(ImGuiCol_Text), false); if (clicked || take_focus) { desc_dlg_ = std::make_unique(item->sig->val_desc); desc_dlg_->title = item->sig->name; @@ -493,12 +491,15 @@ void SignalView::drawValueDescriptionDlg() { desc_sig_ = nullptr; } +static ImVec2 indexButtonsSize(float button) { + return ImVec2(button * 2 + ImGui::GetStyle().ItemInnerSpacing.x * 2, button); +} + SignalView::SignalView(ChartsWidget *charts) : charts_(charts) { settings.sparkline_range = std::clamp(settings.sparkline_range, 1, SPARKLINE_RANGE_MAX); - // seed the size of the [plot][remove] widget (two 22px tool buttons plus the spacing) so the first - // updateState() calls already leave room for the sparklines - button_size_ = ImVec2(22 * 2 + TOOLBAR_ITEM_SPACING, 22); + // Reserve button space for updateState() calls before the first draw (no frame yet: derive the frame height). + button_size_ = indexButtonsSize(UI_FONT_SIZE + ImGui::GetStyle().FramePadding.y * 2.0f); updateToolBar(); connections_.push_back(model_.rowsChanged.connect([this]() { rowsChanged(); })); @@ -617,12 +618,14 @@ float SignalView::widestValueWidth(const cabana::Signal *sig) { const double raw_max = sig->is_signed ? std::ldexp(1.0, sig->size - 1) - 1 : std::ldexp(1.0, sig->size) - 1; const double raw_min = sig->is_signed ? -std::ldexp(1.0, sig->size - 1) : 0.0; float width = 0; + pushMonoFont(ImGui::GetFontSize()); for (double raw : {raw_min, raw_max}) { width = std::max(width, textWidth(sig->formatValue(raw * sig->factor + sig->offset))); } for (const auto &[_, desc] : sig->val_desc) { width = std::max(width, textWidth(desc)); } + popMonoFont(); return width; } @@ -672,8 +675,7 @@ void SignalView::updateState(const std::set *msgs) { // the sparkline label, the range slider and the collapse button float SignalView::toolBarRightWidth(const std::string &range_label) { const ImGuiStyle &style = ImGui::GetStyle(); - return ImGui::CalcTextSize(range_label.c_str()).x + style.ItemSpacing.x + SPARKLINE_SLIDER_WIDTH + style.ItemSpacing.x + - ImGui::GetFont()->CalcTextSizeA(COLLAPSE_ICON_SIZE, FLT_MAX, 0.0f, icon::DASH_SQUARE).x + style.FramePadding.x * 2; + return ImGui::CalcTextSize(range_label.c_str()).x + style.ItemSpacing.x + SPARKLINE_SLIDER_WIDTH + style.ItemSpacing.x + iconButtonWidth(); } // the width at which the tool bar stops squishing: the signal count and the filter box on the left, the @@ -686,8 +688,10 @@ float SignalView::minimumWidth() { } void SignalView::draw() { + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::GetStyleColorVec4(ImGuiCol_WindowBg)); if (!ImGui::BeginChild("SignalView", ImVec2(0, 0), ImGuiChildFlags_Borders)) { ImGui::EndChild(); + ImGui::PopStyleColor(); return; } @@ -710,11 +714,7 @@ void SignalView::draw() { } ImGui::SetItemTooltip("Sparkline time range"); ImGui::SameLine(); - // auto-raise tool button with a 12x12 icon - ImGui::PushFont(ImGui::GetFont(), COLLAPSE_ICON_SIZE); - const bool collapse = toolButton("collapse_all", icon::DASH_SQUARE, "Collapse All"); - ImGui::PopFont(); - if (collapse) collapseAll(); + if (iconButton("collapse_all", icon::ARROWS_COLLAPSE, "Collapse All")) collapseAll(); drawTree(); drawValueDescriptionDlg(); @@ -724,6 +724,7 @@ void SignalView::draw() { current_row_ = model_.signalRow(current_sig_); // used when the row is removed ImGui::EndChild(); + ImGui::PopStyleColor(); } void SignalView::collapseAll() { @@ -872,27 +873,24 @@ bool SignalView::drawItem(SignalModel::Item *item, int depth, DrawContext &ctx) } void SignalView::drawIndexWidget(SignalModel::Item *item, const ImRect &rect) { - // plot_btn + remove_btn, right aligned in the value column - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(3.0f, 2.0f)); - const ImVec2 btn_size(ImGui::CalcTextSize(icon::GRAPH_UP).x + 6.0f, ImGui::GetFrameHeight()); - const ImVec2 size(btn_size.x * 2 + TOOLBAR_ITEM_SPACING, btn_size.y); + const float spacing = ImGui::GetStyle().ItemInnerSpacing.x; + const ImVec2 size = indexButtonsSize(iconButtonWidth()); ImGui::SetCursorScreenPos(ImVec2(rect.Max.x - size.x, rect.Min.y + (rect.GetHeight() - size.y) * 0.5f)); const auto sig = item->sig; const bool checked = item->chart_opened; if (checked) ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive)); - if (ImGui::Button((std::string(icon::GRAPH_UP) + "##plot").c_str(), btn_size) && !editor_open_on_press_) { + if (iconButton("plot", icon::GRAPH_UP) && !editor_open_on_press_) { item->chart_opened = !checked; showChart(model_.msgId(), sig, item->chart_opened, ImGui::GetIO().KeyShift); } if (checked) ImGui::PopStyleColor(); ImGui::SetItemTooltip("%s", checked ? "Close Plot" : "Show Plot\nSHIFT click to add to previous opened plot"); - ImGui::SameLine(0.0f, TOOLBAR_ITEM_SPACING); - if (ImGui::Button((std::string(icon::X) + "##remove").c_str(), btn_size) && !editor_open_on_press_) { + ImGui::SameLine(0.0f, spacing); + if (iconButton("remove", icon::X_LG) && !editor_open_on_press_) { pending_action_ = [this, sig]() { UndoStack::instance()->push(new RemoveSigCommand(model_.msgId(), sig)); }; } ImGui::SetItemTooltip("Remove signal"); - ImGui::PopStyleVar(); button_size_ = size; } @@ -914,12 +912,12 @@ bool ValueDescriptionDlg::draw() { if (!ImGui::BeginPopupModal(popup_id.c_str(), &open, ImGuiWindowFlags_NoSavedSettings)) return ImGui::IsPopupOpen(popup_id.c_str()); bool closing = false; - if (ImGui::Button(icon::PLUS)) { + if (iconButton("add", icon::PLUS_LG, "Add")) { table_.emplace_back("", ""); } - ImGui::SameLine(); + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); ImGui::BeginDisabled(current_row_ == -1); - if (ImGui::Button(icon::DASH) && current_row_ < table_.size()) { + if (iconButton("remove", icon::DASH_LG, "Remove") && current_row_ < table_.size()) { table_.erase(table_.begin() + current_row_); current_row_ = -1; } diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index 7cabac840a..91b41184c0 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -141,7 +141,6 @@ std::string VideoWidget::whatsThis() const { static float toolbarHeight() { return TOOLBAR_MARGIN_Y + ImGui::GetFrameHeight(); } void VideoWidget::drawPlaybackController() { - beginToolbar(); ImGui::SetCursorPosY(ImGui::GetCursorPosY() + TOOLBAR_MARGIN_Y); const float speed_width = menuButtonWidth("0.05x", true); @@ -152,37 +151,29 @@ void VideoWidget::drawPlaybackController() { : formatTime(can->currentSec(), true); const char *time_tooltip = settings.absolute_time ? "Elapsed time" : "Absolute time"; - auto seek_backward = []() { can->seekTo(can->currentSec() - 1); }; - auto toggle_play = []() { can->pause(!can->isPaused()); }; - auto seek_forward = []() { can->seekTo(can->currentSec() + 1); }; - std::vector items = { - {toolbarButtonWidth(icon::REWIND), [&]() { if (toolButton("rewind", icon::REWIND, "Seek backward")) seek_backward(); }, - "Seek backward", seek_backward}, - {toolbarButtonWidth(play_icon), [&]() { if (toolButton("play", play_icon, play_tooltip)) toggle_play(); }, - play_tooltip, toggle_play}, - {toolbarButtonWidth(icon::FAST_FORWARD), [&]() { if (toolButton("fast-forward", icon::FAST_FORWARD, "Seek forward")) seek_forward(); }, - "Seek forward", seek_forward}, + toolbarAction("rewind", icon::REWIND, "Seek backward", []() { can->seekTo(can->currentSec() - 1); }), + toolbarAction("play", play_icon, play_tooltip, []() { can->pause(!can->isPaused()); }, true, true), + toolbarAction("fast-forward", icon::FAST_FORWARD, "Seek forward", []() { can->seekTo(can->currentSec() + 1); }, true, true), }; if (can->liveStreaming()) { - items.push_back({toolbarButtonWidth(icon::SKIP_END), [&]() { - ImGui::BeginDisabled(!skip_to_end_enabled_); - if (toolButton("skip-end", icon::SKIP_END, "Skip to the end")) skipToEnd(); - ImGui::EndDisabled(); - }, "Skip to the end", [this]() { skipToEnd(); }, skip_to_end_enabled_}); + items.push_back(toolbarAction("skip-end", icon::SKIP_END, "Skip to the end", [this]() { skipToEnd(); }, skip_to_end_enabled_, true)); } if (slider_ || msgs_received_) { // a mono font: with proportional digits the time changed width as it ticked and the items after it moved pushMonoFont(ImGui::GetFontSize()); - const float time_width = toolbarButtonWidth(time_text); + const float time_width = ImGui::CalcTextSize(time_text.c_str()).x; popMonoFont(); items.push_back({time_width, [&]() { pushMonoFont(ImGui::GetFontSize()); - if (toolButton("time_display", time_text.c_str(), time_tooltip)) toggleTimeDisplay(); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(time_text.c_str()); popMonoFont(); + if (ImGui::IsItemClicked()) toggleTimeDisplay(); + ImGui::SetItemTooltip("%s", time_tooltip); }, - time_text, [this]() { toggleTimeDisplay(); }}); + time_tooltip, [this]() { toggleTimeDisplay(); }}); } // the expanding spacer: the items after it are right aligned as long as everything fits const size_t spacer_index = items.size(); @@ -195,27 +186,22 @@ void VideoWidget::drawPlaybackController() { ImGui::GetWindowDrawList()->AddLine(ImVec2(x, min.y + 4.0f), ImVec2(x, min.y + ImGui::GetFrameHeight() - 4.0f), ImGui::GetColorU32(ImGuiCol_Separator)); }}; item.in_menu = false; + item.tight = true; return item; }; const char *aspect_ratio_icon = settings.crop_video ? icon::ASPECT_RATIO_FILL : icon::ASPECT_RATIO; - items.push_back({toolbarButtonWidth(aspect_ratio_icon), [&]() { - if (toolButton("crop_video", aspect_ratio_icon, "Crop to fill")) cropVideoClicked(); - }, "Crop to fill", [this]() { cropVideoClicked(); }}); + items.push_back(toolbarAction("crop_video", aspect_ratio_icon, "Crop to fill", [this]() { cropVideoClicked(); })); if (!can->liveStreaming()) { items.push_back(separator()); - items.push_back({toolbarButtonWidth(loop_icon), [&]() { if (toolButton("loop", loop_icon, "Loop playback")) loopPlaybackClicked(); }, - "Loop playback", [this]() { loopPlaybackClicked(); }}); + items.push_back(toolbarAction("loop", loop_icon, "Loop playback", [this]() { loopPlaybackClicked(); }, true, true)); } - items.push_back({speed_width, [&]() { drawSpeedDropdown(speed_width); }}); + items.push_back(toolbarMenu("speed_btn", speed_text_, "Speed", [this]() { drawSpeedMenuItems(); }, true, true, speed_width)); if (!can->liveStreaming()) { items.push_back(separator()); - items.push_back({toolbarButtonWidth(icon::INFO_CIRCLE), - [&]() { if (toolButton("route_info", icon::INFO_CIRCLE, "View route details")) showRouteInfo(); }, - "View route details", [this]() { showRouteInfo(); }}); + items.push_back(toolbarAction("route_info", icon::INFO_CIRCLE, "View route details", [this]() { showRouteInfo(); }, true, true)); } drawToolbar(items, spacer_index); - endToolbar(); } void VideoWidget::skipToEnd() { @@ -241,14 +227,6 @@ void VideoWidget::createSpeedDropdown() { speed_text_ = speedText(speeds[speed_index_]); } -void VideoWidget::drawSpeedDropdown(float width) { - menuButton("speed_btn", speed_text_, "speed_menu", true, width); - if (ImGui::BeginPopup("speed_menu")) { - drawSpeedMenuItems(); - ImGui::EndPopup(); - } -} - void VideoWidget::drawSpeedMenuItems() { // every row declares the same width, so the popup is exactly as wide as the widest one and all the // highlights reach both edges; the label is padded on the right as much as the check column on the left @@ -436,7 +414,7 @@ void Slider::paint(double thumbnail_time) { groove_rect.Min.y = std::floor(center_y - groove_height / 2); groove_rect.Max.y = groove_rect.Min.y + groove_height; - p->AddRectFilled(groove_rect.Min, groove_rect.Max, timeline_colors[(int)TimelineType::None]); + p->AddRectFilled(groove_rect.Min, groove_rect.Max, timeline_colors[(int)TimelineType::None], groove_height * 0.5f); double min = minimum() / factor; double max = maximum() / factor; @@ -471,7 +449,7 @@ void Slider::paint(double thumbnail_time) { if (thumbnail_time >= 0) { float left = rect_.Min.x + (float)((thumbnail_time - min) * width() / span) - 1; ImRect rc(ImVec2(left, rect_.Min.y + 1), ImVec2(left + 2, rect_.Max.y - 1)); - p->AddRectFilled(rc.Min, rc.Max, ImGui::GetColorU32(ImGuiCol_Header), 1.5f); // ImGuiCol_Header is the theme highlight + p->AddRectFilled(rc.Min, rc.Max, ImGui::GetColorU32(ImGuiCol_Header), 1.0f); } } @@ -558,10 +536,10 @@ const RgbImage *StreamCameraView::thumbnailAt(double sec) { } void StreamCameraView::drawScrubThumbnail(ImDrawList *p, double sec) { - p->AddRectFilled(rect().Min, rect().Max, IM_COL32(0, 0, 0, 255)); + p->AddRectFilled(rect().Min, rect().Max, IM_COL32(0, 0, 0, 255), ImGui::GetStyle().ChildRounding); if (const RgbImage *image = thumbnailAt(sec)) { const VideoPlacement placement = videoPlacement(rect(), (float)image->width / image->height, settings.crop_video); - p->AddImage(big_thumbnail_texture_.ref(), placement.min, placement.max, placement.uv0, placement.uv1); + p->AddImageRounded(big_thumbnail_texture_.ref(), placement.min, placement.max, placement.uv0, placement.uv1, IM_COL32_WHITE, ImGui::GetStyle().ChildRounding); drawTime(p, rect(), sec); } } @@ -578,8 +556,8 @@ void StreamCameraView::drawThumbnail(ImDrawList *p, double sec) { int y = height() - h - THUMBNAIL_MARGIN; ImRect thumb_rect(ImVec2(rect().Min.x + x, rect().Min.y + y), ImVec2(rect().Min.x + x + w, rect().Min.y + y + h)); - p->AddImage(big_thumbnail_texture_.ref(), thumb_rect.Min, thumb_rect.Max); - p->AddRect(thumb_rect.Min, thumb_rect.Max, paletteBrightText(), 0.0f, 0, 2.0f); + p->AddImageRounded(big_thumbnail_texture_.ref(), thumb_rect.Min, thumb_rect.Max, ImVec2(0, 0), ImVec2(1, 1), IM_COL32_WHITE, ImGui::GetStyle().FrameRounding); + p->AddRect(thumb_rect.Min, thumb_rect.Max, IM_COL32_WHITE, ImGui::GetStyle().FrameRounding, 0, 2.0f); // look up the alert at the hovered time, the thumbnail frame itself can be seconds away if (auto alert = getReplay()->findAlertAtTime(sec)) { drawAlert(p, thumb_rect, *alert, POINT_10_FONT_SIZE); @@ -595,11 +573,11 @@ void StreamCameraView::drawTime(ImDrawList *p, const ImRect &rect, double second const ImVec2 text_size = font->CalcTextSizeA(POINT_10_FONT_SIZE, FLT_MAX, 0.0f, text); // centered horizontally, above the bottom margin p->AddText(font, POINT_10_FONT_SIZE, ImVec2(rect.GetCenter().x - text_size.x / 2, rect.Max.y - THUMBNAIL_MARGIN - text_size.y), - paletteBrightText(), text); + IM_COL32_WHITE, text); } void StreamCameraView::drawAlert(ImDrawList *p, const ImRect &rect, const Timeline::Entry &alert, float font_size) { - const ImU32 pen = paletteBrightText(); + const ImU32 pen = IM_COL32_WHITE; ImU32 color = withAlpha(timeline_colors[int(alert.type)], 128); std::string text = alert.text1; if (!alert.text2.empty()) text += "\n" + alert.text2; @@ -608,7 +586,7 @@ void StreamCameraView::drawAlert(ImDrawList *p, const ImRect &rect, const Timeli ImFont *font = ImGui::GetFont(); const float wrap_width = std::max(1.0f, text_rect.GetWidth()); const ImVec2 r = font->CalcTextSizeA(font_size, FLT_MAX, wrap_width, text.c_str()); - p->AddRectFilled(ImVec2(text_rect.Min.x, text_rect.Min.y), ImVec2(text_rect.Max.x, text_rect.Min.y + r.y), color); + p->AddRectFilled(ImVec2(text_rect.Min.x, text_rect.Min.y), ImVec2(text_rect.Max.x, text_rect.Min.y + r.y), color, ImGui::GetStyle().FrameRounding, ImDrawFlags_RoundCornersTop); // each line is centered, wrapped continuations stay left aligned float y = text_rect.Min.y; for (const auto &line : utils::split(text, '\n')) { diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.h b/openpilot/tools/cabana/ui/widgets/videowidget.h index 90058850c3..a1f6076a64 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.h +++ b/openpilot/tools/cabana/ui/widgets/videowidget.h @@ -102,7 +102,6 @@ private: void skipToEnd(); void toggleTimeDisplay(); void createSpeedDropdown(); - void drawSpeedDropdown(float width); void drawSpeedMenuItems(); void loopPlaybackClicked(); void cropVideoClicked(); diff --git a/openpilot/tools/cabana/utils/util.h b/openpilot/tools/cabana/utils/util.h index 203c504c44..3570320f34 100644 --- a/openpilot/tools/cabana/utils/util.h +++ b/openpilot/tools/cabana/utils/util.h @@ -60,20 +60,6 @@ ValidState validateIpAddress(const std::string &input); // C-locale floating-point ValidState validateDouble(const std::string &input); -struct DarkTheme { - static constexpr CabanaColor window{0x35, 0x35, 0x35}; - static constexpr CabanaColor window_text{0xff, 0xff, 0xff}; - static constexpr CabanaColor base{0x19, 0x19, 0x19}; - static constexpr CabanaColor tooltip_text{0xff, 0xff, 0xff}; - static constexpr CabanaColor text{0xff, 0xff, 0xff}; - static constexpr CabanaColor button{0x35, 0x35, 0x35}; - static constexpr CabanaColor highlight{0x2a, 0x82, 0xda}; - static constexpr CabanaColor bright_text{0xff, 0xff, 0xff}; - static constexpr CabanaColor disabled_text{0x7f, 0x7f, 0x7f}; - static constexpr CabanaColor light{0x50, 0x50, 0x50}; - static constexpr CabanaColor dark{0x23, 0x23, 0x23}; -}; - namespace utils { bool isMainThread(); From 6bbdf8ad18d86d8eff460dbb9568e1920a2115a6 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sun, 6 Sep 2026 21:20:39 -0700 Subject: [PATCH 020/122] remove sentry (#38790) --- openpilot/system/manager/manager.py | 4 +- openpilot/system/manager/process.py | 4 +- openpilot/system/sentry.py | 73 ----------------------------- openpilot/system/tombstoned.py | 22 +++------ pyproject.toml | 1 - tools/release/README.md | 2 +- uv.lock | 15 ------ 7 files changed, 10 insertions(+), 111 deletions(-) delete mode 100644 openpilot/system/sentry.py diff --git a/openpilot/system/manager/manager.py b/openpilot/system/manager/manager.py index 86fca2723f..929d12754c 100755 --- a/openpilot/system/manager/manager.py +++ b/openpilot/system/manager/manager.py @@ -8,7 +8,6 @@ import traceback from openpilot.cereal import log import openpilot.cereal.messaging as messaging -import openpilot.system.sentry as sentry from openpilot.common.utils import atomic_write from openpilot.common.params import Params, ParamKeyFlag from openpilot.common.text_window import TextWindow @@ -78,7 +77,6 @@ def manager_init() -> None: os.environ['CLEAN'] = '1' # init logging - sentry.init(sentry.SentryProject.SELFDRIVE) cloudlog.bind_global(dongle_id=dongle_id, version=build_metadata.openpilot.version, origin=build_metadata.openpilot.git_normalized_origin, @@ -187,7 +185,7 @@ def main() -> None: manager_thread() except Exception: traceback.print_exc() - sentry.capture_exception() + cloudlog.exception("crash") finally: manager_cleanup() diff --git a/openpilot/system/manager/process.py b/openpilot/system/manager/process.py index 43ea3f7306..363f7f38e7 100644 --- a/openpilot/system/manager/process.py +++ b/openpilot/system/manager/process.py @@ -12,7 +12,6 @@ from setproctitle import setproctitle from openpilot.cereal import log from opendbc.car.structs import car import openpilot.cereal.messaging as messaging -import openpilot.system.sentry as sentry from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog @@ -31,7 +30,6 @@ def launcher(proc: str, name: str) -> None: # add daemon name tag to logs cloudlog.bind(daemon=name) - sentry.set_tag("daemon", name) # exec the process mod.main() @@ -40,7 +38,7 @@ def launcher(proc: str, name: str) -> None: except Exception: # can't install the crash handler because sys.excepthook doesn't play nice # with threads, so catch it here. - sentry.capture_exception() + cloudlog.exception("crash") raise diff --git a/openpilot/system/sentry.py b/openpilot/system/sentry.py deleted file mode 100644 index 8a4e1bb9f2..0000000000 --- a/openpilot/system/sentry.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Install exception handler for process crash.""" -import sentry_sdk -from enum import Enum -from sentry_sdk.integrations.threading import ThreadingIntegration - -from openpilot.common.params import Params -from openpilot.system.athena.registration import is_registered_device -from openpilot.common.hardware import HARDWARE, PC -from openpilot.common.swaglog import cloudlog -from openpilot.common.version import get_build_metadata, get_version - - -class SentryProject(Enum): - # python project - SELFDRIVE = "https://6f3c7076c1e14b2aa10f5dde6dda0cc4@o33823.ingest.sentry.io/77924" - # native project - SELFDRIVE_NATIVE = "https://3e4b586ed21a4479ad5d85083b639bc6@o33823.ingest.sentry.io/157615" - - -def report_tombstone(fn: str, message: str, contents: str) -> None: - cloudlog.error({'tombstone': message}) - - with sentry_sdk.configure_scope() as scope: - scope.set_extra("tombstone_fn", fn) - scope.set_extra("tombstone", contents) - sentry_sdk.capture_message(message=message) - sentry_sdk.flush() - - -def capture_exception(*args, **kwargs) -> None: - cloudlog.error("crash", exc_info=kwargs.get('exc_info', 1)) - - try: - sentry_sdk.capture_exception(*args, **kwargs) - sentry_sdk.flush() # https://github.com/getsentry/sentry-python/issues/291 - except Exception: - cloudlog.exception("sentry exception") - - -def set_tag(key: str, value: str) -> None: - sentry_sdk.set_tag(key, value) - - -def init(project: SentryProject) -> bool: - build_metadata = get_build_metadata() - # forks like to mess with this, so double check - comma_remote = build_metadata.openpilot.comma_remote and "commaai" in build_metadata.openpilot.git_origin - if not comma_remote or not is_registered_device() or PC: - return False - - env = "release" if build_metadata.tested_channel else "master" - dongle_id = Params().get("DongleId") - - integrations = [] - if project == SentryProject.SELFDRIVE: - integrations.append(ThreadingIntegration(propagate_hub=True)) - - sentry_sdk.init(project.value, - default_integrations=False, - release=get_version(), - integrations=integrations, - traces_sample_rate=1.0, - max_value_length=8192, - environment=env) - - sentry_sdk.set_user({"id": dongle_id}) - sentry_sdk.set_tag("dirty", build_metadata.openpilot.is_dirty) - sentry_sdk.set_tag("origin", build_metadata.openpilot.git_origin) - sentry_sdk.set_tag("branch", build_metadata.channel) - sentry_sdk.set_tag("commit", build_metadata.openpilot.git_commit) - sentry_sdk.set_tag("device", HARDWARE.get_device_type()) - - return True diff --git a/openpilot/system/tombstoned.py b/openpilot/system/tombstoned.py index 7741a923cd..7661ec7de5 100755 --- a/openpilot/system/tombstoned.py +++ b/openpilot/system/tombstoned.py @@ -9,10 +9,11 @@ import time import glob from typing import NoReturn -import openpilot.system.sentry as sentry +from openpilot.common.hardware import PC from openpilot.common.hardware.hw import Paths from openpilot.common.swaglog import cloudlog from openpilot.common.version import get_build_metadata +from openpilot.system.athena.registration import is_registered_device MAX_SIZE = 1_000_000 * 100 # allow up to 100M MAX_TOMBSTONE_FN_LEN = 62 # 85 - 23 ("/crash/") @@ -65,22 +66,12 @@ def report_tombstone_apport(fn): return message = "" # One line description of the crash - contents = "" # Full file contents without coredump path = "" # File path relative to openpilot directory - proc_maps = False - with open(fn) as f: for line in f: if "CoreDump" in line: break - elif "ProcMaps" in line: - proc_maps = True - elif "ProcStatus" in line: - proc_maps = False - - if not proc_maps: - contents += line if "ExecutablePath" in line: path = line.strip().split(': ')[-1] @@ -112,13 +103,12 @@ def report_tombstone_apport(fn): if not found: crash_function = stacktrace_s[1] - # Remove arguments that can contain pointers to make sentry one-liner unique + # Remove arguments that can contain pointers from the crash summary crash_function = " ".join(x for x in crash_function.split(' ')[1:] if not x.startswith('0x')) crash_function = re.sub(r'\(.*?\)', '', crash_function) - contents = stacktrace + "\n\n" + contents message = message + " - " + crash_function - sentry.report_tombstone(fn, message, contents) + cloudlog.error({'tombstone': message}) # Copy crashlog to upload folder clean_path = path.replace('/', '_') @@ -141,7 +131,9 @@ def report_tombstone_apport(fn): def main() -> NoReturn: - should_report = sentry.init(sentry.SentryProject.SELFDRIVE_NATIVE) + build_metadata = get_build_metadata() + comma_remote = build_metadata.openpilot.comma_remote and "commaai" in build_metadata.openpilot.git_origin + should_report = comma_remote and is_registered_device() and not PC # Clear apport folder on start, otherwise duplicate crashes won't register clear_apport_folder() diff --git a/pyproject.toml b/pyproject.toml index 78fb53ed1d..7be8f97c0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,6 @@ dependencies = [ # these should be removed "pyzmq", - "sentry-sdk", "setproctitle", "jeepney", "zstandard", # this can go once we're on Python 3.14+ diff --git a/tools/release/README.md b/tools/release/README.md index 862836bf90..7e2e7e2faf 100644 --- a/tools/release/README.md +++ b/tools/release/README.md @@ -22,7 +22,7 @@ - [ ] fresh install with `openpilot-test.comma.ai` - [ ] drive on fresh install - [ ] no submodules or LFS - - [ ] check sentry, MTBF, etc. + - [ ] check MTBF, etc. - [ ] stress test passes in production - [ ] publish the blog post - [ ] `git reset --hard origin/release-mici-staging` diff --git a/uv.lock b/uv.lock index dc9cb69845..c27429f5f2 100644 --- a/uv.lock +++ b/uv.lock @@ -609,7 +609,6 @@ dependencies = [ { name = "pyzmq" }, { name = "requests" }, { name = "scons" }, - { name = "sentry-sdk" }, { name = "setproctitle" }, { name = "sounddevice" }, { name = "tqdm" }, @@ -676,7 +675,6 @@ requires-dist = [ { name = "requests" }, { name = "ruff", marker = "extra == 'testing'" }, { name = "scons" }, - { name = "sentry-sdk" }, { name = "setproctitle" }, { name = "sounddevice" }, { name = "teleoprtc", marker = "extra == 'submodules'", editable = "teleoprtc_repo" }, @@ -930,19 +928,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/43/d6285848e893c19682c06e92679dc1a07d37ff7ea148747b1df681ec496c/scons-4.11.1-py3-none-any.whl", hash = "sha256:454cef364348053422696e3d2ecb4fa593c96a624f955842eaaea64f95c8d11d", size = 4123659, upload-time = "2026-08-27T04:33:12.728Z" }, ] -[[package]] -name = "sentry-sdk" -version = "2.68.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/94/23b7dd072acb9628907bd3f4fbf61794a7b12a9db8f33c1276f70ae5ac92/sentry_sdk-2.68.0.tar.gz", hash = "sha256:648c58e9887311a03470a41539e24bdbbf64a30ca4f5336f7e3dcc87276400b3", size = 1008854, upload-time = "2026-08-13T09:06:21.268Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/9b/e2421d08956d0bc4691d995393d835e563886bff499d8fb10fdefae85a8d/sentry_sdk-2.68.0-py3-none-any.whl", hash = "sha256:538e56c2d03679d42f7c0cb5f1af73a7a510b00abc7e296c13ac49b107b713a4", size = 518670, upload-time = "2026-08-13T09:06:19.735Z" }, -] - [[package]] name = "setproctitle" version = "1.3.7" From 65c411433bebe2833825651b2ad2eedb418172ee Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:22:23 -0700 Subject: [PATCH 021/122] cabana: reduce downloader startup and dense chart rendering overhead (#38793) * cabana: reduce downloader startup and dense chart rendering overhead * cabana: remove downloader spawn tests --- openpilot/tools/cabana/tests/test_cabana.cc | 25 ++++++ openpilot/tools/cabana/ui/chart/chart.cc | 9 +- openpilot/tools/cabana/ui/chart/downsample.h | 39 +++++++++ openpilot/tools/lib/file_downloader.py | 10 +-- openpilot/tools/replay/py_downloader.cc | 87 +++++++++++++------- 5 files changed, 133 insertions(+), 37 deletions(-) create mode 100644 openpilot/tools/cabana/ui/chart/downsample.h diff --git a/openpilot/tools/cabana/tests/test_cabana.cc b/openpilot/tools/cabana/tests/test_cabana.cc index 3523a47e08..e1c98fcc8e 100644 --- a/openpilot/tools/cabana/tests/test_cabana.cc +++ b/openpilot/tools/cabana/tests/test_cabana.cc @@ -10,6 +10,7 @@ #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/routes.h" #include "tools/cabana/ui/qtstate.h" +#include "tools/cabana/ui/chart/downsample.h" #include "tools/cabana/utils/strings.h" const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.bz2"; @@ -353,7 +354,31 @@ void test_qt_state_blobs() { REQUIRE(!qtstate::parseQtHeaderState(fromHex("000000fe00000000000000010000000000000000010000000000000000")).has_value()); } +void test_pixel_envelope() { + struct Point { + double x, y; + Point(double x, double y) : x(x), y(y) {} + }; + std::vector points; + for (int i = 0; i < 1000; ++i) points.emplace_back(i * 0.001, i == 203 ? 99 : i == 201 ? -99 : 0); + const auto result = chart::pixelEnvelope(points.begin(), points.end(), 0, 1, 10); + REQUIRE(result.size() <= 40); + REQUIRE(result.front().x == points.front().x); + REQUIRE(result.back().x == points.back().x); + REQUIRE(std::is_sorted(result.begin(), result.end(), [](const auto &a, const auto &b) { return a.x < b.x; })); + REQUIRE(std::any_of(result.begin(), result.end(), [](const auto &p) { return p.x == .201 && p.y == -99; })); + REQUIRE(std::any_of(result.begin(), result.end(), [](const auto &p) { return p.x == .203 && p.y == 99; })); + const std::vector step{{-1, 0}, {0, 0}, {0, 10}, {0, -10}, {0, 0}, {2, 0}}; + const auto edge = chart::pixelEnvelope(step.begin(), step.end(), 0, 1, 2); + REQUIRE(edge.front().x == -1); + REQUIRE(edge.back().x == 2); + REQUIRE(std::any_of(edge.begin(), edge.end(), [](const auto &p) { return p.y == 10; })); + REQUIRE(std::any_of(edge.begin(), edge.end(), [](const auto &p) { return p.y == -10; })); + REQUIRE(chart::pixelEnvelope(points.begin(), points.begin(), 0, 1, 10).empty()); +} + void test_cabana_core() { + test_pixel_envelope(); test_format_seconds(); test_to_hex(); test_signal_tooltip(); diff --git a/openpilot/tools/cabana/ui/chart/chart.cc b/openpilot/tools/cabana/ui/chart/chart.cc index ea54fc819b..399304186e 100644 --- a/openpilot/tools/cabana/ui/chart/chart.cc +++ b/openpilot/tools/cabana/ui/chart/chart.cc @@ -11,6 +11,7 @@ #include "tools/cabana/core/settings.h" #include "tools/cabana/settings.h" #include "tools/cabana/ui/chart/chartswidget.h" +#include "tools/cabana/ui/chart/downsample.h" #include "tools/cabana/ui/icons.h" #include "tools/cabana/ui/util.h" #include "tools/cabana/utils/strings.h" @@ -665,7 +666,13 @@ void ChartView::drawSeries() { if (begin == end) continue; spec.LineWeight = 2; - ImPlot::PlotLine(label.c_str(), &begin->x, &begin->y, end - begin, spec); + const int pixels = std::max(1, (int)layout_.plot_area.GetWidth()); + if (end - begin > pixels * 4) { + const auto envelope = chart::pixelEnvelope(begin, end, x_min_, x_max_, pixels); + ImPlot::PlotLine(label.c_str(), &envelope.front().x, &envelope.front().y, envelope.size(), spec); + } else { + ImPlot::PlotLine(label.c_str(), &begin->x, &begin->y, end - begin, spec); + } // show points when zoomed in enough if ((num_points == 1 || pixels_per_point > 20) && first != last) { diff --git a/openpilot/tools/cabana/ui/chart/downsample.h b/openpilot/tools/cabana/ui/chart/downsample.h new file mode 100644 index 0000000000..e523125b62 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/downsample.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace chart { + +// Keep endpoints and both extrema in time order in each pixel column. Only rendering +// uses this envelope; calculations, cursor values and exports retain every sample. +template +auto pixelEnvelope(Iterator begin, Iterator end, double min, double max, int pixels) { + using Point = typename std::iterator_traits::value_type; + std::vector result; + if (begin == end || pixels <= 0 || max <= min) return result; + result.reserve(std::min(end - begin, (size_t)pixels * 4)); + auto column = [&](const auto &p) { + return (int)std::clamp((p.x - min) / (max - min) * pixels, 0.0, double(pixels - 1)); + }; + while (begin != end) { + auto last = begin, low = begin, high = begin, next = begin + 1; + const int bucket = column(*begin); + while (next != end && column(*next) == bucket) { + if (next->y < low->y) low = next; + if (next->y > high->y) high = next; + last = next++; + } + std::array selected{begin, low, high, last}; + std::sort(selected.begin(), selected.end()); + auto unique_end = std::unique(selected.begin(), selected.end()); + for (auto it = selected.begin(); it != unique_end; ++it) result.push_back(**it); + begin = next; + } + return result; +} + +} // namespace chart diff --git a/openpilot/tools/lib/file_downloader.py b/openpilot/tools/lib/file_downloader.py index efb06095be..fd517d7ada 100755 --- a/openpilot/tools/lib/file_downloader.py +++ b/openpilot/tools/lib/file_downloader.py @@ -19,16 +19,14 @@ import shutil import sys import tempfile -import zstandard as zstd - from openpilot.common.hardware.hw import Paths -from openpilot.tools.lib.api import CommaApi, UnauthorizedError, APIError -from openpilot.tools.lib.auth_config import get_token -from openpilot.tools.lib.url_file import URLFile def api_call(func): """Run an API call, outputting JSON result or error to stdout.""" + from openpilot.tools.lib.api import CommaApi, UnauthorizedError, APIError + from openpilot.tools.lib.auth_config import get_token + try: result = func(CommaApi(get_token())) json.dump(result, sys.stdout) @@ -62,6 +60,7 @@ def make_decompressor(compression): if compression == 'bz2': return bz2.BZ2Decompressor() if compression == 'zst': + import zstandard as zstd return zstd.ZstdDecompressor().decompressobj() raise ValueError(f"Unsupported compression type: {compression}") @@ -126,6 +125,7 @@ def cmd_download(args): try: # Stream the file in a single HTTP request instead of making # a separate Range request per chunk (which was very slow). + from openpilot.tools.lib.url_file import URLFile pool = URLFile.pool_manager() r = pool.request("GET", url, preload_content=False) if r.status not in (200, 206): diff --git a/openpilot/tools/replay/py_downloader.cc b/openpilot/tools/replay/py_downloader.cc index db2b2be127..a7ab5baa91 100644 --- a/openpilot/tools/replay/py_downloader.cc +++ b/openpilot/tools/replay/py_downloader.cc @@ -5,6 +5,10 @@ #include #include #include +#include +#ifdef __APPLE__ +#include +#endif #include #include #include @@ -27,7 +31,7 @@ void reportProgress(const char *line) { // Run a Python command and capture stdout. Stderr is scanned for PROGRESS lines and otherwise passed // through to the parent's stderr. Returns stdout content. If abort is signaled, kills the child process. std::string runPython(const std::vector &args, std::atomic *abort = nullptr) { - // Build argv for execvp + // Build argv for the downloader module std::vector argv; argv.push_back("python3"); argv.push_back("-m"); @@ -37,50 +41,71 @@ std::string runPython(const std::vector &args, std::atomic *a } argv.push_back(nullptr); + auto open_pipe = [](int (&fds)[2]) { +#ifdef __linux__ + return pipe2(fds, O_CLOEXEC); +#else + if (pipe(fds) != 0) return -1; + if (fcntl(fds[0], F_SETFD, FD_CLOEXEC) == 0 && fcntl(fds[1], F_SETFD, FD_CLOEXEC) == 0) return 0; + close(fds[0]); close(fds[1]); + return -1; +#endif + }; int stdout_pipe[2], stderr_pipe[2]; - if (pipe(stdout_pipe) != 0) { + if (open_pipe(stdout_pipe) != 0) { rWarning("py_downloader: pipe() failed"); return {}; } - if (pipe(stderr_pipe) != 0) { + if (open_pipe(stderr_pipe) != 0) { rWarning("py_downloader: pipe() failed"); close(stdout_pipe[0]); close(stdout_pipe[1]); return {}; } - pid_t pid = fork(); - if (pid < 0) { - rWarning("py_downloader: fork() failed"); + // Avoid copying the large replay address space and running atfork handlers on + // every segment download: both can stall rendering even from a worker thread. + std::vector environment; +#ifdef __APPLE__ + char **parent_environment = *_NSGetEnviron(); +#else + char **parent_environment = environ; +#endif + for (char **entry = parent_environment; *entry; ++entry) { + if (strncmp(*entry, "OPENPILOT_PREFIX=", 17) != 0) environment.emplace_back(*entry); + } + std::vector envp; + for (auto &entry : environment) envp.push_back(entry.data()); + envp.push_back(nullptr); + + posix_spawn_file_actions_t actions; + posix_spawnattr_t attributes; + int error = posix_spawn_file_actions_init(&actions); + const bool actions_initialized = error == 0; + if (!error) error = posix_spawnattr_init(&attributes); + const bool attributes_initialized = error == 0; + if (!error) error = posix_spawn_file_actions_addopen(&actions, STDIN_FILENO, "/dev/null", O_RDONLY, 0); + if (!error) error = posix_spawn_file_actions_adddup2(&actions, stdout_pipe[1], STDOUT_FILENO); + if (!error) error = posix_spawn_file_actions_adddup2(&actions, stderr_pipe[1], STDERR_FILENO); + for (int fd : {stdout_pipe[0], stdout_pipe[1], stderr_pipe[0], stderr_pipe[1]}) { + if (!error) error = posix_spawn_file_actions_addclose(&actions, fd); + } +#ifdef POSIX_SPAWN_SETSID + if (!error) error = posix_spawnattr_setflags(&attributes, POSIX_SPAWN_SETSID); +#else + if (!error) error = posix_spawnattr_setpgroup(&attributes, 0); + if (!error) error = posix_spawnattr_setflags(&attributes, POSIX_SPAWN_SETPGROUP); +#endif + pid_t pid = -1; + if (!error) error = posix_spawnp(&pid, "python3", &actions, &attributes, const_cast(argv.data()), envp.data()); + if (attributes_initialized) posix_spawnattr_destroy(&attributes); + if (actions_initialized) posix_spawn_file_actions_destroy(&actions); + if (error) { + rWarning("py_downloader: posix_spawnp() failed: %s", strerror(error)); close(stdout_pipe[0]); close(stdout_pipe[1]); close(stderr_pipe[0]); close(stderr_pipe[1]); return {}; } - if (pid == 0) { - // Child process — detach from controlling terminal so Python - // cannot corrupt terminal settings needed by ncurses in the parent. - setsid(); - int devnull = open("/dev/null", O_RDONLY); - if (devnull >= 0) { - dup2(devnull, STDIN_FILENO); - if (devnull > STDERR_FILENO) close(devnull); - } - - // Clear OPENPILOT_PREFIX so the Python process uses default paths - // (e.g. ~/.comma/auth.json). The prefix is only for IPC in the parent. - unsetenv("OPENPILOT_PREFIX"); - - close(stdout_pipe[0]); - dup2(stdout_pipe[1], STDOUT_FILENO); - close(stdout_pipe[1]); - close(stderr_pipe[0]); - dup2(stderr_pipe[1], STDERR_FILENO); - close(stderr_pipe[1]); - - execvp("python3", const_cast(argv.data())); - _exit(127); - } - // Parent process close(stdout_pipe[1]); close(stderr_pipe[1]); From cb70ba89aa41b78d1cf56f6e2be93210cb5ee1c9 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:50:13 -0700 Subject: [PATCH 022/122] cabana: smooth startup layout (#38794) * cabana: smooth startup layout * cabana: remove camera tab startup comment * cabana: initialize dock geometry on the first frame * cabana: remove route loading placeholder --- openpilot/tools/cabana/ui/app.cc | 4 +++ openpilot/tools/cabana/ui/mainwin.cc | 29 ++++++++++++------- .../tools/cabana/ui/widgets/cameraview.cc | 2 +- .../tools/cabana/ui/widgets/cameraview.h | 2 ++ .../tools/cabana/ui/widgets/videowidget.cc | 14 +++++++-- 5 files changed, 37 insertions(+), 14 deletions(-) diff --git a/openpilot/tools/cabana/ui/app.cc b/openpilot/tools/cabana/ui/app.cc index ce4678f223..3f4dac1016 100644 --- a/openpilot/tools/cabana/ui/app.cc +++ b/openpilot/tools/cabana/ui/app.cc @@ -132,6 +132,8 @@ public: #ifdef __APPLE__ glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE); #endif + // Restore geometry and render the initial layout before mapping the window. + glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE); window_ = glfwCreateWindow(1600, 900, "Cabana", nullptr, nullptr); if (window_ == nullptr) { glfwTerminate(); @@ -212,6 +214,8 @@ int run(std::unique_ptr stream, StreamLoader stream_loader, cons inistate::applyWindowGeometry(glfw.window()); MainWindow win(glfw.window(), std::move(stream), std::move(stream_loader), dbc_file); + renderFrame(glfw.window(), &win); + glfwShowWindow(glfw.window()); while (!win.exited()) { if (g_signal_exit.exchange(false)) { printf("\nexiting...\n"); diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index e5490432a2..d78de4be34 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -184,7 +184,7 @@ void MainWindow::drawMenuBar() { if (ImGui::MenuItem("Full Screen", "Ctrl+F11")) toggleFullScreen(); ImGui::Separator(); ImGui::MenuItem(messages_widget_ ? messages_widget_->title().c_str() : "MESSAGES", nullptr, &messages_visible_); - ImGui::MenuItem(video_dock_title_.empty() ? "##video_dock" : video_dock_title_.c_str(), nullptr, &video_visible_); + ImGui::MenuItem(video_dock_title_.empty() ? "Video" : video_dock_title_.c_str(), nullptr, &video_visible_); ImGui::Separator(); if (ImGui::MenuItem("Reset Window Layout")) { messages_visible_ = video_visible_ = true; @@ -789,8 +789,10 @@ void MainWindow::drawWaitDialog() { void MainWindow::drawDockspace() { const ImGuiViewport *viewport = ImGui::GetMainViewport(); - ImGui::SetNextWindowPos(viewport->WorkPos); - ImGui::SetNextWindowSize(viewport->WorkSize); + // Use the menu bar's current-frame reservation, including on the first frame. + const ImRect work_rect = static_cast(viewport)->GetBuildWorkRect(); + ImGui::SetNextWindowPos(work_rect.Min); + ImGui::SetNextWindowSize(work_rect.GetSize()); ImGui::SetNextWindowViewport(viewport->ID); ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); @@ -813,6 +815,7 @@ void MainWindow::drawDockspace() { // messages left, video (with charts) right, center widget in the middle ImGui::DockBuilderRemoveNode(dock_id); ImGui::DockBuilderAddNode(dock_id, ImGuiDockNodeFlags_DockSpace); + ImGui::DockBuilderSetNodePos(dock_id, ImGui::GetCursorScreenPos()); ImGui::DockBuilderSetNodeSize(dock_id, dock_size); ImGuiID center = dock_id, left = 0, right = 0; ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, 0.28f, &left, ¢er); @@ -856,11 +859,13 @@ bool beginPanel(const char *name, bool *open, ImGuiWindowFlags flags = 0) { } // namespace void MainWindow::drawMessagesPanel() { - const std::string name = messages_widget_->title() + MESSAGES_PANEL_ID; + const std::string name = (messages_widget_ ? messages_widget_->title() : "MESSAGES") + std::string(MESSAGES_PANEL_ID); setNextPanelClass(); if (beginPanel(name.c_str(), &messages_visible_)) { - help_overlay_.add(messages_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); - messages_widget_->draw(); + if (messages_widget_) { + help_overlay_.add(messages_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); + messages_widget_->draw(); + } } const bool floating = floatingOut(); ImGui::End(); @@ -868,13 +873,13 @@ void MainWindow::drawMessagesPanel() { } void MainWindow::drawVideoPanel() { - const std::string name = video_dock_title_ + VIDEO_PANEL; + const std::string name = (video_dock_title_.empty() ? "Video" : video_dock_title_) + VIDEO_PANEL; setNextPanelClass(); const bool video_open = beginPanel(name.c_str(), &video_visible_); const bool floating = floatingOut(); - if (!video_open) { + if (video_widget_ && !video_open) { video_widget_->setVisible(false); // the dock is collapsed or tabbed behind another one, like hideEvent - } else { + } else if (video_widget_) { const ImVec2 avail = ImGui::GetContentRegionAvail(); const bool live = can->liveStreaming(); // the bordered child pads its content, so the heights the widget asks for grow by the padding @@ -962,9 +967,11 @@ void MainWindow::draw() { ImGui::EndChild(); } ImGui::End(); - if (messages_widget_ && messages_visible_) drawMessagesPanel(); + // Submit the same dock windows while loading, so ImGui doesn't collapse their + // nodes and then redistribute the layout when the stream's widgets arrive. + if (messages_visible_) drawMessagesPanel(); if (video_widget_ && !video_visible_) video_widget_->setVisible(false); - if (video_widget_ && video_visible_) drawVideoPanel(); + if (video_visible_) drawVideoPanel(); if (charts_widget_ && charts_floating_) { bool open = true; ImGui::SetNextWindowSize(ImGui::GetMainViewport()->WorkSize, ImGuiCond_Appearing); diff --git a/openpilot/tools/cabana/ui/widgets/cameraview.cc b/openpilot/tools/cabana/ui/widgets/cameraview.cc index 1f730344d5..460ef47c25 100644 --- a/openpilot/tools/cabana/ui/widgets/cameraview.cc +++ b/openpilot/tools/cabana/ui/widgets/cameraview.cc @@ -95,7 +95,7 @@ float CameraWidget::frameAspectRatio() const { if (frame_texture_.width > 0 && frame_texture_.height > 0) { return (float)frame_texture_.width / frame_texture_.height; } - return 1928.0f / 1208.0f; // the road camera, until the first frame arrives + return DEFAULT_CAMERA_ASPECT_RATIO; // the road camera, until the first frame arrives } void CameraWidget::paint() { diff --git a/openpilot/tools/cabana/ui/widgets/cameraview.h b/openpilot/tools/cabana/ui/widgets/cameraview.h index 2812aea86e..a90565a0f8 100644 --- a/openpilot/tools/cabana/ui/widgets/cameraview.h +++ b/openpilot/tools/cabana/ui/widgets/cameraview.h @@ -18,6 +18,8 @@ #include "tools/cabana/core/observable.h" #include "msgq/visionipc/visionipc_client.h" +constexpr float DEFAULT_CAMERA_ASPECT_RATIO = 1928.0f / 1208.0f; + // Center-crop the source to fill the destination without stretching. inline ImVec2 videoFillUv(const ImVec2 &size, float aspect_ratio) { const float ratio = size.x / size.y / aspect_ratio; diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index 91b41184c0..ce8fb64c04 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -261,6 +261,16 @@ void VideoWidget::createCameraWidget() { if (index != -1) cam_widget_->setStreamType((VisionStreamType)camera_tab_->tabData(index)); })); connections_.push_back(static_cast(can)->qLogLoaded.connect([this](std::shared_ptr qlog) { cam_widget_->parseQLog(qlog); })); + + if (auto *replay = getReplay(); replay && !replay->hasFlag(REPLAY_FLAG_NO_VIPC)) { + std::set streams; + for (const auto &[num, segment] : replay->route().segments()) { + if (!segment.narrow_road_cam.empty() || !segment.qcamera.empty()) streams.insert(VISION_STREAM_NARROW_ROAD); + if (replay->hasFlag(REPLAY_FLAG_CABIN_CAMERA) && !segment.cabin_cam.empty()) streams.insert(VISION_STREAM_CABIN); + if (replay->hasFlag(REPLAY_FLAG_WIDE_ROAD) && !segment.wide_road_cam.empty()) streams.insert(VISION_STREAM_WIDE_ROAD); + } + vipcAvailableStreamsUpdated(streams); + } } void VideoWidget::drawCameraWidget() { @@ -344,10 +354,10 @@ float VideoWidget::sizeHintHeight() const { return MIN_VIDEO_HEIGHT + SLIDER_HEIGHT + toolbarHeight(); } -// the video pane opens with the camera at its natural aspect ratio, filling the width of the dock +// Keep the pane's default proportions stable as frames arrive or cameras change. float VideoWidget::defaultHeight(float width) const { if (!cam_widget_) return toolbarHeight(); // live streams have no camera or slider - const float cam_height = std::max((float)MIN_VIDEO_HEIGHT, width / cam_widget_->frameAspectRatio()); + const float cam_height = std::max((float)MIN_VIDEO_HEIGHT, width / DEFAULT_CAMERA_ASPECT_RATIO); const float tab_height = camera_tab_->count() >= 2 ? ImGui::GetFrameHeight() : 0.0f; return cam_height + tab_height + SLIDER_HEIGHT + toolbarHeight(); } From 9c02eebb72800d0707deddd404c2ba9d812ef77d Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:05:26 -0700 Subject: [PATCH 023/122] cabana: fix clipped control outlines and filter sizing (#38795) * cabana: fix clipped control outlines and filter sizing * cabana: remove added comments * cabana: document control helper API usage --- openpilot/tools/cabana/ui/chart/chart.cc | 2 +- openpilot/tools/cabana/ui/util.cc | 15 ++++++++++++++- openpilot/tools/cabana/ui/util.h | 6 +++++- openpilot/tools/cabana/ui/widgets/historylog.cc | 7 +++++-- .../tools/cabana/ui/widgets/messageswidget.cc | 3 +-- openpilot/tools/cabana/ui/widgets/signalview.cc | 4 +--- 6 files changed, 27 insertions(+), 10 deletions(-) diff --git a/openpilot/tools/cabana/ui/chart/chart.cc b/openpilot/tools/cabana/ui/chart/chart.cc index 399304186e..169a588eaa 100644 --- a/openpilot/tools/cabana/ui/chart/chart.cc +++ b/openpilot/tools/cabana/ui/chart/chart.cc @@ -139,7 +139,7 @@ void ChartView::updateLayout() { const ImVec2 top_left = layout_.rect.Min + ImVec2(LAYOUT_MARGINS.x, LAYOUT_MARGINS.y); layout_.move_icon_rect = ImRect(top_left, top_left + grip); const ImVec2 btn_size(iconButtonWidth(), iconButtonWidth()); - const ImVec2 close_min(layout_.rect.Max.x - LAYOUT_MARGINS.z - btn_size.x, top_left.y); + const ImVec2 close_min(layout_.rect.Max.x - std::max(LAYOUT_MARGINS.z, CONTROL_OUTLINE_PADDING) - btn_size.x, top_left.y); layout_.close_btn_rect = ImRect(close_min, close_min + btn_size); const ImVec2 manage_min(close_min.x - btn_size.x - ImGui::GetStyle().ItemInnerSpacing.x, top_left.y); layout_.manage_btn_rect = ImRect(manage_min, manage_min + btn_size); diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index f646f33da4..6269bd11c3 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -62,9 +62,21 @@ bool inputTextMultiline(const char *label, std::string *s, const ImVec2 &size, I inputCallback, &ctx); } +bool beginControlChild(const char *id, const ImVec2 &size, ImGuiWindowFlags flags) { + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(CONTROL_OUTLINE_PADDING, CONTROL_OUTLINE_PADDING)); + const bool visible = ImGui::BeginChild(id, size, ImGuiChildFlags_AlwaysUseWindowPadding, flags); + ImGui::PopStyleVar(); + return visible; +} + bool clearableInput(const char *label, std::string *s, const char *hint, ImGuiInputTextCallback validator) { + const float width = ImGui::CalcItemWidth(); + const float clear_width = iconButtonWidth() + ImGui::GetStyle().ItemInnerSpacing.x; + const bool show_clear = !s->empty() && width >= clear_width + ImGui::GetFrameHeight(); + ImGui::SetNextItemWidth(show_clear ? width - clear_width : width); + ImGui::BeginGroup(); bool changed = validatedInput(label, s, validator, hint); - if (!s->empty()) { + if (show_clear) { ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); ImGui::PushID(label); if (iconButton("clear", icon::X_LG)) { @@ -73,6 +85,7 @@ bool clearableInput(const char *label, std::string *s, const char *hint, ImGuiIn } ImGui::PopID(); } + ImGui::EndGroup(); return changed; } diff --git a/openpilot/tools/cabana/ui/util.h b/openpilot/tools/cabana/ui/util.h index 87afb28f74..98c814b6c4 100644 --- a/openpilot/tools/cabana/ui/util.h +++ b/openpilot/tools/cabana/ui/util.h @@ -31,7 +31,11 @@ inline bool inputText(const char *label, std::string *s, const char *hint = "", bool inputTextMultiline(const char *label, std::string *s, const ImVec2 &size, ImGuiInputTextFlags flags = 0); -// an input with a trailing clear button once it holds text; true when the text changed +constexpr float CONTROL_OUTLINE_PADDING = 1.0f; +// Always pair with ImGui::EndChild(), even when false is returned. +bool beginControlChild(const char *id, const ImVec2 &size, ImGuiWindowFlags flags = 0); + +// SetNextItemWidth includes the field and clear button. Returns true when text changes. bool clearableInput(const char *label, std::string *s, const char *hint = "", ImGuiInputTextCallback validator = nullptr); bool comboBox(const char *label, int *index, const std::vector &items); diff --git a/openpilot/tools/cabana/ui/widgets/historylog.cc b/openpilot/tools/cabana/ui/widgets/historylog.cc index ba51c4d6ee..19ce2c9f65 100644 --- a/openpilot/tools/cabana/ui/widgets/historylog.cc +++ b/openpilot/tools/cabana/ui/widgets/historylog.cc @@ -132,11 +132,13 @@ void LogsWidget::exportToCSV() { void LogsWidget::draw() { const ImGuiStyle &style = ImGui::GetStyle(); + beginControlChild("toolbar", ImVec2(0, ImGui::GetFrameHeight() + CONTROL_OUTLINE_PADDING * 2), + ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + // toolbar: the export button is right aligned and never clipped, the value input shrinks first const float export_w = iconButtonWidth(); if (!sigs_.empty()) { - const float clear_w = value_edit_.empty() ? 0.0f : iconButtonWidth(); - const float fixed = DISPLAY_TYPE_WIDTH + SIGNALS_WIDTH + COMPARE_WIDTH + clear_w + style.ItemSpacing.x * 4 + export_w; + const float fixed = DISPLAY_TYPE_WIDTH + SIGNALS_WIDTH + COMPARE_WIDTH + style.ItemSpacing.x * 4 + export_w; const float value_w = std::clamp(ImGui::GetContentRegionAvail().x - fixed, 30.0f, 120.0f); ImGui::SetNextItemWidth(DISPLAY_TYPE_WIDTH); @@ -169,6 +171,7 @@ void LogsWidget::draw() { if (iconButton("export_csv", icon::FILETYPE_CSV)) exportToCSV(); ImGui::EndDisabled(); disabledItemTooltip("Export to CSV file..."); + ImGui::EndChild(); ImGui::Separator(); drawTable(); diff --git a/openpilot/tools/cabana/ui/widgets/messageswidget.cc b/openpilot/tools/cabana/ui/widgets/messageswidget.cc index 6de1735d79..08148107a0 100644 --- a/openpilot/tools/cabana/ui/widgets/messageswidget.cc +++ b/openpilot/tools/cabana/ui/widgets/messageswidget.cc @@ -434,12 +434,11 @@ void MessagesWidget::drawHeader() { } // the filter editors under the header - const float clear_width = iconButtonWidth(); ImGui::TableNextRow(); for (int i = 0; i < MessageList::COLUMN_COUNT; i++) { if (!ImGui::TableSetColumnIndex(i)) continue; ImGui::PushID(i); - ImGui::SetNextItemWidth(filters_[i].empty() ? -FLT_MIN : std::max(1.0f, ImGui::GetContentRegionAvail().x - clear_width)); + ImGui::SetNextItemWidth(-FLT_MIN); const std::string placeholder = std::string("Filter ") + COLUMN_TITLES[i]; if (clearableInput("##filter", &filters_[i], placeholder.c_str())) { std::map filters; diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index c0ed091568..2413912ea2 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -737,10 +737,8 @@ void SignalView::collapseAll() { void SignalView::drawTree() { ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); const float min_height = std::max(ImGui::GetContentRegionAvail().y, 300.0f); - const bool visible = ImGui::BeginChild("tree", ImVec2(0, min_height), ImGuiChildFlags_None); - ImGui::PopStyleVar(); + const bool visible = beginControlChild("tree", ImVec2(0, min_height)); if (visible) { DrawContext ctx{ImGui::GetWindowDrawList(), ImGui::GetCursorScreenPos().x, ImGui::GetContentRegionAvail().x, rowHeight()}; // the press that closes an open editor is consumed by the focus change, the index widgets never see it From 0f9ec7158b82f962515136372c42e1ff20dedd07 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:32:22 -0700 Subject: [PATCH 024/122] cabana: validate message IDs and include recorded CAN messages (#38799) --- openpilot/tools/cabana/core/message_id.h | 17 +++++++++++++---- openpilot/tools/cabana/tests/test_cabana.cc | 13 +++++++++++++ .../tools/cabana/ui/chart/signalselector.cc | 5 ++++- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/openpilot/tools/cabana/core/message_id.h b/openpilot/tools/cabana/core/message_id.h index ecac279631..e7f4e3c0b4 100644 --- a/openpilot/tools/cabana/core/message_id.h +++ b/openpilot/tools/cabana/core/message_id.h @@ -1,7 +1,9 @@ #pragma once +#include #include #include #include +#include #include #include @@ -11,11 +13,18 @@ struct MessageId { uint8_t source = 0; uint32_t address = 0; std::string toString() const { char b[64]; snprintf(b, sizeof(b), "%u:%X", source, address); return b; } - static MessageId fromString(const std::string &s) { - const auto p = s.find(':'); - if (p == std::string::npos) return {}; - return {.source = static_cast(std::stoul(s.substr(0, p))), .address = static_cast(std::stoul(s.substr(p + 1), nullptr, 16))}; + // strict "bus:HEX" parser + static std::optional parse(const std::string &s) { + const auto colon = s.find(':'); + if (colon == std::string::npos) return std::nullopt; + const char *begin = s.data(), *end = begin + s.size(); + uint32_t source = 0, address = 0; + auto bus = std::from_chars(begin, begin + colon, source); + auto addr = std::from_chars(begin + colon + 1, end, address, 16); + if (bus.ec != std::errc() || bus.ptr != begin + colon || source > 255 || addr.ec != std::errc() || addr.ptr != end) return std::nullopt; + return MessageId{static_cast(source), address}; } + static MessageId fromString(const std::string &s) { return parse(s).value_or(MessageId{}); } bool operator==(const MessageId &o) const { return source == o.source && address == o.address; } bool operator!=(const MessageId &o) const { return !(*this == o); } bool operator<(const MessageId &o) const { return std::tie(source, address) < std::tie(o.source, o.address); } diff --git a/openpilot/tools/cabana/tests/test_cabana.cc b/openpilot/tools/cabana/tests/test_cabana.cc index e1c98fcc8e..983a80a459 100644 --- a/openpilot/tools/cabana/tests/test_cabana.cc +++ b/openpilot/tools/cabana/tests/test_cabana.cc @@ -15,6 +15,18 @@ const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.bz2"; +void test_message_id_parsing() { + for (const auto &text : {"", "1", ":123", "1:", "-1:123", "256:1", "1:100000000", "1:1junk", "1junk:1", "1:1:1"}) { + REQUIRE(!MessageId::parse(text)); + REQUIRE(MessageId::fromString(text) == MessageId{}); + } + const MessageId expected{255, 0xffffffff}; + REQUIRE(MessageId::parse("255:FFFFFFFF") == expected); + REQUIRE(MessageId::parse("255:ffffffff") == expected); + REQUIRE(MessageId::parse(expected.toString()) == expected); + REQUIRE(MessageId::parse("0:0") == MessageId{}); +} + void test_generate_dbc() { std::string fn = std::string(OPENDBC_FILE_PATH) + "/tesla_can.dbc"; DBCFile dbc_origin(fn); @@ -381,6 +393,7 @@ void test_cabana_core() { test_pixel_envelope(); test_format_seconds(); test_to_hex(); + test_message_id_parsing(); test_signal_tooltip(); test_generate_dbc(); test_comment_order(); diff --git a/openpilot/tools/cabana/ui/chart/signalselector.cc b/openpilot/tools/cabana/ui/chart/signalselector.cc index cab1de0816..2733a65f33 100644 --- a/openpilot/tools/cabana/ui/chart/signalselector.cc +++ b/openpilot/tools/cabana/ui/chart/signalselector.cc @@ -11,7 +11,10 @@ #include "tools/cabana/utils/strings.h" SignalSelector::SignalSelector(std::string title) : title_(std::move(title)) { - for (const auto &[id, _] : can->lastMessages()) { + std::set ids; + for (const auto &[id, _] : can->eventsMap()) ids.insert(id); + for (const auto &[id, _] : can->lastMessages()) ids.insert(id); + for (const auto &id : ids) { if (auto m = dbc()->msg(id)) { msgs_combo_.push_back({m->name + " (" + id.toString() + ")", id}); } From 624d5b999517090325999a67466327cd4a20d055 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:33:14 -0700 Subject: [PATCH 025/122] cabana: fix stream and replay lifetimes (#38796) --- .../tools/cabana/streams/abstractstream.h | 2 +- .../tools/cabana/streams/replaystream.cc | 1 + openpilot/tools/cabana/tests/test_cabana.cc | 25 +++++++++++++++++++ openpilot/tools/cabana/ui/threadpool.h | 12 ++++++--- openpilot/tools/replay/replay.cc | 18 +++++++------ openpilot/tools/replay/replay.h | 2 ++ openpilot/tools/replay/seg_mgr.cc | 4 +++ openpilot/tools/replay/seg_mgr.h | 1 + 8 files changed, 54 insertions(+), 11 deletions(-) diff --git a/openpilot/tools/cabana/streams/abstractstream.h b/openpilot/tools/cabana/streams/abstractstream.h index dedf9a2da6..45a9af92f4 100644 --- a/openpilot/tools/cabana/streams/abstractstream.h +++ b/openpilot/tools/cabana/streams/abstractstream.h @@ -42,7 +42,7 @@ public: inline double currentSec() const { return current_sec_; } inline uint64_t toMonoTime(double sec) const { return beginMonoTime() + std::max(sec, 0.0) * 1e9; } - inline double toSeconds(uint64_t mono_time) const { return std::max(0.0, (mono_time - beginMonoTime()) / 1e9); } + inline double toSeconds(uint64_t mono_time) const { return mono_time > beginMonoTime() ? (mono_time - beginMonoTime()) / 1e9 : 0.0; } inline const std::unordered_map &lastMessages() const { return last_msgs; } bool isMessageActive(const MessageId &id) const; diff --git a/openpilot/tools/cabana/streams/replaystream.cc b/openpilot/tools/cabana/streams/replaystream.cc index fe8048c215..c0ab46abc9 100644 --- a/openpilot/tools/cabana/streams/replaystream.cc +++ b/openpilot/tools/cabana/streams/replaystream.cc @@ -19,6 +19,7 @@ ReplayStream::ReplayStream() { ReplayStream::~ReplayStream() { cancelWaits(); + if (replay) replay->stop(); } // runs on replay's merge thread: a segment of CAN data takes ~30 ms to parse and group, which dropped diff --git a/openpilot/tools/cabana/tests/test_cabana.cc b/openpilot/tools/cabana/tests/test_cabana.cc index 983a80a459..268aa9a86d 100644 --- a/openpilot/tools/cabana/tests/test_cabana.cc +++ b/openpilot/tools/cabana/tests/test_cabana.cc @@ -1,15 +1,20 @@ +#include +#include #include #include #include #include #include +#include +#include #include "common/tests/native_test.h" #include "tools/cabana/dbc/dbcfile.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/routes.h" #include "tools/cabana/ui/qtstate.h" +#include "tools/cabana/ui/threadpool.h" #include "tools/cabana/ui/chart/downsample.h" #include "tools/cabana/utils/strings.h" @@ -366,6 +371,25 @@ void test_qt_state_blobs() { REQUIRE(!qtstate::parseQtHeaderState(fromHex("000000fe00000000000000010000000000000000010000000000000000")).has_value()); } +void test_parallel_failure_joins_workers() { + for (bool fail_on_caller : {true, false}) { + std::atomic finished = 0; + const size_t chunks = std::clamp(std::thread::hardware_concurrency(), 2, 4) + 1; + bool caught = false; + try { + parallelFor(chunks, [&](size_t begin, size_t end) { + if (begin == (fail_on_caller ? 0u : 1u)) throw std::runtime_error("task failed"); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + ++finished; + }); + } catch (const std::runtime_error &) { + caught = true; + } + REQUIRE(caught); + REQUIRE(finished == chunks - 1); + } +} + void test_pixel_envelope() { struct Point { double x, y; @@ -405,6 +429,7 @@ void test_cabana_core() { test_route_timestamps(); test_route_api_response(); test_route_json(); + test_parallel_failure_joins_workers(); test_qt_state_blobs(); } diff --git a/openpilot/tools/cabana/ui/threadpool.h b/openpilot/tools/cabana/ui/threadpool.h index 5a45ec80f2..3580f55c78 100644 --- a/openpilot/tools/cabana/ui/threadpool.h +++ b/openpilot/tools/cabana/ui/threadpool.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -36,7 +37,7 @@ public: stop_ = true; } cv_.notify_all(); - for (auto &t : threads_) t.join(); + for (auto &thread : threads_) thread.join(); } private: @@ -76,6 +77,11 @@ inline void parallelFor(size_t n, const std::function &allow, const std::vec auto event_schema = capnp::Schema::from().asStruct(); sockets_.resize(event_schema.getUnionFields().size(), nullptr); - std::vector active_services; - active_services.reserve(services.size()); + auto &active_services = active_services_; for (const auto &[name, _] : services) { bool is_blocked = std::find(block.begin(), block.end(), name) != block.end(); @@ -55,9 +54,6 @@ void Replay::setupServices(const std::vector &allow, const std::vec std::string services_str = join(active_services, ", "); rInfo("active services: %s", services_str.c_str()); - if (!sm_) { - pm_ = std::make_unique(active_services); - } } void Replay::setupSegmentManager(bool has_filters) { @@ -73,6 +69,15 @@ void Replay::setupSegmentManager(bool has_filters) { } Replay::~Replay() { + stop(); + camera_server_.reset(); + seg_mgr_.reset(); +} + +void Replay::stop() { + // Merge callbacks access both Replay and its owner. Join them while all objects + // and their owning pointers are still alive, before stopping the playback thread. + seg_mgr_->stop(); if (stream_thread_.joinable()) { rInfo("shutdown: in progress..."); interruptStream([this]() { @@ -82,8 +87,6 @@ Replay::~Replay() { stream_thread_.join(); rInfo("shutdown: done"); } - camera_server_.reset(); - seg_mgr_.reset(); } bool Replay::load() { @@ -235,6 +238,7 @@ void Replay::publishMessage(const Event *e) { if (event_filter_ && event_filter_(e)) return; if (!sm_) { + if (!pm_) pm_ = std::make_unique(active_services_); // consumers with an event filter never need one auto bytes = e->data.asBytes(); int ret = pm_->send(sockets_[e->which], (capnp::byte *)bytes.begin(), bytes.size()); if (ret == -1) { diff --git a/openpilot/tools/replay/replay.h b/openpilot/tools/replay/replay.h index 7cd5fbecd0..59e1d67d48 100644 --- a/openpilot/tools/replay/replay.h +++ b/openpilot/tools/replay/replay.h @@ -37,6 +37,7 @@ public: Replay(const std::string &route, std::vector allow, std::vector block, SubMaster *sm = nullptr, uint32_t flags = REPLAY_FLAG_NONE, const std::string &data_dir = "", bool auto_source = false); ~Replay(); + void stop(); bool load(); RouteLoadError lastRouteError() const { return route().lastError(); } void start(int seconds = 0) { seekTo(min_seconds_ + seconds, false); } @@ -107,6 +108,7 @@ private: double min_seconds_ = 0; double max_seconds_ = 0; SubMaster *sm_ = nullptr; + std::vector active_services_; std::unique_ptr pm_; std::vector sockets_; std::unique_ptr camera_server_; diff --git a/openpilot/tools/replay/seg_mgr.cc b/openpilot/tools/replay/seg_mgr.cc index 0778cacbc1..074e7e53db 100644 --- a/openpilot/tools/replay/seg_mgr.cc +++ b/openpilot/tools/replay/seg_mgr.cc @@ -3,6 +3,10 @@ #include SegmentManager::~SegmentManager() { + stop(); +} + +void SegmentManager::stop() { { std::unique_lock lock(mutex_); exit_ = true; diff --git a/openpilot/tools/replay/seg_mgr.h b/openpilot/tools/replay/seg_mgr.h index 54e156fb60..55b412b25a 100644 --- a/openpilot/tools/replay/seg_mgr.h +++ b/openpilot/tools/replay/seg_mgr.h @@ -23,6 +23,7 @@ public: SegmentManager(const std::string &route_name, uint32_t flags, const std::string &data_dir = "", bool auto_source = false) : flags_(flags), route_(route_name, data_dir, auto_source), event_data_(std::make_shared()) {} ~SegmentManager(); + void stop(); bool load(); void setCurrentSegment(int seg_num); From 1bb019521e39d61382d38efa18ded53d6932fafe Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:42:41 -0700 Subject: [PATCH 026/122] cabana: fix chart ranges and control layout (#38797) --- openpilot/tools/cabana/ui/chart/chart.cc | 65 ++++++++----- openpilot/tools/cabana/ui/chart/chart.h | 2 +- .../tools/cabana/ui/chart/chartswidget.cc | 40 +++++--- openpilot/tools/cabana/ui/chart/tiplabel.cc | 96 +++++++++++++------ openpilot/tools/cabana/ui/chart/tiplabel.h | 12 ++- openpilot/tools/cabana/ui/util.cc | 20 ++++ openpilot/tools/cabana/ui/util.h | 2 + 7 files changed, 166 insertions(+), 71 deletions(-) diff --git a/openpilot/tools/cabana/ui/chart/chart.cc b/openpilot/tools/cabana/ui/chart/chart.cc index 169a588eaa..8dc5d627d0 100644 --- a/openpilot/tools/cabana/ui/chart/chart.cc +++ b/openpilot/tools/cabana/ui/chart/chart.cc @@ -6,7 +6,6 @@ #include #include #include -#include #include "tools/cabana/core/settings.h" #include "tools/cabana/settings.h" @@ -139,7 +138,7 @@ void ChartView::updateLayout() { const ImVec2 top_left = layout_.rect.Min + ImVec2(LAYOUT_MARGINS.x, LAYOUT_MARGINS.y); layout_.move_icon_rect = ImRect(top_left, top_left + grip); const ImVec2 btn_size(iconButtonWidth(), iconButtonWidth()); - const ImVec2 close_min(layout_.rect.Max.x - std::max(LAYOUT_MARGINS.z, CONTROL_OUTLINE_PADDING) - btn_size.x, top_left.y); + const ImVec2 close_min(layout_.rect.Max.x - ImGui::GetStyle().WindowPadding.x - btn_size.x, top_left.y); layout_.close_btn_rect = ImRect(close_min, close_min + btn_size); const ImVec2 manage_min(close_min.x - btn_size.x - ImGui::GetStyle().ItemInnerSpacing.x, top_left.y); layout_.manage_btn_rect = ImRect(manage_min, manage_min + btn_size); @@ -150,7 +149,7 @@ void ChartView::updateLayout() { const int marker_size = markerSize(); const int row_height = std::max(marker_size, fm_height) + fm_height + 3; // + the signal value line const int legend_left = layout_.move_icon_rect.Max.x + LEGEND_SPACING; - const int legend_right = std::max(layout_.manage_btn_rect.Min.x - LAYOUT_MARGINS.z, legend_left + 10); + const int legend_right = std::max(layout_.manage_btn_rect.Min.x - ImGui::GetStyle().ItemInnerSpacing.x, legend_left + 10); // layout legend entries left-to-right, wrapping between the move icon and the buttons layout_.legend_rects.clear(); @@ -158,6 +157,9 @@ void ChartView::updateLayout() { for (auto &s : sigs_) { int w = marker_size + LEGEND_SPACING + bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, s.sig->name.c_str()).x + ImGui::CalcTextSize(msgLabel(s.msg_id).c_str()).x; + pushMonoFont(font_size); + w = std::max(w, (int)std::ceil(ImGui::CalcTextSize("-0.00000e+000").x)); + popMonoFont(); w = std::min(w, legend_right - legend_left); // keep oversized entries clear of the header buttons if (x + w > legend_right && x > legend_left) { x = legend_left; @@ -262,13 +264,14 @@ void ChartView::updateAxisY() { auto [first, last] = visibleRange(s.vals); s.min = std::numeric_limits::max(); s.max = std::numeric_limits::lowest(); + if (first == last) continue; if (can->liveStreaming()) { for (auto it = first; it != last; ++it) { if (it->y < s.min) s.min = it->y; if (it->y > s.max) s.max = it->y; } } else { - std::tie(s.min, s.max) = s.segment_tree.minmax(std::distance(s.vals.cbegin(), first), std::distance(s.vals.cbegin(), last)); + std::tie(s.min, s.max) = s.segment_tree.minmax(std::distance(s.vals.cbegin(), first), std::distance(s.vals.cbegin(), last) - 1); } min = std::min(min, s.min); max = std::max(max, s.max); @@ -278,7 +281,8 @@ void ChartView::updateAxisY() { y_unit_ = unit; - double delta = std::abs(max - min) < 1e-3 ? 1 : (max - min) * 0.05; + const double magnitude = std::max(std::abs(min), std::abs(max)); + double delta = max - min <= magnitude * 1e-9 ? (magnitude > 0 ? magnitude * 0.05 : 1) : (max - min) * 0.05; auto [min_y, max_y, tick_count] = getNiceAxisNumbers(min - delta, max + delta, 3); if (min_y != y_min_ || max_y != y_max_) { y_min_ = min_y; @@ -412,10 +416,10 @@ void ChartView::handleMouseRelease() { // Prevent zooming/seeking past the end of the route double min = std::clamp(secondsAtPoint(rubber_rect_.Min), can->minSeconds(), can->maxSeconds()); double max = std::clamp(secondsAtPoint(rubber_rect_.Max), can->minSeconds(), can->maxSeconds()); - if (rubber_rect_.GetWidth() <= 0) { - // no rubber dragged, seek to mouse position + if (rubber_rect_.GetWidth() <= 10) { + // Small movements are still clicks; use the same threshold as drag-to-zoom. can->seekTo(std::clamp(secondsAtPoint(press_pos_), can->minSeconds(), can->maxSeconds())); - } else if (rubber_rect_.GetWidth() > 10 && (max - min) > MIN_ZOOM_SECONDS) { + } else if ((max - min) > MIN_ZOOM_SECONDS) { charts_widget_->zoom_undo_stack_.push(new ZoomCommand({min, max})); } rubber_rect_ = ImRect(); @@ -434,8 +438,8 @@ void ChartView::handleMouseRelease() { void ChartView::takeSignalsFrom(ChartView *source) { for (auto &s : source->sigs_) { + s.color = uniqueColor(s.color); sigs_.push_back(std::move(s)); - sigs_.back().color = uniqueColor(sigs_.back().color, sigs_.back().sig); } source->sigs_.clear(); updateAxisY(); @@ -478,17 +482,17 @@ void ChartView::showTip(double sec) { s.track_pt = *pt; x = std::max(x, xPos(pt->x)); } - std::string name = sigs_.size() > 1 ? s.sig->name + ": " : ""; + std::string name = s.sig->name; std::string min = s.min == std::numeric_limits::max() ? "--" : utils::toString(s.min); std::string max = s.max == std::numeric_limits::lowest() ? "--" : utils::toString(s.max); - text_list.push_back({.has_marker = true, .marker = toImU32(s.color), .name = name, .bold = value, .rest = " (" + min + ", " + max + ")"}); + text_list.push_back({.has_marker = true, .marker = toImU32(s.color), .name = name, .value = value, .min = min, .max = max}); } } if (x < 0) { x = tooltip_x_; } ImVec2 pt(x, layout_.plot_area.Min.y); - text_list.insert(text_list.begin(), TipLine{.name = formatNumber(secondsAtPoint({x, 0}), 2)}); + text_list.insert(text_list.begin(), TipLine{.name = formatNumber(sec, 2) + " s"}); tip_label_.showText(pt, text_list, visible_rect); } @@ -747,6 +751,7 @@ void ChartView::drawTimeline() { } void ChartView::drawSignalValue() { + pushMonoFont(ImGui::GetFontSize()); ImDrawList *painter = ImGui::GetWindowDrawList(); const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text); for (int i = 0; i < sigs_.size() && i < layout_.legend_rects.size(); ++i) { @@ -757,27 +762,35 @@ void ChartView::drawSignalValue() { ImRect value_rect(value_min, value_min + layout_.legend_rects[i].GetSize()); float w = ImGui::CalcTextSize(value.c_str()).x; if (w <= value_rect.GetWidth()) { - painter->AddText(ImVec2(value_rect.GetCenter().x - w / 2, value_rect.Min.y), color, value.c_str()); + painter->AddText(value_rect.Min, color, value.c_str()); } else { addTextEllipsis(painter, ImGui::GetFont(), color, value_rect.Min, value_rect.Max.x, value); } } + popMonoFont(); } CabanaColor ChartView::uniqueColor(CabanaColor color, const cabana::Signal *exclude) const { - for (auto &s : sigs_) { - if (s.sig != exclude && std::abs(color.hsv().hue - s.color.hsv().hue) < 0.1) { - // use different color to distinguish it from others. - auto last_color = sigs_.back().color; - static thread_local std::mt19937 rng{std::random_device{}()}; - std::uniform_int_distribution sat(35, 99); - std::uniform_int_distribution val(85, 99); - color = CabanaColor::fromHsv(std::fmod(last_color.hsv().hue + 60 / 360.0, 1.0), - sat(rng) / 100.0, - val(rng) / 100.0, - color.a / 255.0f); - break; + auto separation = [&](float hue) { + float distance = 1.0f; + for (const auto &s : sigs_) { + if (exclude && s.sig == exclude) continue; + const float delta = std::abs(hue - s.color.hsv().hue); + distance = std::min(distance, std::min(delta, 1.0f - delta)); + } + return distance; + }; + const float original_hue = color.hsv().hue; + if (separation(original_hue) >= 0.1f) return color; + + float best_hue = original_hue, best_distance = -1; + for (int i = 0; i < 36; ++i) { + const float hue = std::fmod(original_hue + i / 36.0f, 1.0f); + const float distance = separation(hue); + if (distance > best_distance) { + best_hue = hue; + best_distance = distance; } } - return color; + return CabanaColor::fromHsv(best_hue, 0.8f, 0.9f, color.a / 255.0f); } diff --git a/openpilot/tools/cabana/ui/chart/chart.h b/openpilot/tools/cabana/ui/chart/chart.h index fc2b63712c..d32906390a 100644 --- a/openpilot/tools/cabana/ui/chart/chart.h +++ b/openpilot/tools/cabana/ui/chart/chart.h @@ -120,7 +120,7 @@ private: double y_min_ = 0; double y_max_ = 1; int y_tick_count_ = 3; - int y_precision_ = 0; + int y_precision_ = 1; std::string y_unit_; // interaction enum class MouseMode { None, Rubber, Scrub }; diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.cc b/openpilot/tools/cabana/ui/chart/chartswidget.cc index d10300cbe8..a5845cefdc 100644 --- a/openpilot/tools/cabana/ui/chart/chartswidget.cc +++ b/openpilot/tools/cabana/ui/chart/chartswidget.cc @@ -206,12 +206,13 @@ void ChartsWidget::drawToolBar() { const std::string range_lb = is_zoomed ? std::string() : utils::formatSeconds(max_chart_range_); std::string reset_zoom_text; if (!is_zoomed) { - items.push_back({ImGui::CalcTextSize(range_lb.c_str()).x, [&range_lb]() { + // the range label and the slider are one unit: drawn inline and moved to the overflow menu together + slider_index = items.size(); + const float label_width = ImGui::CalcTextSize(range_lb.c_str()).x + ImGui::GetStyle().ItemInnerSpacing.x; + items.push_back({label_width + slider_width, [this, &range_lb, &slider_width]() { ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(range_lb.c_str()); - }}); - slider_index = items.size(); - items.push_back({slider_width, [this, &slider_width]() { + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); // Restore the slider width in overflow; the toolbar may have shrunk it. const bool in_menu = ImGui::GetCurrentWindow()->Flags & ImGuiWindowFlags_Popup; const float width = in_menu ? std::max(ImGui::GetContentRegionAvail().x, 150.0f) : slider_width; @@ -219,9 +220,17 @@ void ChartsWidget::drawToolBar() { ImGui::SetItemTooltip("Set the chart range"); }}); } else { + const auto &range = *can->timeRange(); char buf[64]; - snprintf(buf, sizeof(buf), "%.2f-%.2f", can->timeRange()->first, can->timeRange()->second); + snprintf(buf, sizeof(buf), "%.2f-%.2f", range.first, range.second); reset_zoom_text = buf; + // The undo/redo/reset buttons form one group. The reset button has a fixed width in the mono font, + // sized for the longest range the stream can show, so its neighbors do not shift as the range changes. + const int digits = std::max({1, (int)std::to_string((long long)can->maxSeconds()).size(), (int)std::to_string((long long)range.second).size()}); + const std::string widest = std::string(digits, '0') + ".00"; + pushMonoFont(ImGui::GetFontSize()); + const float reset_zoom_width = iconTextButtonWidth(icon::ZOOM_OUT, widest + "-" + widest); + popMonoFont(); items.push_back({iconButtonWidth(), [this]() { ImGui::BeginDisabled(!zoom_undo_stack_.canUndo()); if (iconButton("undo_zoom", icon::ARROW_COUNTERCLOCKWISE, "Undo Zoom")) zoom_undo_stack_.undo(); @@ -232,10 +241,15 @@ void ChartsWidget::drawToolBar() { if (iconButton("redo_zoom", icon::ARROW_CLOCKWISE, "Redo Zoom")) zoom_undo_stack_.redo(); ImGui::EndDisabled(); }}); - items.push_back({toolbarButtonWidth(std::string(icon::ZOOM_OUT) + " " + reset_zoom_text), [this, &reset_zoom_text]() { - if (ImGui::Button((std::string(icon::ZOOM_OUT) + " " + reset_zoom_text + "###reset_zoom_btn").c_str())) zoomReset(); + items.back().tight = true; + items.push_back({reset_zoom_width, [this, &reset_zoom_text, reset_zoom_width]() { + pushMonoFont(ImGui::GetFontSize()); + const bool clicked = iconTextButton("reset_zoom_btn", icon::ZOOM_OUT, reset_zoom_text, reset_zoom_width); + popMonoFont(); + if (clicked) zoomReset(); ImGui::SetItemTooltip("Reset Zoom"); }}); + items.back().tight = true; } items.push_back(toolbarAction("remove_all_btn", icon::TRASH, "Remove all charts", [this]() { removeAll(); }, !charts_.empty())); const char *dock_btn_icon = is_docked_ ? icon::BOX_ARROW_UP_RIGHT : icon::BOX_ARROW_IN_DOWN_LEFT; @@ -247,7 +261,7 @@ void ChartsWidget::drawToolBar() { const float shrink = std::min(slider_width - MIN_RANGE_SLIDER_WIDTH, toolbarWidth(items, spacer_index) - ImGui::GetContentRegionAvail().x); if (shrink > 0.0f) { slider_width -= shrink; - items[slider_index].width = slider_width; + items[slider_index].width -= shrink; } } drawToolbar(items, spacer_index); @@ -273,7 +287,8 @@ ChartView *ChartsWidget::createChart(int pos) { ChartView *ptr = chart.get(); pos = std::clamp(pos, 0, (int)charts_.size()); charts_.insert(charts_.begin() + pos, std::move(chart)); - currentCharts().insert(currentCharts().begin() + pos, ptr); + auto ¤t = currentCharts(); + current.insert(current.begin() + std::min(pos, (int)current.size()), ptr); updateLayout(); return ptr; } @@ -291,8 +306,8 @@ void ChartsWidget::showChart(const MessageId &id, const cabana::Signal *sig, boo void ChartsWidget::splitChart(ChartView *src_chart) { if (src_chart->signals().size() > 1) { - auto it = std::find_if(charts_.begin(), charts_.end(), [src_chart](auto &c) { return c.get() == src_chart; }); - const int pos = it - charts_.begin() + 1; + auto ¤t = currentCharts(); + const int pos = std::find(current.begin(), current.end(), src_chart) - current.begin() + 1; for (auto &s : src_chart->takeExtraSignals()) { createChart(pos)->adoptSignal(std::move(s)); } @@ -606,9 +621,8 @@ void ChartsWidget::draw() { } void ChartsContainer::draw() { - ImGuiWindow *window = ImGui::GetCurrentWindow(); const ImVec2 start = ImGui::GetCursorScreenPos(); - const float width_avail = window->InnerRect.GetWidth() - (window->ScrollbarY ? ImGui::GetStyle().ItemInnerSpacing.x : 0.0f); + const float width_avail = ImGui::GetContentRegionAvail().x; geometry_ = ImRect(start, start + ImVec2(width_avail, 0)); charts_widget_->updateLayout(); diff --git a/openpilot/tools/cabana/ui/chart/tiplabel.cc b/openpilot/tools/cabana/ui/chart/tiplabel.cc index 68889b7886..0efc4e7ad6 100644 --- a/openpilot/tools/cabana/ui/chart/tiplabel.cc +++ b/openpilot/tools/cabana/ui/chart/tiplabel.cc @@ -2,35 +2,49 @@ #include "tools/cabana/ui/chart/tiplabel.h" #include -#include +#include #include "tools/cabana/ui/util.h" ImVec2 TipLabel::layoutLines(ImDrawList *p, const ImVec2 &origin, ImU32 fg) const { - ImFont *bold = boldFont(); const float font_size = ImGui::GetFontSize(); - const float line_height = ImGui::GetTextLineHeight(); - ImVec2 size(0, 0); - float y = origin.y; + const float line_height = std::ceil(ImGui::GetTextLineHeight() + 2); + const float marker = std::floor(markerSize()); + const float gap = 8; + const float width = column_widths_[0] + column_widths_[1] + column_widths_[2] + column_widths_[3] + gap * 3; + const ImU32 muted = ImGui::GetColorU32(ImGuiCol_TextDisabled); + float y = std::round(origin.y); + auto draw = [&](float x, const std::string &text, ImU32 color) { + if (p) p->AddText(ImVec2(std::round(x), y), color, text.c_str()); + }; + const char *heading = !text_.empty() && !text_[0].has_marker ? text_[0].name.c_str() : "Signal"; + const char *headers[] = {heading, "Value", "Min", "Max"}; + float x = origin.x; + for (int i = 0; i < 4; ++i) { + draw(i ? x + column_widths_[i] - ImGui::CalcTextSize(headers[i]).x : x, headers[i], muted); + x += column_widths_[i] + gap; + } + y += line_height; + if (p) p->AddLine(ImVec2(origin.x, y - 2), ImVec2(origin.x + width, y - 2), ImGui::GetColorU32(ImGuiCol_Border)); for (const auto &line : text_) { - float x = origin.x; - if (line.has_marker) { - if (p) drawColorMarker(p, ImVec2(x, y), line.marker); - x += markerSize() + 4; + if (!line.has_marker) continue; + if (p) { + const ImVec2 marker_pos(std::round(origin.x), std::round(y + (font_size - marker) * 0.5f)); + p->AddRectFilled(marker_pos, marker_pos + ImVec2(marker, marker), line.marker); } - if (p) p->AddText(ImVec2(x, y), fg, line.name.c_str()); - x += ImGui::CalcTextSize(line.name.c_str()).x; - if (!line.bold.empty()) { - if (p) p->AddText(bold, font_size, ImVec2(x, y), fg, line.bold.c_str()); - x += bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, line.bold.c_str()).x; + if (p) drawElidedText(p, ImRect(ImVec2(origin.x + marker + 6, y), + ImVec2(origin.x + column_widths_[0], y + font_size)), line.name, fg); + x = origin.x + column_widths_[0] + gap; + pushMonoFont(font_size); + const std::string *values[] = {&line.value, &line.min, &line.max}; + for (int i = 0; i < 3; ++i) { + draw(x + column_widths_[i + 1] - ImGui::CalcTextSize(values[i]->c_str()).x, *values[i], i == 0 ? fg : muted); + x += column_widths_[i + 1] + gap; } - if (p) p->AddText(ImVec2(x, y), fg, line.rest.c_str()); - x += ImGui::CalcTextSize(line.rest.c_str()).x; - size.x = std::max(size.x, x - origin.x); + popMonoFont(); y += line_height; } - size.y = y - origin.y; - return size; + return ImVec2(width, y - origin.y); } ImVec2 TipLabel::sizeHint() const { @@ -38,25 +52,53 @@ ImVec2 TipLabel::sizeHint() const { } void TipLabel::showText(const ImVec2 &pt, const std::vector &text, const ImRect &rect) { + bool same_signals = text.size() == text_.size(); + for (size_t i = 1; same_signals && i < text.size(); ++i) same_signals = text[i].name == text_[i].name; + if (!same_signals) column_widths_ = {}; text_ = text; + anchor_ = pt; + area_ = rect; + visible_ = !text_.empty(); +} + +void TipLabel::updateLayout() { + // Playback notifications can update the text outside an ImGui window scope. + // Measure and position it only while the owning chart is being drawn. + column_widths_[0] = std::max(column_widths_[0], ImGui::CalcTextSize(text_.empty() ? "Signal" : text_[0].name.c_str()).x); + for (const auto &line : text_) { + if (line.has_marker) column_widths_[0] = std::max(column_widths_[0], std::ceil(markerSize() + 6 + ImGui::CalcTextSize(line.name.c_str()).x)); + } + pushMonoFont(ImGui::GetFontSize()); + const float number_width = std::ceil(ImGui::CalcTextSize("-0.00000").x); + for (int i = 1; i < 4; ++i) column_widths_[i] = std::max(column_widths_[i], number_width); + for (const auto &line : text_) { + const std::string *values[] = {&line.value, &line.min, &line.max}; + for (int i = 0; i < 3; ++i) column_widths_[i + 1] = std::max(column_widths_[i + 1], std::ceil(ImGui::CalcTextSize(values[i]->c_str()).x)); + } + popMonoFont(); + const ImGuiViewport *viewport = ImGui::GetWindowViewport(); + const ImRect bounds(viewport->WorkPos, viewport->WorkPos + viewport->WorkSize); + const float numeric_width = column_widths_[1] + column_widths_[2] + column_widths_[3] + 24 + MARGIN * 2 + 1; + column_widths_[0] = std::min(column_widths_[0], std::max(40.0f, std::min(ImGui::GetFontSize() * 16, bounds.GetWidth() - numeric_width))); if (!text_.empty()) { ImVec2 extra(1, 1); size_ = sizeHint() + extra; - ImVec2 tip_pos(pt.x + 8, rect.Min.y + 2); - if (tip_pos.x + size_.x >= rect.Max.x) { - tip_pos.x = pt.x - size_.x - 8; - } - if (rect.Contains(ImRect(tip_pos, tip_pos + size_))) { - pos_ = tip_pos; - visible_ = true; - return; + ImVec2 tip_pos(anchor_.x + 8, area_.Min.y + 2); + if (anchor_.x >= area_.GetCenter().x) { + tip_pos.x = anchor_.x - size_.x - 8; } + tip_pos.x = std::clamp(tip_pos.x, bounds.Min.x, std::max(bounds.Min.x, bounds.Max.x - size_.x)); + tip_pos.y = std::clamp(tip_pos.y, bounds.Min.y, std::max(bounds.Min.y, bounds.Max.y - size_.y)); + pos_ = tip_pos; + visible_ = true; + return; } visible_ = false; } void TipLabel::draw() { if (!visible_) return; + updateLayout(); ImDrawList *p = ImGui::GetForegroundDrawList(); // filled panel with a 1px frame diff --git a/openpilot/tools/cabana/ui/chart/tiplabel.h b/openpilot/tools/cabana/ui/chart/tiplabel.h index 6aa80538e8..fe7b099d37 100644 --- a/openpilot/tools/cabana/ui/chart/tiplabel.h +++ b/openpilot/tools/cabana/ui/chart/tiplabel.h @@ -1,18 +1,18 @@ #pragma once +#include #include #include #include "imgui.h" #include "imgui_internal.h" -// one line of the tip: [square] name value (min, max) +// A signal row, or a time heading when has_marker is false. struct TipLine { bool has_marker = false; ImU32 marker = 0; std::string name; - std::string bold; - std::string rest; + std::string value, min, max; }; class TipLabel { @@ -26,9 +26,13 @@ private: // lays the lines out from origin, drawing them when p is given; returns the size of the text block ImVec2 layoutLines(ImDrawList *p, const ImVec2 &origin, ImU32 fg) const; ImVec2 sizeHint() const; + void updateLayout(); - static constexpr float MARGIN = 2.0f; // 1 + PM_ToolTipLabelFrameWidth + static constexpr float MARGIN = 6.0f; std::vector text_; + std::array column_widths_{}; + ImVec2 anchor_; + ImRect area_; ImVec2 pos_; ImVec2 size_; bool visible_ = false; diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index 6269bd11c3..30b6dc2c76 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -214,6 +214,26 @@ bool iconButton(const char *id, const char *icon, const char *tooltip) { return clicked; } +float iconTextButtonWidth(const char *icon, const std::string &text) { + const ImGuiStyle &style = ImGui::GetStyle(); + return ImGui::CalcTextSize(icon).x + style.ItemInnerSpacing.x + ImGui::CalcTextSize(text.c_str(), nullptr, true).x + style.FramePadding.x * 2; +} + +bool iconTextButton(const char *id, const char *icon, const std::string &text, float width) { + const ImGuiStyle &style = ImGui::GetStyle(); + if (width <= 0.0f) width = iconTextButtonWidth(icon, text); + const bool clicked = ImGui::Button((std::string("###") + id).c_str(), ImVec2(width, 0.0f)); + const ImVec2 min = ImGui::GetItemRectMin(), max = ImGui::GetItemRectMax(); + const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text); + auto *draw_list = ImGui::GetWindowDrawList(); + draw_list->AddText(ImVec2(min.x + style.FramePadding.x, min.y + style.FramePadding.y), color, icon); + // the text is centered between the icon and the right padding + const float left = min.x + style.FramePadding.x + ImGui::CalcTextSize(icon).x + style.ItemInnerSpacing.x; + const float slack = max.x - style.FramePadding.x - left - ImGui::CalcTextSize(text.c_str(), nullptr, true).x; + draw_list->AddText(ImVec2(left + std::max(0.0f, slack * 0.5f), min.y + style.FramePadding.y), color, text.c_str()); + return clicked; +} + void disabledItemTooltip(const char *text) { if (ImGui::IsItemHovered(ImGuiHoveredFlags_ForTooltip | ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", text); } diff --git a/openpilot/tools/cabana/ui/util.h b/openpilot/tools/cabana/ui/util.h index 98c814b6c4..576f197e88 100644 --- a/openpilot/tools/cabana/ui/util.h +++ b/openpilot/tools/cabana/ui/util.h @@ -74,6 +74,8 @@ int nonWhitespaceValidator(ImGuiInputTextCallbackData *data); // Use ItemInnerSpacing between related buttons and ItemSpacing between groups. bool iconButton(const char *id, const char *icon, const char *tooltip = nullptr); float iconButtonWidth(); +bool iconTextButton(const char *id, const char *icon, const std::string &text, float width = 0.0f); +float iconTextButtonWidth(const char *icon, const std::string &text); // tooltip for the last item that also shows while the item is disabled void disabledItemTooltip(const char *text); From 2b4d050e9c0ef1442fdbd3018949911cc7b39855 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:43:41 -0700 Subject: [PATCH 027/122] cabana: fix video startup and desktop interactions (#38798) * cabana: fix video startup and desktop interactions * cabana: remove util comments --- openpilot/tools/cabana/ui/helpoverlay.cc | 18 +++++++++------ openpilot/tools/cabana/ui/helpoverlay.h | 7 +++++- openpilot/tools/cabana/ui/mainwin.cc | 22 ++++++++++--------- openpilot/tools/cabana/ui/util.cc | 11 ++++++++-- openpilot/tools/cabana/ui/util.h | 7 ++++++ .../tools/cabana/ui/widgets/cameraview.h | 3 ++- .../tools/cabana/ui/widgets/videowidget.h | 2 +- 7 files changed, 48 insertions(+), 22 deletions(-) diff --git a/openpilot/tools/cabana/ui/helpoverlay.cc b/openpilot/tools/cabana/ui/helpoverlay.cc index 1fe061a382..bc64f99196 100644 --- a/openpilot/tools/cabana/ui/helpoverlay.cc +++ b/openpilot/tools/cabana/ui/helpoverlay.cc @@ -139,15 +139,19 @@ void HelpOverlay::toggle() { } void HelpOverlay::add(const std::string &text, const ImRect &rect) { - if (visible_) texts_.emplace_back(text, rect); + if (visible_) texts_.push_back({text, rect, ImGui::GetWindowViewport()}); } void HelpOverlay::draw() { if (!visible_) return; - const ImGuiViewport *viewport = ImGui::GetMainViewport(); - ImDrawList *dl = ImGui::GetForegroundDrawList(); - const ImRect work_rect(viewport->WorkPos, ImVec2(viewport->WorkPos.x + viewport->WorkSize.x, viewport->WorkPos.y + viewport->WorkSize.y)); - dl->AddRectFilled(viewport->Pos, ImVec2(viewport->Pos.x + viewport->Size.x, viewport->Pos.y + viewport->Size.y), IM_COL32(0, 0, 0, 50)); + // Each panel belongs to a viewport; detached panels need their own foreground layer. + std::vector viewports{ImGui::GetMainViewport()}; + for (const auto &entry : texts_) { + if (std::find(viewports.begin(), viewports.end(), entry.viewport) == viewports.end()) viewports.push_back(entry.viewport); + } + for (auto *viewport : viewports) { + ImGui::GetForegroundDrawList(viewport)->AddRectFilled(viewport->Pos, ImVec2(viewport->Pos.x + viewport->Size.x, viewport->Pos.y + viewport->Size.y), IM_COL32(0, 0, 0, 50)); + } ImFont *font = ImGui::GetFont(); ImFont *bold_font = boldFont() ? boldFont() : font; const float font_size = ImGui::GetFontSize(); @@ -156,7 +160,8 @@ void HelpOverlay::draw() { if (r.swatch) return font_size; return (r.bold ? bold_font : font)->CalcTextSizeA(font_size, FLT_MAX, 0.0f, r.text.c_str()).x; }; - for (const auto &[raw, rect] : texts_) { + for (const auto &[raw, rect, viewport] : texts_) { + ImDrawList *dl = ImGui::GetForegroundDrawList(viewport); if (raw.empty()) continue; const auto lines = parseHelpHtml(raw); float width = 0; @@ -167,7 +172,6 @@ void HelpOverlay::draw() { } const ImVec2 size(width, lines.size() * line_h); const ImVec2 center((rect.Min.x + rect.Max.x) * 0.5f, (rect.Min.y + rect.Max.y) * 0.5f); - if (!work_rect.Contains(center)) continue; // a torn off panel is in another viewport const ImVec2 min(center.x - size.x * 0.5f - 8.0f, center.y - size.y * 0.5f - 8.0f); const ImVec2 max(center.x + size.x * 0.5f + 8.0f, center.y + size.y * 0.5f + 8.0f); dl->AddRectFilled(min, max, ImGui::GetColorU32(ImGuiCol_PopupBg), ImGui::GetStyle().PopupRounding); diff --git a/openpilot/tools/cabana/ui/helpoverlay.h b/openpilot/tools/cabana/ui/helpoverlay.h index 2639d20946..1431a4c762 100644 --- a/openpilot/tools/cabana/ui/helpoverlay.h +++ b/openpilot/tools/cabana/ui/helpoverlay.h @@ -18,7 +18,12 @@ public: void draw(); private: - std::vector> texts_; + struct Entry { + std::string text; + ImRect rect; + ImGuiViewport *viewport; + }; + std::vector texts_; bool visible_ = false; int opened_frame_ = -1; }; diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index d78de4be34..d720e87c83 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -96,8 +96,8 @@ void MainWindow::drawFileMenu() { if (ImGui::MenuItem("Export to CSV...", nullptr, false, has_stream)) exportToCSV(); ImGui::Separator(); - if (ImGui::MenuItem("New DBC File", "Ctrl+N")) newFile(); - if (ImGui::MenuItem("Open DBC File...", "Ctrl+O")) openFile(); + if (ImGui::MenuItem("New DBC File", shortcut("N").c_str())) newFile(); + if (ImGui::MenuItem("Open DBC File...", shortcut("O").c_str())) openFile(); if (ImGui::BeginMenu("Manage DBC Files", has_stream)) { drawManageDBCsMenu(); @@ -120,8 +120,8 @@ void MainWindow::drawFileMenu() { ImGui::Separator(); const int cnt = dbc()->nonEmptyDBCCount(); const std::string save_text = cnt > 1 ? "Save " + std::to_string(cnt) + " DBCs..." : "Save DBC..."; - if (ImGui::MenuItem(save_text.c_str(), "Ctrl+S", false, cnt > 0)) save(); - if (ImGui::MenuItem("Save DBC As...", "Ctrl+Shift+S", false, cnt == 1)) saveAs(); + if (ImGui::MenuItem(save_text.c_str(), shortcut("S").c_str(), false, cnt > 0)) save(); + if (ImGui::MenuItem("Save DBC As...", shortcut("Shift+S").c_str(), false, cnt == 1)) saveAs(); // TODO: Support clipboard for multiple files if (ImGui::MenuItem("Copy DBC To Clipboard", nullptr, false, cnt == 1)) saveToClipboard(); @@ -129,7 +129,7 @@ void MainWindow::drawFileMenu() { if (ImGui::MenuItem("Settings...")) openSettings(); ImGui::Separator(); - if (ImGui::MenuItem("Exit", "Ctrl+Q")) close(); + if (ImGui::MenuItem("Exit", shortcut("Q").c_str())) close(); } namespace { @@ -175,13 +175,13 @@ void MainWindow::drawMenuBar() { auto stack = UndoStack::instance(); const std::string undo_text = stack->canUndo() ? "Undo " + stack->undoText() : "Undo"; const std::string redo_text = stack->canRedo() ? "Redo " + stack->redoText() : "Redo"; - if (ImGui::MenuItem(undo_text.c_str(), "Ctrl+Z", false, stack->canUndo())) stack->undo(); - if (ImGui::MenuItem(redo_text.c_str(), "Ctrl+Shift+Z", false, stack->canRedo())) stack->redo(); + if (ImGui::MenuItem(undo_text.c_str(), shortcut("Z").c_str(), false, stack->canUndo())) stack->undo(); + if (ImGui::MenuItem(redo_text.c_str(), shortcut("Shift+Z").c_str(), false, stack->canRedo())) stack->redo(); ImGui::EndMenu(); } if (beginTopMenu("View")) { - if (ImGui::MenuItem("Full Screen", "Ctrl+F11")) toggleFullScreen(); + if (ImGui::MenuItem("Full Screen", shortcut("F11").c_str())) toggleFullScreen(); ImGui::Separator(); ImGui::MenuItem(messages_widget_ ? messages_widget_->title().c_str() : "MESSAGES", nullptr, &messages_visible_); ImGui::MenuItem(video_dock_title_.empty() ? "Video" : video_dock_title_.c_str(), nullptr, &video_visible_); @@ -216,7 +216,6 @@ void MainWindow::createDockWidgets() { center_widget_.setChartsWidget(charts_widget_.get()); video_widget_ = std::make_unique(); widget_connections_.push_back(charts_widget_->toggleChartsDocking.connect([this]() { toggleChartsDocking(); })); - widget_connections_.push_back(charts_widget_->showTip.connect([this](double sec) { video_widget_->showThumbnail(sec); })); } void MainWindow::showStatusMessage(const std::string &msg, int timeout_ms) { @@ -976,7 +975,10 @@ void MainWindow::draw() { bool open = true; ImGui::SetNextWindowSize(ImGui::GetMainViewport()->WorkSize, ImGuiCond_Appearing); setNextWindowFloatsOut(); - if (ImGui::Begin(CHARTS_WINDOW, &open, ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) charts_widget_->draw(); + if (ImGui::Begin(CHARTS_WINDOW, &open, ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { + help_overlay_.add(charts_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); + charts_widget_->draw(); + } ImGui::End(); if (!open) toggleChartsDocking(); } diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index 30b6dc2c76..4566064a27 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -519,7 +519,12 @@ void drawToolbar(const std::vector &items, size_t spacer_index, flo // the extension button sits fully inside the toolbar: its right edge is the content region right edge const float extension_x = std::max(start_x, right_edge - extension_width); visible == 0 ? ImGui::SetCursorPosX(extension_x) : ImGui::SameLine(extension_x); - if (iconButton("toolbar_extension", icon::CHEVRON_DOUBLE_RIGHT, "More")) ImGui::OpenPopup("toolbar_extension_menu"); + const bool extension_open = ImGui::IsPopupOpen("toolbar_extension_menu"); + const bool extension_clicked = iconButton("toolbar_extension", icon::CHEVRON_DOUBLE_RIGHT, "More"); + if (!extension_open && (extension_clicked || + (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByPopup) && ImGui::IsMouseClicked(ImGuiMouseButton_Left)))) { + ImGui::OpenPopup("toolbar_extension_menu"); + } // the popup opens inward: its right edge is aligned with the button so it stays inside the window ImGui::SetNextWindowPos(ImVec2(ImGui::GetItemRectMax().x, ImGui::GetItemRectMax().y), ImGuiCond_Always, ImVec2(1, 0)); if (ImGui::BeginPopup("toolbar_extension_menu")) { @@ -565,7 +570,9 @@ bool menuButton(const char *id, const std::string &text, const char *popup_id, b ImGui::PushStyleColor(ImGuiCol_Button, popup_open ? style.Colors[ImGuiCol_ButtonActive] : style.Colors[ImGuiCol_Button]); ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(padding_x, style.FramePadding.y)); ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.0f, 0.5f)); - const bool clicked = ImGui::ButtonEx((text + "###" + id).c_str(), ImVec2(width, 0.0f), ImGuiButtonFlags_PressedOnClick); + const bool pressed = ImGui::ButtonEx((text + "###" + id).c_str(), ImVec2(width, 0.0f), ImGuiButtonFlags_PressedOnClick); + const bool clicked = pressed || (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByPopup) && + ImGui::IsMouseClicked(ImGuiMouseButton_Left)); ImGui::PopStyleVar(2); ImGui::PopStyleColor(); if (bold) popBoldFont(); diff --git a/openpilot/tools/cabana/ui/util.h b/openpilot/tools/cabana/ui/util.h index 576f197e88..d7a27fba7b 100644 --- a/openpilot/tools/cabana/ui/util.h +++ b/openpilot/tools/cabana/ui/util.h @@ -71,6 +71,13 @@ int doubleValidator(ImGuiInputTextCallbackData *data); int ipValidator(ImGuiInputTextCallbackData *data); int nonWhitespaceValidator(ImGuiInputTextCallbackData *data); +#ifdef __APPLE__ +constexpr const char *MOD_KEY = "Cmd"; +#else +constexpr const char *MOD_KEY = "Ctrl"; +#endif +inline std::string shortcut(const char *keys) { return std::string(MOD_KEY) + "+" + keys; } + // Use ItemInnerSpacing between related buttons and ItemSpacing between groups. bool iconButton(const char *id, const char *icon, const char *tooltip = nullptr); float iconButtonWidth(); diff --git a/openpilot/tools/cabana/ui/widgets/cameraview.h b/openpilot/tools/cabana/ui/widgets/cameraview.h index a90565a0f8..000e745f66 100644 --- a/openpilot/tools/cabana/ui/widgets/cameraview.h +++ b/openpilot/tools/cabana/ui/widgets/cameraview.h @@ -18,7 +18,8 @@ #include "tools/cabana/core/observable.h" #include "msgq/visionipc/visionipc_client.h" -constexpr float DEFAULT_CAMERA_ASPECT_RATIO = 1928.0f / 1208.0f; +// OS04C10 output, also used by its 1344x760 downscaled video. +constexpr float DEFAULT_CAMERA_ASPECT_RATIO = 2688.0f / 1520.0f; // Center-crop the source to fill the destination without stretching. inline ImVec2 videoFillUv(const ImVec2 &size, float aspect_ratio) { diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.h b/openpilot/tools/cabana/ui/widgets/videowidget.h index a1f6076a64..f7f0ced062 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.h +++ b/openpilot/tools/cabana/ui/widgets/videowidget.h @@ -89,10 +89,10 @@ public: // MainWindow calls this every frame with the video dock visibility, so the camera widget gets its // vipc thread started and stopped void setVisible(bool visible); - void showThumbnail(double seconds); std::string whatsThis() const; private: + void showThumbnail(double seconds); void updateSliderThumbnail(); // the thumbnail follows the mouse over the slider std::string formatTime(double sec, bool include_milliseconds = false); void timeRangeChanged(); From 7c1f2244431330916f8e8031dfa93910d7d9947b Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:03:12 -0700 Subject: [PATCH 028/122] cabana: stack the undo/redo zoom buttons in the chart menu (#38800) --- openpilot/tools/cabana/ui/chart/chartswidget.cc | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.cc b/openpilot/tools/cabana/ui/chart/chartswidget.cc index a5845cefdc..16fb7853b1 100644 --- a/openpilot/tools/cabana/ui/chart/chartswidget.cc +++ b/openpilot/tools/cabana/ui/chart/chartswidget.cc @@ -231,17 +231,15 @@ void ChartsWidget::drawToolBar() { pushMonoFont(ImGui::GetFontSize()); const float reset_zoom_width = iconTextButtonWidth(icon::ZOOM_OUT, widest + "-" + widest); popMonoFont(); - items.push_back({iconButtonWidth(), [this]() { + items.push_back({iconButtonWidth() * 2 + ImGui::GetStyle().ItemInnerSpacing.x, [this]() { ImGui::BeginDisabled(!zoom_undo_stack_.canUndo()); if (iconButton("undo_zoom", icon::ARROW_COUNTERCLOCKWISE, "Undo Zoom")) zoom_undo_stack_.undo(); ImGui::EndDisabled(); - }}); - items.push_back({iconButtonWidth(), [this]() { + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); ImGui::BeginDisabled(!zoom_undo_stack_.canRedo()); if (iconButton("redo_zoom", icon::ARROW_CLOCKWISE, "Redo Zoom")) zoom_undo_stack_.redo(); ImGui::EndDisabled(); }}); - items.back().tight = true; items.push_back({reset_zoom_width, [this, &reset_zoom_text, reset_zoom_width]() { pushMonoFont(ImGui::GetFontSize()); const bool clicked = iconTextButton("reset_zoom_btn", icon::ZOOM_OUT, reset_zoom_text, reset_zoom_width); From 20ae4ac44d5760e1005d9a818ae5517335c0c4d9 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:08:32 -0700 Subject: [PATCH 029/122] cabana: standardize settings step buttons (#38801) * cabana: align and brighten settings step buttons * cabana: use standard settings icon button color --- .../tools/cabana/ui/dialogs/settingsdialog.cc | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc b/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc index ea82a62a8d..f67e2f2ad8 100644 --- a/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc +++ b/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc @@ -7,6 +7,7 @@ #include "imgui_internal.h" #include "tools/cabana/settings.h" #include "tools/cabana/ui/dialogs/filedialog.h" +#include "tools/cabana/ui/icons.h" #include "tools/cabana/ui/util.h" #include "tools/cabana/utils/util.h" @@ -32,6 +33,25 @@ void formRow(FormLabel label, float label_width) { ImGui::SetNextItemWidth(-FLT_MIN); } +void settingInputInt(const char *id, int *value, int step, int step_fast, int minimum, int maximum) { + const float spacing = ImGui::GetStyle().ItemInnerSpacing.x; + const float width = ImGui::CalcItemWidth(); + ImGui::PushID(id); + ImGui::BeginGroup(); + ImGui::SetNextItemWidth(width - 2 * (iconButtonWidth() + spacing)); + ImGui::InputInt("##value", value, 0); + *value = std::clamp(*value, minimum, maximum); + const int increment = ImGui::GetIO().KeyCtrl ? step_fast : step; + ImGui::PushItemFlag(ImGuiItemFlags_ButtonRepeat, true); + ImGui::SameLine(0.0f, spacing); + if (iconButton("decrement", icon::DASH_LG)) *value = std::max(minimum, *value - increment); + ImGui::SameLine(0.0f, spacing); + if (iconButton("increment", icon::PLUS_LG)) *value = std::min(maximum, *value + increment); + ImGui::PopItemFlag(); + ImGui::EndGroup(); + ImGui::PopID(); +} + } // namespace void SettingsDialog::open() { @@ -56,10 +76,7 @@ void SettingsDialog::draw() { int theme_index = theme_ - LIGHT_THEME; if (ImGui::Combo("##theme", &theme_index, themes, IM_ARRAYSIZE(themes))) theme_ = theme_index + LIGHT_THEME; formRow(CACHED_MINUTES, label_width); - // InputInt takes no character filter, so out of range text is clamped after the edit - if (ImGui::InputInt("##cached_minutes", &cached_minutes_, 1, 10)) { - cached_minutes_ = std::clamp(cached_minutes_, MIN_CACHE_MINUTES, MAX_CACHE_MINUTES); - } + settingInputInt("cached_minutes", &cached_minutes_, 1, 10, MIN_CACHE_MINUTES, MAX_CACHE_MINUTES); ImGui::SeparatorText("New Signal Settings"); static const char *directions[] = {"MSB First", "LSB First", "Always Little Endian", "Always Big Endian"}; @@ -68,7 +85,7 @@ void SettingsDialog::draw() { ImGui::SeparatorText("Chart"); formRow(CHART_HEIGHT, label_width); - if (ImGui::InputInt("##chart_height", &chart_height_, 10, 10)) chart_height_ = std::clamp(chart_height_, 100, 500); + settingInputInt("chart_height", &chart_height_, 10, 10, 100, 500); checkBox("Enable live stream logging", &log_livestream_); ImGui::BeginDisabled(!log_livestream_); From 3e54af9db8d44ec8a36c6de624a42f8a4ca4953b Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:47:20 -0700 Subject: [PATCH 030/122] cabana: playback controls for live streaming (#38802) * Hide Cabana playback controls during live streaming * cabana: retain pause and go-live controls for live streams --- openpilot/tools/cabana/ui/mainwin.cc | 8 +++--- .../tools/cabana/ui/widgets/videowidget.cc | 25 ++++++++++--------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index d720e87c83..eef3a9b54a 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -903,8 +903,8 @@ void MainWindow::drawVideoPanel() { video_h = avail.y - splitter_h - charts_min_h; } } - // The splitter provides the gap; extra ItemSpacing would leave an undraggable strip. - if (!charts_floating_) ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); + // Replay uses a splitter for the gap; live streams use normal item spacing. + if (!charts_floating_ && !live) ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); if (video_h > 0.0f) { ImGui::BeginChild("video", ImVec2(0, video_h), ImGuiChildFlags_Borders); help_overlay_.add(video_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); @@ -913,7 +913,7 @@ void MainWindow::drawVideoPanel() { } else { video_widget_->setVisible(false); // the splitter collapsed the video: stop the vipc thread } - if (!charts_floating_) { + if (!charts_floating_ && !live) { ImGui::InvisibleButton("##splitter", ImVec2(-1.0f, splitter_h)); const bool splitter_hovered = ImGui::IsItemHovered() && !live, splitter_active = ImGui::IsItemActive() && !live; if (splitter_active) { @@ -927,6 +927,8 @@ void MainWindow::drawVideoPanel() { ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(splitter.Min.x, line_y), ImVec2(splitter.Max.x, line_y + 2.0f), ImGui::GetColorU32(splitter_active ? ImGuiCol_SeparatorActive : splitter_hovered ? ImGuiCol_SeparatorHovered : ImGuiCol_Border)); ImGui::PopStyleVar(); + } + if (!charts_floating_) { if (!charts_collapsed) { // the chart list scrolls in its own child, the container itself never scrolls ImGui::BeginChild("charts", ImVec2(0, 0), ImGuiChildFlags_Borders, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index ce8fb64c04..9d80c1a2bd 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -141,7 +141,8 @@ std::string VideoWidget::whatsThis() const { static float toolbarHeight() { return TOOLBAR_MARGIN_Y + ImGui::GetFrameHeight(); } void VideoWidget::drawPlaybackController() { - ImGui::SetCursorPosY(ImGui::GetCursorPosY() + TOOLBAR_MARGIN_Y); + if (!can->liveStreaming()) + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + TOOLBAR_MARGIN_Y); const float speed_width = menuButtonWidth("0.05x", true); const char *play_icon = can->isPaused() ? icon::PLAY : icon::PAUSE; @@ -151,13 +152,15 @@ void VideoWidget::drawPlaybackController() { : formatTime(can->currentSec(), true); const char *time_tooltip = settings.absolute_time ? "Elapsed time" : "Absolute time"; - std::vector items = { - toolbarAction("rewind", icon::REWIND, "Seek backward", []() { can->seekTo(can->currentSec() - 1); }), - toolbarAction("play", play_icon, play_tooltip, []() { can->pause(!can->isPaused()); }, true, true), - toolbarAction("fast-forward", icon::FAST_FORWARD, "Seek forward", []() { can->seekTo(can->currentSec() + 1); }, true, true), - }; + std::vector items; + if (!can->liveStreaming()) { + items.push_back(toolbarAction("rewind", icon::REWIND, "Seek backward", []() { can->seekTo(can->currentSec() - 1); })); + } + items.push_back(toolbarAction("play", play_icon, play_tooltip, []() { can->pause(!can->isPaused()); }, true, true)); if (can->liveStreaming()) { - items.push_back(toolbarAction("skip-end", icon::SKIP_END, "Skip to the end", [this]() { skipToEnd(); }, skip_to_end_enabled_, true)); + items.push_back(toolbarAction("skip-end", icon::SKIP_END, "Go live", [this]() { skipToEnd(); }, skip_to_end_enabled_, true)); + } else { + items.push_back(toolbarAction("fast-forward", icon::FAST_FORWARD, "Seek forward", []() { can->seekTo(can->currentSec() + 1); }, true, true)); } if (slider_ || msgs_received_) { // a mono font: with proportional digits the time changed width as it ticked and the items after it moved @@ -190,13 +193,11 @@ void VideoWidget::drawPlaybackController() { return item; }; const char *aspect_ratio_icon = settings.crop_video ? icon::ASPECT_RATIO_FILL : icon::ASPECT_RATIO; - items.push_back(toolbarAction("crop_video", aspect_ratio_icon, "Crop to fill", [this]() { cropVideoClicked(); })); if (!can->liveStreaming()) { + items.push_back(toolbarAction("crop_video", aspect_ratio_icon, "Crop to fill", [this]() { cropVideoClicked(); })); items.push_back(separator()); items.push_back(toolbarAction("loop", loop_icon, "Loop playback", [this]() { loopPlaybackClicked(); }, true, true)); - } - items.push_back(toolbarMenu("speed_btn", speed_text_, "Speed", [this]() { drawSpeedMenuItems(); }, true, true, speed_width)); - if (!can->liveStreaming()) { + items.push_back(toolbarMenu("speed_btn", speed_text_, "Speed", [this]() { drawSpeedMenuItems(); }, true, true, speed_width)); items.push_back(separator()); items.push_back(toolbarAction("route_info", icon::INFO_CIRCLE, "View route details", [this]() { showRouteInfo(); }, true, true)); } @@ -356,7 +357,7 @@ float VideoWidget::sizeHintHeight() const { // Keep the pane's default proportions stable as frames arrive or cameras change. float VideoWidget::defaultHeight(float width) const { - if (!cam_widget_) return toolbarHeight(); // live streams have no camera or slider + if (!cam_widget_) return ImGui::GetFrameHeight(); // live streams have no camera or slider const float cam_height = std::max((float)MIN_VIDEO_HEIGHT, width / DEFAULT_CAMERA_ASPECT_RATIO); const float tab_height = camera_tab_->count() >= 2 ? ImGui::GetFrameHeight() : 0.0f; return cam_height + tab_height + SLIDER_HEIGHT + toolbarHeight(); From bb86bee6882ed5422c89d2333fe98b85db21812d Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:56:19 -0700 Subject: [PATCH 031/122] cabana: clean up UI wording and formatting (#38803) --- .../tools/cabana/ui/chart/chartswidget.cc | 2 +- .../tools/cabana/ui/chart/signalselector.cc | 4 +-- .../tools/cabana/ui/dialogs/messagebox.cc | 2 +- .../tools/cabana/ui/dialogs/routesdialog.cc | 4 +-- .../tools/cabana/ui/dialogs/streamselector.cc | 16 ++++----- openpilot/tools/cabana/ui/mainwin.cc | 36 +++++++++---------- openpilot/tools/cabana/ui/tools/findsignal.cc | 16 ++++----- .../tools/cabana/ui/tools/findsimilarbits.cc | 6 ++-- .../tools/cabana/ui/widgets/detailwidget.cc | 6 ++-- .../tools/cabana/ui/widgets/messageswidget.cc | 8 ++--- .../tools/cabana/ui/widgets/signalview.cc | 2 +- 11 files changed, 51 insertions(+), 51 deletions(-) diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.cc b/openpilot/tools/cabana/ui/chart/chartswidget.cc index 16fb7853b1..24ce32aa25 100644 --- a/openpilot/tools/cabana/ui/chart/chartswidget.cc +++ b/openpilot/tools/cabana/ui/chart/chartswidget.cc @@ -69,7 +69,7 @@ std::string ChartsWidget::whatsThis() const { Click: Click to seek to a corresponding time.
Drag: Zoom into the chart.
Shift + Drag: Scrub through the chart to view values.
- Right Mouse: Open the context menu.
+ Right-click: Open the context menu.
)"; } diff --git a/openpilot/tools/cabana/ui/chart/signalselector.cc b/openpilot/tools/cabana/ui/chart/signalselector.cc index 2733a65f33..dd84c49cba 100644 --- a/openpilot/tools/cabana/ui/chart/signalselector.cc +++ b/openpilot/tools/cabana/ui/chart/signalselector.cc @@ -43,7 +43,7 @@ bool SignalSelector::draw() { ImGui::BeginGroup(); ImGui::TextUnformatted("Available Signals"); // a combo popup with a filter box - const char *preview = msgs_combo_index_ >= 0 ? msgs_combo_[msgs_combo_index_].text.c_str() : "Select a msg..."; + const char *preview = msgs_combo_index_ >= 0 ? msgs_combo_[msgs_combo_index_].text.c_str() : "Select a message..."; ImGui::SetNextItemWidth(column_w); if (ImGui::BeginCombo("##msgs_combo", preview)) { if (ImGui::IsWindowAppearing()) { @@ -51,7 +51,7 @@ bool SignalSelector::draw() { ImGui::SetKeyboardFocusHere(); } ImGui::SetNextItemWidth(-FLT_MIN); - inputText("##msgs_filter", &msgs_combo_filter_, "Select a msg..."); + inputText("##msgs_filter", &msgs_combo_filter_, "Select a message..."); for (int i = 0; i < (int)msgs_combo_.size(); ++i) { if (!msgs_combo_filter_.empty() && !utils::containsCI(msgs_combo_[i].text, msgs_combo_filter_)) continue; if (ImGui::Selectable(msgs_combo_[i].text.c_str(), i == msgs_combo_index_)) { diff --git a/openpilot/tools/cabana/ui/dialogs/messagebox.cc b/openpilot/tools/cabana/ui/dialogs/messagebox.cc index 9d1a2bde97..bac66037a9 100644 --- a/openpilot/tools/cabana/ui/dialogs/messagebox.cc +++ b/openpilot/tools/cabana/ui/dialogs/messagebox.cc @@ -68,7 +68,7 @@ void draw() { ImGui::Separator(); if (!box.detailed_text.empty()) { // the details button sits at the left of the button box - if (ImGui::Button(g_show_details ? "Hide Details..." : "Show Details...")) g_show_details = !g_show_details; + if (ImGui::Button(g_show_details ? "Hide Details" : "Show Details")) g_show_details = !g_show_details; ImGui::SameLine(); } dialogButtons("OK", &result, &done, true, box.has_cancel ? "Cancel" : nullptr); diff --git a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc index d0676ae709..8e192dce17 100644 --- a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc +++ b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc @@ -61,7 +61,7 @@ void RoutesDialog::setRouteList(const std::vector &list, bool if (success) { for (const auto &route : list) { const int mins = static_cast((route.end_ms - route.start_ms) / 60000); - s_.routes.push_back({routes::formatUnixMs(route.start_ms) + " " + std::to_string(mins) + "min", route.name}); + s_.routes.push_back({routes::formatUnixMs(route.start_ms) + " " + std::to_string(mins) + " min", route.name}); } if (!s_.routes.empty()) s_.route_index = 0; } else { @@ -80,7 +80,7 @@ void RoutesDialog::finish(bool accepted) { void RoutesDialog::draw() { if (!open_) return; - if (!beginDialog("Remote routes", &popup_, ImVec2(480.0f, 420.0f))) return; + if (!beginDialog("Remote Routes", &popup_, ImVec2(480.0f, 420.0f))) return; ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Device"); diff --git a/openpilot/tools/cabana/ui/dialogs/streamselector.cc b/openpilot/tools/cabana/ui/dialogs/streamselector.cc index 4c724c6fce..f1d2b1da13 100644 --- a/openpilot/tools/cabana/ui/dialogs/streamselector.cc +++ b/openpilot/tools/cabana/ui/dialogs/streamselector.cc @@ -19,15 +19,15 @@ void OpenReplayWidget::draw() { ImGui::TextUnformatted("Route"); ImGui::SameLine(); ImGui::SetNextItemWidth(-250.0f); - inputText("##route", &route_, "Enter route name or browse for local/remote route"); + inputText("##route", &route_, "Enter a route name or browse for a local or remote route"); ImGui::SameLine(); - if (ImGui::Button("Remote route...")) { + if (ImGui::Button("Remote Route...")) { routes_dialog_.open(utils::guarded(alive_, [this](bool accepted, const std::string &route) { if (accepted) route_ = route; })); } ImGui::SameLine(); - if (ImGui::Button("Local route...")) { + if (ImGui::Button("Local Route...")) { FileDialog::getExistingDirectory("Open Local Route", settings.last_route_dir, utils::guarded(alive_, [this](const std::string &dir) { if (!dir.empty()) { route_ = dir; @@ -128,7 +128,7 @@ void OpenPandaWidget::buildConfigForm() { void OpenPandaWidget::draw() { if (already_connected_) { ImGui::Text("Already connected to %s.", can->routeName().c_str()); - ImGui::TextUnformatted("Close the current connection via [File menu -> Close Stream] before connecting to another Panda."); + ImGui::TextUnformatted("Select File > Close Stream before connecting to another panda."); return; } ImGui::AlignTextToFramePadding(); @@ -193,7 +193,7 @@ void OpenDeviceWidget::draw() { ImGui::SameLine(label_width); ImGui::BeginDisabled(mode_ != 1); ImGui::SetNextItemWidth(-1.0f); - validatedText("##ip", &ip_address_, validateIpAddress, "Enter device Ip Address", ipValidator); + validatedText("##ip", &ip_address_, validateIpAddress, "Enter device IP address", ipValidator); ImGui::EndDisabled(); } @@ -263,7 +263,7 @@ void StreamSelector::open(Callback on_done) { void StreamSelector::draw() { if (!open_) return; - if (!beginDialog("Open stream", &popup_, ImVec2(768.0f, 0.0f))) return; + if (!beginDialog("Open Stream", &popup_, ImVec2(768.0f, 0.0f))) return; AbstractOpenStreamWidget *current = nullptr; const ImVec4 pane = ImGui::GetStyleColorVec4(ImGuiCol_WindowBg); @@ -287,10 +287,10 @@ void StreamSelector::draw() { first_frame_ = false; ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted("dbc File"); + ImGui::TextUnformatted("DBC File"); ImGui::SameLine(); ImGui::SetNextItemWidth(-90.0f); - inputText("##dbc", &dbc_file_, "Choose a dbc file to open", ImGuiInputTextFlags_ReadOnly); + inputText("##dbc", &dbc_file_, "Choose a DBC file to open", ImGuiInputTextFlags_ReadOnly); ImGui::SameLine(); if (ImGui::Button("Browse...")) { FileDialog::getOpenFileName("Open File", settings.last_dir, ".dbc", [this](const std::string &fn) { diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index eef3a9b54a..3f8c815e77 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -92,7 +92,7 @@ void MainWindow::loadFingerprints() { void MainWindow::drawFileMenu() { const bool has_stream = hasStream(); if (ImGui::MenuItem("Open Stream...")) selectAndOpenStream(); - if (ImGui::MenuItem("Close stream", nullptr, false, has_stream)) closeStream(); + if (ImGui::MenuItem("Close Stream", nullptr, false, has_stream)) closeStream(); if (ImGui::MenuItem("Export to CSV...", nullptr, false, has_stream)) exportToCSV(); ImGui::Separator(); @@ -115,7 +115,7 @@ void MainWindow::drawFileMenu() { } ImGui::EndMenu(); } - if (ImGui::MenuItem("Load DBC From Clipboard")) loadFromClipboard(); + if (ImGui::MenuItem("Load DBC from Clipboard")) loadFromClipboard(); ImGui::Separator(); const int cnt = dbc()->nonEmptyDBCCount(); @@ -123,7 +123,7 @@ void MainWindow::drawFileMenu() { if (ImGui::MenuItem(save_text.c_str(), shortcut("S").c_str(), false, cnt > 0)) save(); if (ImGui::MenuItem("Save DBC As...", shortcut("Shift+S").c_str(), false, cnt == 1)) saveAs(); // TODO: Support clipboard for multiple files - if (ImGui::MenuItem("Copy DBC To Clipboard", nullptr, false, cnt == 1)) saveToClipboard(); + if (ImGui::MenuItem("Copy DBC to Clipboard", nullptr, false, cnt == 1)) saveToClipboard(); ImGui::Separator(); if (ImGui::MenuItem("Settings...")) openSettings(); @@ -283,7 +283,7 @@ void MainWindow::closeStream() { if (dbc()->nonEmptyDBCCount() > 0) { dbc()->fileChanged(); } - showStatusMessage("stream closed"); + showStatusMessage("Stream closed"); } void MainWindow::exportToCSV() { @@ -315,7 +315,7 @@ void MainWindow::loadFile(const std::string &fn, SourceSet s, std::functionopen(s, fn, &error)) { updateRecentFiles(fn); - showStatusMessage("DBC File " + fn + " loaded", 2000); + showStatusMessage("DBC file " + fn + " loaded", 2000); if (then) then(); } else { MessageBox::warning("Failed to load DBC file", "Failed to parse DBC file " + fn, error, then); @@ -333,11 +333,11 @@ void MainWindow::loadDBCFromOpendbc(const std::string &name) { void MainWindow::loadFromClipboard(SourceSet s, bool close_all) { std::string text; if (!utils::getClipboardText(&text)) { - MessageBox::warning("Load From Clipboard", "No clipboard tool found. Install xclip (X11) or wl-clipboard (Wayland)."); + MessageBox::warning("Load from Clipboard", "No clipboard tool found. Install xclip (X11) or wl-clipboard (Wayland)."); return; } if (text.empty()) { - MessageBox::warning("Load From Clipboard", "Clipboard is empty."); + MessageBox::warning("Load from Clipboard", "Clipboard is empty."); return; } @@ -345,9 +345,9 @@ void MainWindow::loadFromClipboard(SourceSet s, bool close_all) { std::string error; bool ret = dbc()->open(s, std::string(""), text, &error); if (ret && dbc()->nonEmptyDBCCount() > 0) { - MessageBox::information("Load From Clipboard", "DBC Successfully Loaded!"); + MessageBox::information("Load from Clipboard", "DBC loaded successfully."); } else { - MessageBox::warning("Failed to load DBC from clipboard", "Make sure that you paste the text with correct format.", error); + MessageBox::warning("Failed to load DBC from clipboard", "Make sure the clipboard contains correctly formatted DBC text.", error); } }); } @@ -512,9 +512,9 @@ void MainWindow::saveFileToClipboard(DBCFile *dbc_file) { void MainWindow::copyToClipboard(const std::string &text) { if (utils::setClipboardText(text)) { - MessageBox::information("Copy To Clipboard", "DBC Successfully copied!"); + MessageBox::information("Copy to Clipboard", "DBC copied successfully."); } else { - MessageBox::warning("Copy To Clipboard", "Failed to copy DBC to clipboard. Install xclip (X11) or wl-clipboard (Wayland)."); + MessageBox::warning("Copy to Clipboard", "Failed to copy DBC to clipboard. Install xclip (X11) or wl-clipboard (Wayland)."); } } @@ -528,9 +528,9 @@ void MainWindow::drawManageDBCsMenu() { const std::string title = "Bus " + std::to_string(source) + " (" + (dbc_file ? dbc_file->name() : "No DBCs loaded") + ")"; ImGui::PushID(source); if (ImGui::BeginMenu(title.c_str())) { - if (ImGui::MenuItem("New DBC File...")) newFile(ss); + if (ImGui::MenuItem("New DBC File")) newFile(ss); if (ImGui::MenuItem("Open DBC File...")) openFile(ss); - if (ImGui::MenuItem("Load DBC From Clipboard...")) loadFromClipboard(ss, false); + if (ImGui::MenuItem("Load DBC from Clipboard")) loadFromClipboard(ss, false); // Show sub-menu for each dbc for this source. if (dbc_file) { @@ -538,9 +538,9 @@ void MainWindow::drawManageDBCsMenu() { ImGui::MenuItem((dbc_file->name() + " (" + toString(dbc()->sources(dbc_file)) + ")").c_str(), nullptr, false, false); if (ImGui::MenuItem("Save...")) saveFile(dbc_file); if (ImGui::MenuItem("Save As...")) saveFileAs(dbc_file); - if (ImGui::MenuItem("Copy to Clipboard...")) saveFileToClipboard(dbc_file); - if (ImGui::MenuItem("Remove from this bus...")) closeFile(ss, {}); - if (ImGui::MenuItem("Remove from all buses...")) closeFile(dbc_file); + if (ImGui::MenuItem("Copy to Clipboard")) saveFileToClipboard(dbc_file); + if (ImGui::MenuItem("Remove from This Bus...")) closeFile(ss, {}); + if (ImGui::MenuItem("Remove from All Buses...")) closeFile(dbc_file); } ImGui::EndMenu(); } @@ -578,7 +578,7 @@ void MainWindow::remindSaveChanges(std::function then) { if (then) then(); return; } - std::string text = "You have unsaved changes. Press ok to save them, cancel to discard."; + std::string text = "You have unsaved changes. Select OK to save them or Cancel to discard them."; MessageBox::question("Unsaved Changes", text, [this, then](bool ok) { if (ok) { save([this, then]() { remindSaveChanges(then); }); @@ -755,7 +755,7 @@ void MainWindow::drawStatusBar() { ImGui::TextUnformatted(bar.message.c_str()); } else { bar.message.clear(); - ImGui::TextUnformatted("For Help, Press F1"); + ImGui::TextUnformatted("For help, press F1"); } if (bar.progress_visible) { ImGui::SameLine(width - pad - 300.0f); diff --git a/openpilot/tools/cabana/ui/tools/findsignal.cc b/openpilot/tools/cabana/ui/tools/findsignal.cc index 1642ac2cc5..c3ce025f4c 100644 --- a/openpilot/tools/cabana/ui/tools/findsignal.cc +++ b/openpilot/tools/cabana/ui/tools/findsignal.cc @@ -89,7 +89,7 @@ bool FindSignalDlg::draw() { drawFindGroup(); ImGui::EndChild(); if (searched_) { - ImGui::Text("%zu matches. right click on an item to create signal. double click to open message", + ImGui::Text("%zu matches. Right-click an item to create a signal. Double-click to open the message.", search_.filtered_signals.size()); } } @@ -103,12 +103,12 @@ void FindSignalDlg::drawMessageGroup() { ImGui::TextUnformatted("Bus"); ImGui::SameLine(80); ImGui::SetNextItemWidth(-1); - inputText("##bus", &bus_, "comma-separated values. Leave blank for all"); + inputText("##bus", &bus_, "Comma-separated values. Leave blank for all."); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Address"); ImGui::SameLine(80); ImGui::SetNextItemWidth(-1); - inputText("##address", &address_, "comma-separated hex values. Leave blank for all"); + inputText("##address", &address_, "Comma-separated hex values. Leave blank for all."); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Time"); ImGui::SameLine(80); @@ -138,7 +138,7 @@ void FindSignalDlg::drawPropertiesGroup() { ImGui::SetNextItemWidth(70); if (ImGui::InputInt("##max_size", &max_size_, 1, 10)) max_size_ = std::clamp(max_size_, 1, 64); ImGui::SameLine(); - checkBox("Little endian", &little_endian_); + checkBox("Little Endian", &little_endian_); ImGui::SameLine(); checkBox("Signed", &is_signed_); ImGui::AlignTextToFramePadding(); @@ -157,7 +157,7 @@ void FindSignalDlg::drawPropertiesGroup() { void FindSignalDlg::drawFindGroup() { static const char *compare_items[] = {"=", ">", ">=", "!=", "<", "<=", "between"}; const int compare_count = IM_ARRAYSIZE(compare_items); - ImGui::TextUnformatted("Find signal"); + ImGui::TextUnformatted("Find Signal"); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Value"); ImGui::SameLine(); @@ -177,14 +177,14 @@ void FindSignalDlg::drawFindGroup() { ImGui::SameLine(); const bool first = !searching_ && search_.histories.empty(); ImGui::BeginDisabled(searching_ || search_.histories.size() <= 1); - if (ImGui::Button("Undo prev find")) { + if (ImGui::Button("Undo Previous Find")) { search_.undo(); searched_ = true; } ImGui::EndDisabled(); ImGui::SameLine(); ImGui::BeginDisabled(searching_ || (search_.filtered_signals.empty() && !first)); - if (ImGui::Button(searching_ ? "Finding ...." : (first ? "Find" : "Find Next"))) search(); + if (ImGui::Button(searching_ ? "Finding..." : (first ? "Find" : "Find Next"))) search(); ImGui::EndDisabled(); ImGui::SameLine(); ImGui::BeginDisabled(searching_ || first); @@ -203,7 +203,7 @@ void FindSignalDlg::drawFindGroup() { } void FindSignalDlg::drawTable() { - static const char *titles[] = {"Id", "Start Bit, size", "(time, value)"}; + static const char *titles[] = {"ID", "Start Bit, Size", "(Time, Value)"}; const int columns = IM_ARRAYSIZE(titles); const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable | ImGuiTableFlags_NoSavedSettings; if (!ImGui::BeginTable("view", columns + 1, flags, ImVec2(0, 0))) return; diff --git a/openpilot/tools/cabana/ui/tools/findsimilarbits.cc b/openpilot/tools/cabana/ui/tools/findsimilarbits.cc index 81ae3660a9..daffc5408c 100644 --- a/openpilot/tools/cabana/ui/tools/findsimilarbits.cc +++ b/openpilot/tools/cabana/ui/tools/findsimilarbits.cc @@ -9,7 +9,7 @@ #include "tools/cabana/ui/util.h" FindSimilarBitsDlg::FindSimilarBitsDlg() { - setTitle("Find similar bits"); + setTitle("Find Similar Bits"); for (int bus : can->sources) { bus_items_.push_back(bus); @@ -64,7 +64,7 @@ bool FindSimilarBitsDlg::draw() { ImGui::SetNextItemWidth(60); ImGui::Combo("##equal", &equal_, "Yes\0No\0"); ImGui::SameLine(); - ImGui::TextUnformatted("Min msg count"); + ImGui::TextUnformatted("Minimum Message Count"); ImGui::SameLine(); ImGui::SetNextItemWidth(80); if (ImGui::InputInt("##min_msgs", &min_msgs_, 1, 10)) min_msgs_ = std::max(min_msgs_, 0); @@ -86,7 +86,7 @@ void FindSimilarBitsDlg::drawTable() { const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable | ImGuiTableFlags_NoSavedSettings; if (!ImGui::BeginTable("table", 7, flags, ImVec2(0, 0))) return; ImGui::TableSetupScrollFreeze(0, 1); - static const char *headers[] = {"address", "byte idx", "bit idx", "mismatches", "total msgs", "% mismatched"}; + static const char *headers[] = {"Address", "Byte Index", "Bit Index", "Mismatches", "Messages", "Mismatched (%)"}; // the fixed widths are section sizes: imgui adds the cell padding on top of the column width const float padding = ImGui::GetStyle().CellPadding.x * 2; ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 40.0f - padding); // vertical header: row number diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc index 00ed44b09d..0e5cda1bd2 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.cc +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -310,7 +310,7 @@ std::vector> DetailWidget::helpRects() const { EditMessageDialog::EditMessageDialog(const MessageId &msg_id, const std::string &title, int size, float parent_width) : msg_id_(msg_id), original_name_(title), name_edit_(title), size_spin_(size), width_(parent_width * 0.9f) { - window_title_ = "Edit message: " + msg_id.toString(); + window_title_ = "Edit Message: " + msg_id.toString(); if (auto msg = dbc()->msg(msg_id)) { node_ = msg->transmitter; @@ -438,10 +438,10 @@ void CenterWidget::drawWelcomeWidget() { y += ImGui::GetFrameHeightWithSpacing(); }; - centered("<-Select a message to view details", y); + centered("<- Select a message to view details", y); y += ImGui::GetTextLineHeightWithSpacing(); newShortcutRow("Pause", "Space"); newShortcutRow("Help", "F1"); - newShortcutRow("WhatsThis", "Shift+F1"); + newShortcutRow("What's This?", "Shift+F1"); ImGui::PopStyleColor(); } diff --git a/openpilot/tools/cabana/ui/widgets/messageswidget.cc b/openpilot/tools/cabana/ui/widgets/messageswidget.cc index 08148107a0..52e0687204 100644 --- a/openpilot/tools/cabana/ui/widgets/messageswidget.cc +++ b/openpilot/tools/cabana/ui/widgets/messageswidget.cc @@ -231,11 +231,11 @@ std::string MessagesWidget::whatsThis() const { return R"( Message View
Byte color
- constant changing
+ constantly changing
increasing
decreasing
Shortcuts
- Horizontal Scrolling:  shift+wheel  + Horizontal Scrolling:  Shift+Wheel  )"; } @@ -310,10 +310,10 @@ void MessagesWidget::drawContextMenu() { } } ImGui::Separator(); - if (ImGui::MenuItem("Multi-Line bytes", nullptr, settings.multiple_lines_hex)) { + if (ImGui::MenuItem("Multiline Bytes", nullptr, settings.multiple_lines_hex)) { setMultiLineBytes(!settings.multiple_lines_hex); } - if (ImGui::MenuItem("Show inactive messages", nullptr, list_.show_inactive_messages)) { + if (ImGui::MenuItem("Show Inactive Messages", nullptr, list_.show_inactive_messages)) { list_.showInactiveMessages(!list_.show_inactive_messages); } ImGui::EndPopup(); diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index 2413912ea2..d98058354a 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -883,7 +883,7 @@ void SignalView::drawIndexWidget(SignalModel::Item *item, const ImRect &rect) { showChart(model_.msgId(), sig, item->chart_opened, ImGui::GetIO().KeyShift); } if (checked) ImGui::PopStyleColor(); - ImGui::SetItemTooltip("%s", checked ? "Close Plot" : "Show Plot\nSHIFT click to add to previous opened plot"); + ImGui::SetItemTooltip("%s", checked ? "Close Plot" : "Show Plot\nShift-click to add to the previously opened plot"); ImGui::SameLine(0.0f, spacing); if (iconButton("remove", icon::X_LG) && !editor_open_on_press_) { pending_action_ = [this, sig]() { UndoStack::instance()->push(new RemoveSigCommand(model_.msgId(), sig)); }; From 10b9e73859cb5838548305604fb1f34446becc34 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:06:01 -0700 Subject: [PATCH 032/122] cabana: fill event bar to camera edges (#38807) --- openpilot/tools/cabana/ui/widgets/videowidget.cc | 10 +++++----- openpilot/tools/cabana/ui/widgets/videowidget.h | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index 9d80c1a2bd..3149619d71 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -523,7 +523,7 @@ void StreamCameraView::draw(const ImVec2 &size, double thumbnail_time) { scrubbing ? drawScrubThumbnail(p, thumbnail_time) : drawThumbnail(p, thumbnail_time); } if (auto alert = getReplay()->findAlertAtTime(scrubbing ? thumbnail_time : can->currentSec())) { - drawAlert(p, rect(), *alert, ImGui::GetFontSize()); + drawAlert(p, rect(), *alert, ImGui::GetFontSize(), ImGui::GetStyle().ChildRounding); } if (can->isPaused()) { @@ -571,7 +571,7 @@ void StreamCameraView::drawThumbnail(ImDrawList *p, double sec) { p->AddRect(thumb_rect.Min, thumb_rect.Max, IM_COL32_WHITE, ImGui::GetStyle().FrameRounding, 0, 2.0f); // look up the alert at the hovered time, the thumbnail frame itself can be seconds away if (auto alert = getReplay()->findAlertAtTime(sec)) { - drawAlert(p, thumb_rect, *alert, POINT_10_FONT_SIZE); + drawAlert(p, thumb_rect, *alert, POINT_10_FONT_SIZE, ImGui::GetStyle().FrameRounding); } drawTime(p, thumb_rect, sec); } @@ -587,17 +587,17 @@ void StreamCameraView::drawTime(ImDrawList *p, const ImRect &rect, double second IM_COL32_WHITE, text); } -void StreamCameraView::drawAlert(ImDrawList *p, const ImRect &rect, const Timeline::Entry &alert, float font_size) { +void StreamCameraView::drawAlert(ImDrawList *p, const ImRect &rect, const Timeline::Entry &alert, float font_size, float rounding) { const ImU32 pen = IM_COL32_WHITE; ImU32 color = withAlpha(timeline_colors[int(alert.type)], 128); std::string text = alert.text1; if (!alert.text2.empty()) text += "\n" + alert.text2; - ImRect text_rect(ImVec2(rect.Min.x + 1, rect.Min.y + 1), ImVec2(rect.Max.x - 1, rect.Max.y - 1)); + const ImRect &text_rect = rect; ImFont *font = ImGui::GetFont(); const float wrap_width = std::max(1.0f, text_rect.GetWidth()); const ImVec2 r = font->CalcTextSizeA(font_size, FLT_MAX, wrap_width, text.c_str()); - p->AddRectFilled(ImVec2(text_rect.Min.x, text_rect.Min.y), ImVec2(text_rect.Max.x, text_rect.Min.y + r.y), color, ImGui::GetStyle().FrameRounding, ImDrawFlags_RoundCornersTop); + p->AddRectFilled(ImVec2(text_rect.Min.x, text_rect.Min.y), ImVec2(text_rect.Max.x, text_rect.Min.y + r.y), color, rounding, ImDrawFlags_RoundCornersTop); // each line is centered, wrapped continuations stay left aligned float y = text_rect.Min.y; for (const auto &line : utils::split(text, '\n')) { diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.h b/openpilot/tools/cabana/ui/widgets/videowidget.h index f7f0ced062..1abdbdbe71 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.h +++ b/openpilot/tools/cabana/ui/widgets/videowidget.h @@ -70,7 +70,7 @@ private: void collectThumbnails(); // moves the decoded thumbnails in once a parseQLog task is done // the first thumbnail at or after sec, uploaded to big_thumbnail_texture_; nullptr when there is none const RgbImage *thumbnailAt(double sec); - void drawAlert(ImDrawList *p, const ImRect &rect, const Timeline::Entry &alert, float font_size); + void drawAlert(ImDrawList *p, const ImRect &rect, const Timeline::Entry &alert, float font_size, float rounding); void drawThumbnail(ImDrawList *p, double sec); void drawScrubThumbnail(ImDrawList *p, double sec); void drawTime(ImDrawList *p, const ImRect &rect, double seconds); From 3eafb658bf3e12fa2e515958aa641fd5a99fdf67 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:48:33 -0700 Subject: [PATCH 033/122] mici: remove duplicate pairing button from main settings (#38809) --- openpilot/selfdrive/ui/mici/layouts/settings/settings.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/settings.py b/openpilot/selfdrive/ui/mici/layouts/settings/settings.py index eb7789cba9..477bd78935 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/settings.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/settings.py @@ -3,7 +3,7 @@ from openpilot.system.ui.widgets.scroller import NavScroller from openpilot.selfdrive.ui.mici.widgets.button import BigButton from openpilot.selfdrive.ui.mici.layouts.settings.toggles import TogglesLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.network.network_layout import NetworkLayoutMici -from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici, PairBigButton +from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.developer import DeveloperLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.software import SoftwareLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.firehose import FirehoseLayout @@ -49,7 +49,6 @@ class SettingsLayout(NavScroller): network_btn, device_btn, software_btn, - PairBigButton(), firehose_btn, developer_btn, ]) From 8f2d66d0e5d35daf93670269e336047627eaa745 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:26:53 -0700 Subject: [PATCH 034/122] cabana: round only the outer video frame (#38808) * cabana: remove video frame corner rounding * cabana: round the outer video frame including letterboxing --- .../tools/cabana/ui/widgets/cameraview.cc | 19 ++++++++++++++++++- .../tools/cabana/ui/widgets/cameraview.h | 2 ++ .../tools/cabana/ui/widgets/videowidget.cc | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/openpilot/tools/cabana/ui/widgets/cameraview.cc b/openpilot/tools/cabana/ui/widgets/cameraview.cc index 460ef47c25..b771717c22 100644 --- a/openpilot/tools/cabana/ui/widgets/cameraview.cc +++ b/openpilot/tools/cabana/ui/widgets/cameraview.cc @@ -21,6 +21,23 @@ void generateMipmap() { } } // namespace +void drawVideoFrame(ImDrawList *draw_list, ImTextureRef texture, const ImRect &rect, const VideoPlacement &placement) { + const ImVec2 size(placement.max.x - placement.min.x, placement.max.y - placement.min.y); + if (size.x <= 0 || size.y <= 0) return; + + // Round the full frame, then clip to the square video bounds so letterboxing + // doesn't introduce a second set of rounded corners around the image. + const ImVec2 uv_scale((placement.uv1.x - placement.uv0.x) / size.x, + (placement.uv1.y - placement.uv0.y) / size.y); + const ImVec2 uv0(placement.uv0.x + (rect.Min.x - placement.min.x) * uv_scale.x, + placement.uv0.y + (rect.Min.y - placement.min.y) * uv_scale.y); + const ImVec2 uv1(placement.uv1.x + (rect.Max.x - placement.max.x) * uv_scale.x, + placement.uv1.y + (rect.Max.y - placement.max.y) * uv_scale.y); + draw_list->PushClipRect(placement.min, placement.max, true); + draw_list->AddImageRounded(texture, rect.Min, rect.Max, uv0, uv1, IM_COL32_WHITE, ImGui::GetStyle().ChildRounding); + draw_list->PopClipRect(); +} + void GlTexture::upload(const RgbImage &image) { if (id == 0) { glGenTextures(1, &id); @@ -114,7 +131,7 @@ void CameraWidget::paint() { // mirror cabin camera horizontally std::swap(placement.uv0.x, placement.uv1.x); } - p->AddImageRounded(frame_texture_.ref(), placement.min, placement.max, placement.uv0, placement.uv1, IM_COL32_WHITE, ImGui::GetStyle().ChildRounding); + drawVideoFrame(p, frame_texture_.ref(), rect_, placement); } void CameraWidget::vipcThread() { diff --git a/openpilot/tools/cabana/ui/widgets/cameraview.h b/openpilot/tools/cabana/ui/widgets/cameraview.h index 000e745f66..db4e04f63c 100644 --- a/openpilot/tools/cabana/ui/widgets/cameraview.h +++ b/openpilot/tools/cabana/ui/widgets/cameraview.h @@ -50,6 +50,8 @@ inline VideoPlacement videoPlacement(const ImRect &rect, float source_aspect_rat return placement; } +void drawVideoFrame(ImDrawList *draw_list, ImTextureRef texture, const ImRect &rect, const VideoPlacement &placement); + // tightly packed RGBA pixels struct RgbImage { int width = 0; diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index 3149619d71..5122ca96fa 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -550,7 +550,7 @@ void StreamCameraView::drawScrubThumbnail(ImDrawList *p, double sec) { p->AddRectFilled(rect().Min, rect().Max, IM_COL32(0, 0, 0, 255), ImGui::GetStyle().ChildRounding); if (const RgbImage *image = thumbnailAt(sec)) { const VideoPlacement placement = videoPlacement(rect(), (float)image->width / image->height, settings.crop_video); - p->AddImageRounded(big_thumbnail_texture_.ref(), placement.min, placement.max, placement.uv0, placement.uv1, IM_COL32_WHITE, ImGui::GetStyle().ChildRounding); + drawVideoFrame(p, big_thumbnail_texture_.ref(), rect(), placement); drawTime(p, rect(), sec); } } From 25d9d41c9007e64de7bb28ff8f27c7148a34b5ab Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:55:28 -0700 Subject: [PATCH 035/122] esim: comma four profile management UI (#37844) * esim: MICI eSIM profile management UI * esim: align rename button position regardless of delete button * esim: skip profile UI when SIM is not an eUICC Probe is_euicc() on the first profile poll and cache it; non-eUICC SIMs avoid list_profiles/process_notifications (which hang on a plain SIM) and the eSIM button shows ICCID/MCC-MNC metadata instead of opening the profile management screen. * esim: satisfy ruff E731 in action_pressed helper * esim: get modem info from modem.py state instead of shelling out * esim: simplify switch lifecycle, drop active flag and settle window * esim: only show 'switching...' on the target profile * esim: read cell strength directly from HARDWARE * esim: hide checkmark and dim cell icon during switch; keep rename available * esim: disable active profile button (rename still clickable as overlay) * esim: stop rename/delete buttons and labels from flashing during operations * esim: show 'comma prime' on network button for comma profile * esim: move display_name and is_comma onto Profile dataclass * esim: anchor rename button to rightmost slot to prevent shift * esim: include iccid prefix check in Profile.is_comma * esim: drop process_notifications from cellular manager * esim: disable network button when SIM isn't an eUICC * esim: rename ESim* classes to Esim* * esim: sort imports * esim: default to 'loading...' instead of 'no active profile' * esim: rename PROFILE_POLL_INTERVAL to PROFILE_POLL_INTERVAL_S * esim: slim EsimNetworkButton * esim: use DEFAULT_TEXT_COLOR; load delete dialog texture in __init__ * esim: revert cell-icon index trick, name each NetworkStrength explicitly * esim: tighten delete/rename spacing so 'switch' label fits on one line * esim: shrink delete/rename buttons by 25% * esim: keep rename button at full size, only delete shrinks * Revert "disable modem.py for now" This reverts commit 1eeba86ec1826add74837dcf40beaf8ce81fb865. * Revert "modem.py is disabled" This reverts commit d238a1ccc4d72a9165d9483ea8bd84d218ad8f00. * lpa: inline comma iccid prefix * ui/cellular_manager: clear switching state when LPA returns * ui/cellular_manager: lock callback queue, drop poll log noise * esim: sort NetworkStrength/NetworkType imports * esim: drop switching_iccid concept * esim: show 'switching...' on the clicked profile button * esim: optimistic switch, skip post-switch list_profiles to avoid flicker * esim: only style profile button from local op flags, not global busy * esim: remove deleting/switching state, collapse profile button branches * esim: show GSM settings on full prime when non-comma profile is active * esim: expose CellularManager.active_profile, dedup callers * esim: apply comma prime defaults on switch (roaming, metered, no APN) * esim: restore re_sort on show_event to avoid first-frame jank * lpa: apply comma prime defaults inside TiciLPA.switch_profile * lpa: lazy-import Params to break circular import * lpa: treat +CME ERROR 13 (SIM failure) as non-eUICC in is_euicc * esim: show 'obtaining IP...' on non-eUICC path while connecting * Revert "lpa: treat +CME ERROR 13 (SIM failure) as non-eUICC in is_euicc" This reverts commit 475ca448ea914ff8f9892b4cbf4525d961d7618b. * esim: poll profiles every 5s * lpa: tighten Profile.is_comma to require both Webbing provider and comma BIN * lpa: fall back to '' instead of iccid prefix in display_name * esim: re-probe is_euicc each poll for runtime SIM swaps * esim_ui: rename EsimUIMici to EsimUI * esim: simplify cellular manager and profile UI - optimistic profile switch at click time so the active button no longer bounces back - unify LPA worker threads, refresh_profiles resets the poll timer - deterministic profile ordering on show - reuse wifi ForgetButton for delete, drop DeleteButton - construct CellularManager inside NetworkLayoutMici - drop comma prime param defaults from LPA.switch_profile (moved to a separate PR) * esim: drop eUICC state change log * ui: gate cellular settings on prime subscription * ui: disable eSIM management for full prime * ui: keep eSIM management disabled for full prime * ui: unify eSIM profile action buttons * ui: tighten eSIM profile action spacing * ui: place rename before delete in eSIM actions * esim: restrict individual profiles and process switch notifications * esim: defer poll after operations and confirm eUICC loss before clearing profiles * ui: allow cellular settings for unregistered and unpaired devices * ui: disable all profile switching for full prime * ui: simplify cellular access to not full prime * ui: make profile poll interval a CellularManager constant * ui: read modem state on the profile poll cadence * ui: scope eSIM profile colors to their class * ui: inline modem state read in cellular polling * ui: rely on hardware modem state fallback --- openpilot/common/esim/base.py | 7 + openpilot/common/hardware/base.py | 3 + openpilot/selfdrive/ui/lib/prime_state.py | 4 + .../mici/layouts/settings/network/__init__.py | 47 ++++ .../mici/layouts/settings/network/esim_ui.py | 230 ++++++++++++++++++ .../settings/network/network_layout.py | 24 +- openpilot/system/ui/lib/cellular_manager.py | 151 ++++++++++++ 7 files changed, 460 insertions(+), 6 deletions(-) create mode 100644 openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py create mode 100644 openpilot/system/ui/lib/cellular_manager.py diff --git a/openpilot/common/esim/base.py b/openpilot/common/esim/base.py index b783a630b2..472b523e2c 100644 --- a/openpilot/common/esim/base.py +++ b/openpilot/common/esim/base.py @@ -21,6 +21,13 @@ class Profile: def is_comma(self) -> bool: return self.provider == 'Webbing' and self.iccid.startswith('8985235') + @property + def display_name(self) -> str: + if self.is_comma: + return "comma prime" + name = self.nickname or self.provider or "" + return f"{name} (...{self.iccid[-4:]})" + class LPABase(ABC): @abstractmethod diff --git a/openpilot/common/hardware/base.py b/openpilot/common/hardware/base.py index 5d8f7770dc..7c8842a0ec 100644 --- a/openpilot/common/hardware/base.py +++ b/openpilot/common/hardware/base.py @@ -145,6 +145,9 @@ class HardwareBase(ABC): def get_modem_temperatures(self): return [] + def get_modem_state(self) -> dict: + return {} + def initialize_hardware(self): pass diff --git a/openpilot/selfdrive/ui/lib/prime_state.py b/openpilot/selfdrive/ui/lib/prime_state.py index b57380ac57..1fa87e31df 100644 --- a/openpilot/selfdrive/ui/lib/prime_state.py +++ b/openpilot/selfdrive/ui/lib/prime_state.py @@ -101,6 +101,10 @@ class PrimeState: with self._lock: return bool(self.prime_type > PrimeType.NONE) + def is_full_prime(self) -> bool: + with self._lock: + return self.prime_type > PrimeType.NONE and self.prime_type != PrimeType.LITE + def is_paired(self) -> bool: with self._lock: return self.prime_type > PrimeType.UNPAIRED diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py index ddbab4b478..4719feecb9 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -1,10 +1,57 @@ import pyray as rl +from openpilot.cereal import log from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiIcon from openpilot.selfdrive.ui.mici.widgets.button import BigButton +from openpilot.common.hardware import HARDWARE from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.cellular_manager import CellularManager from openpilot.system.ui.lib.wifi_manager import WifiManager, ConnectStatus, SecurityType, normalize_ssid +NetworkStrength = log.DeviceState.NetworkStrength +NetworkType = log.DeviceState.NetworkType + + +class EsimNetworkButton(BigButton): + def __init__(self, cellular_manager: CellularManager): + self._cellular_manager = cellular_manager + self._cell_icons = { + NetworkStrength.unknown: gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 64, 47), + NetworkStrength.poor: gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 64, 47), + NetworkStrength.moderate: gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 64, 47), + NetworkStrength.good: gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 64, 47), + NetworkStrength.great: gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 64, 47), + } + super().__init__("esim", "loading...", self._cell_icons[NetworkStrength.unknown], scroll=True) + + def _update_state(self): + super()._update_state() + self.set_enabled(self._cellular_manager.is_euicc is not False) + text, value, icon = self._compute_state() + self.set_text(text) + self.set_value(value) + self.set_icon(icon) + + def _compute_state(self): + cm = self._cellular_manager + none_icon = self._cell_icons[NetworkStrength.unknown] + ip = cm.modem_state.get("ip_address") or "obtaining IP..." + if cm.is_euicc is False: + iccid = cm.modem_state.get("iccid") or "" + if not iccid: + return "sim", "no sim", none_icon + return f"sim (...{iccid[-4:]})", ip, self._cell_icon() + + active = cm.active_profile + if active is None: + return "esim", "loading...", none_icon + return active.display_name, ip, self._cell_icon() + + def _cell_icon(self): + # read directly from HARDWARE so it reflects modem state even when wifi is the active connection + strength = HARDWARE.get_network_strength(NetworkType.cell4G) + return self._cell_icons.get(strength, self._cell_icons[NetworkStrength.unknown]) + class WifiNetworkButton(BigButton): def __init__(self, wifi_manager: WifiManager): diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py new file mode 100644 index 0000000000..0e115b232f --- /dev/null +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -0,0 +1,230 @@ +import pyray as rl +from collections.abc import Callable + +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR +from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigInputDialog, BigConfirmationDialog +from openpilot.common.esim.base import Profile +from openpilot.system.ui.lib.application import DEFAULT_TEXT_COLOR, FontWeight, MousePos, TextAlignment, gui_app +from openpilot.system.ui.lib.cellular_manager import CellularManager +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import gui_label +from openpilot.system.ui.widgets.scroller import NavScroller + + +class ProfileActionButton(Widget): + SIZE = 68 + MARGIN = 10 + HORIZONTAL_MARGIN = 4 + + def __init__(self, callback: Callable, delete: bool = False): + super().__init__() + self.set_click_callback(callback) + self._delete = delete + self._trash_txt = gui_app.texture("icons_mici/settings/network/new/trash.png", 25, 30) if delete else None + + self._bg_txt = gui_app.texture("icons_mici/buttons/button_circle.png", self.SIZE, self.SIZE) + self._bg_pressed_txt = gui_app.texture("icons_mici/buttons/button_circle_pressed.png", self.SIZE, self.SIZE) + self.set_rect(rl.Rectangle(0, 0, self.SIZE + self.HORIZONTAL_MARGIN * 2, self.SIZE + self.MARGIN * 2)) + + def _render(self, _): + bg_txt = self._bg_pressed_txt if self.is_pressed else self._bg_txt + rl.draw_texture_ex(bg_txt, (self._rect.x + (self._rect.width - self._bg_txt.width) / 2, + self._rect.y + (self._rect.height - self._bg_txt.height) / 2), 0, 1.0, rl.WHITE) + color = rl.Color(255, 105, 115, 255) if self._delete else DEFAULT_TEXT_COLOR + if not self.enabled: + color = rl.Color(color.r, color.g, color.b, 90) + if self._trash_txt: + rl.draw_texture_ex(self._trash_txt, (self._rect.x + (self._rect.width - self._trash_txt.width) / 2, + self._rect.y + (self._rect.height - self._trash_txt.height) / 2), 0, 1.0, color) + else: + gui_label(self._rect, "Aa", 30, color=color, alignment=TextAlignment.CENTER) + + +class EsimProfileButton(BigButton): + SUB_LABEL_DISABLED = rl.Color(255, 255, 255, int(255 * 0.585)) + CHECK_ICON_COLOR = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) + LABEL_PADDING = 98 + LABEL_WIDTH = 402 - 98 - 28 + SUB_LABEL_WIDTH = 402 - BigButton.LABEL_HORIZONTAL_PADDING * 2 + + def __init__(self, profile: Profile, cellular_manager: CellularManager, profiles_enabled: Callable[[], bool]): + self._cellular_manager = cellular_manager + self._profiles_enabled = profiles_enabled + super().__init__(profile.display_name, scroll=True) + + self._profile = profile + + self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 48, 36) + self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 48, 36) + self._check_txt = gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 32, 32) + self._comma_txt = gui_app.texture("icons_mici/settings/comma_icon.png", 36, 36) if profile.is_comma else None + + self._delete_btn = ProfileActionButton(self._on_delete, delete=True) + self._rename_btn = ProfileActionButton(self._on_rename) if not profile.is_comma else None + self._delete_btn.set_enabled(lambda: not self._locked and not self._cellular_manager.busy and self._show_delete_btn) + if self._rename_btn: + self._rename_btn.set_enabled(lambda: not self._locked and not self._cellular_manager.busy) + self.set_enabled(lambda: not self._profile.enabled and self._profiles_enabled() and not self._cellular_manager.busy) + self.update_profile(profile) + + @property + def profile(self) -> Profile: + return self._profile + + def update_profile(self, profile: Profile): + self._profile = profile + active = profile.enabled + self.set_text(profile.display_name) + self.set_value("active" if active else "switch") + + def _update_state(self): + super()._update_state() + self._sub_label.set_color(DEFAULT_TEXT_COLOR if self.enabled else self.SUB_LABEL_DISABLED) + self._sub_label.set_font_weight(FontWeight.SEMI_BOLD if self.enabled else FontWeight.ROMAN) + + @property + def _locked(self) -> bool: + return not self._profile.is_comma and not self._profiles_enabled() + + @property + def _show_delete_btn(self) -> bool: + return not self._profile.enabled and not self._profile.is_comma + + def _on_rename(self): + current = self._profile.nickname or "" + dlg = BigInputDialog("nickname", default_text=current, minimum_length=0, confirm_callback=self._on_nickname_entered) + gui_app.push_widget(dlg) + + def _on_delete(self): + icon = gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64) + gui_app.push_widget(BigConfirmationDialog("slide to delete", icon, self._delete_profile, red=True)) + + def _delete_profile(self): + if not self._locked and not self._cellular_manager.busy and self._show_delete_btn: + self._cellular_manager.delete_profile(self._profile.iccid) + + def _on_nickname_entered(self, nickname: str): + if not self._locked and not self._cellular_manager.busy: + self._cellular_manager.nickname_profile(self._profile.iccid, nickname.strip()) + + def _handle_mouse_release(self, mouse_pos: MousePos): + if self._show_delete_btn and rl.check_collision_point_rec(mouse_pos, self._delete_btn.rect): + return + if self._rename_btn is not None and rl.check_collision_point_rec(mouse_pos, self._rename_btn.rect): + return + super()._handle_mouse_release(mouse_pos) + + def _get_label_font_size(self): + return 48 + + def _draw_content(self, btn_y: float): + self._label.set_color(self.SUB_LABEL_DISABLED if self._locked else LABEL_COLOR) + label_rect = rl.Rectangle(self._rect.x + self.LABEL_PADDING, btn_y + self.LABEL_VERTICAL_PADDING, + self.LABEL_WIDTH, self._rect.height - self.LABEL_VERTICAL_PADDING * 2) + self._label.render(label_rect) + + active = self._profile.enabled + + if self.value: + sub_label_x = self._rect.x + self.LABEL_HORIZONTAL_PADDING + label_y = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING + action_w = self._rename_btn.rect.width if self._rename_btn is not None else 0 + action_w += self._delete_btn.rect.width if self._show_delete_btn else 0 + sub_label_w = self.SUB_LABEL_WIDTH - action_w + sub_label_height = self._sub_label.get_content_height(sub_label_w) + + if active: + check_y = int(label_y - sub_label_height + (sub_label_height - self._check_txt.height) / 2) + rl.draw_texture_ex(self._check_txt, rl.Vector2(sub_label_x, check_y), 0.0, 1.0, self.CHECK_ICON_COLOR) + sub_label_x += self._check_txt.width + 14 + + sub_label_rect = rl.Rectangle(sub_label_x, label_y - sub_label_height, sub_label_w, sub_label_height) + self._sub_label.render(sub_label_rect) + + if self._comma_txt: + rl.draw_texture_ex(self._comma_txt, (self._rect.x + 36, btn_y + 38), 0.0, 1.0, rl.WHITE) + else: + cell_icon = self._cell_full_txt if active else self._cell_none_txt + rl.draw_texture_ex(cell_icon, (self._rect.x + 30, btn_y + 38), 0.0, 1.0, rl.WHITE) + + btn_x = self._rect.x + self._rect.width - (ProfileActionButton.MARGIN - ProfileActionButton.HORIZONTAL_MARGIN) + btn_bottom = btn_y + self._rect.height + if self._show_delete_btn: + btn_x -= self._delete_btn.rect.width + self._delete_btn.render(rl.Rectangle( + btn_x, btn_bottom - self._delete_btn.rect.height, + self._delete_btn.rect.width, self._delete_btn.rect.height, + )) + if self._rename_btn is not None: + btn_x -= self._rename_btn.rect.width + self._rename_btn.render(rl.Rectangle( + btn_x, btn_bottom - self._rename_btn.rect.height, + self._rename_btn.rect.width, self._rename_btn.rect.height, + )) + + def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: + def action_pressed() -> bool: + return self._delete_btn.is_pressed or (self._rename_btn is not None and self._rename_btn.is_pressed) + super().set_touch_valid_callback(lambda: touch_callback() and not action_pressed()) + self._delete_btn.set_touch_valid_callback(touch_callback) + if self._rename_btn: + self._rename_btn.set_touch_valid_callback(touch_callback) + + +class EsimUI(NavScroller): + def __init__(self, cellular_manager: CellularManager, profiles_enabled: Callable[[], bool]): + super().__init__() + + self._cellular_manager = cellular_manager + self._profiles_enabled = profiles_enabled + + self._cellular_manager.on_profiles_updated = self._update_buttons + self._cellular_manager.on_operation_error = self._on_error + + def show_event(self): + super().show_event() + self._update_buttons(re_sort=True) + self._cellular_manager.refresh_profiles() + + def _update_buttons(self, re_sort: bool = False): + existing = {btn.profile.iccid: btn for btn in self._scroller.items} + buttons = [] + for profile in self._cellular_manager.profiles: + btn = existing.get(profile.iccid) + if btn is None: + btn = EsimProfileButton(profile, self._cellular_manager, self._profiles_enabled) + btn.set_click_callback(lambda btn=btn: self._on_profile_clicked(btn.profile)) + self._scroller.add_widget(btn) + else: + btn.update_profile(profile) + buttons.append(btn) + + if re_sort: + self._scroller.items[:] = sorted(buttons, key=lambda b: not b.profile.enabled) + else: + self._scroller.items[:] = [btn for btn in self._scroller.items if btn in buttons] + + def _move_profile_to_front(self, iccid: str | None, scroll: bool = False): + front_btn_idx = next((i for i, btn in enumerate(self._scroller.items) if btn.profile.iccid == iccid), None) if iccid else None + + if front_btn_idx is not None and front_btn_idx > 0: + self._scroller.move_item(front_btn_idx, 0) + + if scroll: + self._scroller.scroll_to(self._scroller.scroll_panel.get_offset(), smooth=True) + + def _update_state(self): + super()._update_state() + + active = self._cellular_manager.active_profile + self._move_profile_to_front(active.iccid if active else None) + + def _on_error(self, error: str): + dlg = BigDialog("esim error", error) + gui_app.push_widget(dlg) + + def _on_profile_clicked(self, profile: Profile): + if self._cellular_manager.busy or not self._profiles_enabled(): + return + self._cellular_manager.switch_profile(profile.iccid) + self._move_profile_to_front(profile.iccid, scroll=True) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py index 58b3d0c77d..d991ed5806 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py @@ -1,12 +1,13 @@ -from openpilot.system.ui.widgets.scroller import NavScroller -from openpilot.selfdrive.ui.mici.layouts.settings.network import WifiNetworkButton +from openpilot.selfdrive.ui.mici.layouts.settings.network import EsimNetworkButton, WifiNetworkButton +from openpilot.selfdrive.ui.mici.layouts.settings.network.esim_ui import EsimUI from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle, BigParamControl, BigToggle from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.selfdrive.ui.lib.prime_state import PrimeType from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.cellular_manager import CellularManager from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, MeteredType +from openpilot.system.ui.widgets.scroller import NavScroller class NetworkLayoutMici(NavScroller): @@ -64,6 +65,15 @@ class NetworkLayoutMici(NavScroller): self._wifi_button = WifiNetworkButton(self._wifi_manager) self._wifi_button.set_click_callback(lambda: gui_app.push_widget(self._wifi_ui)) + # ******** eSIM ******** + self._cellular_manager = CellularManager() + self._esim_ui = EsimUI( + self._cellular_manager, + lambda: not ui_state.prime_state.is_full_prime(), + ) + self._esim_button = EsimNetworkButton(self._cellular_manager) + self._esim_button.set_click_callback(lambda: gui_app.push_widget(self._esim_ui)) + # ******** Advanced settings ******** # ******** Roaming toggle ******** self._roaming_btn = BigParamControl("enable roaming", "GsmRoaming") @@ -78,6 +88,7 @@ class NetworkLayoutMici(NavScroller): # Main scroller ---------------------------------- self._scroller.add_widgets([ self._wifi_button, + self._esim_button, self._network_metered_btn, self._tethering_toggle_btn, self._tethering_password_btn, @@ -91,8 +102,7 @@ class NetworkLayoutMici(NavScroller): def _update_state(self): super()._update_state() - # If not using prime SIM, show GSM settings and enable IPv4 forwarding - show_cell_settings = ui_state.prime_state.get_type() in (PrimeType.NONE, PrimeType.LITE) + show_cell_settings = not ui_state.prime_state.is_full_prime() self._wifi_manager.set_ipv4_forward(show_cell_settings) self._roaming_btn.set_visible(show_cell_settings) self._apn_btn.set_visible(show_cell_settings) @@ -102,14 +112,16 @@ class NetworkLayoutMici(NavScroller): super().show_event() self._wifi_manager.set_active(True) - # Process wifi callbacks while at any point in the nav stack + # Process wifi and esim callbacks while at any point in the nav stack gui_app.add_nav_stack_tick(self._wifi_manager.process_callbacks) + gui_app.add_nav_stack_tick(self._cellular_manager.process_callbacks) def hide_event(self): super().hide_event() self._wifi_manager.set_active(False) gui_app.remove_nav_stack_tick(self._wifi_manager.process_callbacks) + gui_app.remove_nav_stack_tick(self._cellular_manager.process_callbacks) def _edit_apn(self): def update_apn(apn: str): diff --git a/openpilot/system/ui/lib/cellular_manager.py b/openpilot/system/ui/lib/cellular_manager.py new file mode 100644 index 0000000000..97f20b5bea --- /dev/null +++ b/openpilot/system/ui/lib/cellular_manager.py @@ -0,0 +1,151 @@ +import time +import threading +from collections.abc import Callable +from dataclasses import replace + +from openpilot.common.hardware import HARDWARE +from openpilot.common.swaglog import cloudlog +from openpilot.common.esim.base import LPABase, Profile +from openpilot.common.esim.esim import execute_and_process_notifications + + +class CellularManager: + PROFILE_POLL_INTERVAL_S = 5.0 + + def __init__(self): + self._lpa: LPABase | None = None + self._profiles: list[Profile] = [] + self._busy: bool = False + # re-probed every poll; SIM may be swapped at runtime on tray-accessible devices + self._is_euicc: bool | None = None + self._modem_state: dict = {} + + self._lock = threading.Lock() + self._callback_lock = threading.Lock() + self._callback_queue: list[Callable] = [] + + self.on_profiles_updated: Callable[[], None] | None = None + self.on_operation_error: Callable[[str], None] | None = None + + self._last_profile_poll: float = 0.0 + self._polling: bool = False + + @property + def modem_state(self) -> dict: + return self._modem_state + + def process_callbacks(self): + with self._callback_lock: + to_run, self._callback_queue = self._callback_queue, [] + for cb in to_run: + cb() + + if not self._busy and not self._polling and time.monotonic() - self._last_profile_poll >= self.PROFILE_POLL_INTERVAL_S: + self._last_profile_poll = time.monotonic() + self._modem_state = HARDWARE.get_modem_state() + self._poll_profiles() + + @property + def profiles(self) -> list[Profile]: + return self._profiles + + @property + def active_profile(self) -> Profile | None: + return next((p for p in self._profiles if p.enabled), None) + + @property + def busy(self) -> bool: + return self._busy + + @property + def is_euicc(self) -> bool | None: + return self._is_euicc + + def _ensure_lpa(self) -> LPABase: + if self._lpa is None: + self._lpa = HARDWARE.get_sim_lpa() + return self._lpa + + def _enqueue(self, cb: Callable): + with self._callback_lock: + self._callback_queue.append(cb) + + def _stop_polling(self): + self._polling = False + + def _set_profiles(self, profiles: list[Profile]): + self._profiles = profiles + if self.on_profiles_updated: + self.on_profiles_updated() + + def _finish(self, profiles: list[Profile] | None = None, error: str | None = None): + self._busy = False + # defer the next poll a full interval; the eUICC can briefly report stale state after an operation + self._last_profile_poll = time.monotonic() + if profiles is not None: + self._set_profiles(profiles) + if error is not None: + self.refresh_profiles() + if self.on_operation_error: + self.on_operation_error(error) + + def _run_operation(self, fn: Callable[[LPABase], None], error_msg: str, relist: bool = True): + self._busy = True + + def worker(): + try: + with self._lock: + lpa = self._ensure_lpa() + fn(lpa) + profiles = lpa.list_profiles() if relist else None + self._enqueue(lambda: self._finish(profiles=profiles)) + except Exception as e: + cloudlog.exception(error_msg) + err = str(e) + self._enqueue(lambda: self._finish(error=err)) + + threading.Thread(target=worker, daemon=True).start() + + def refresh_profiles(self): + # next process_callbacks tick polls, respecting busy/polling guards + self._last_profile_poll = 0.0 + + def _poll_profiles(self): + self._polling = True + def worker(): + try: + with self._lock: + lpa = self._ensure_lpa() + is_euicc = lpa.is_euicc() + profiles = lpa.list_profiles() if is_euicc else [] + self._enqueue(lambda: self._finish_poll(is_euicc, profiles)) + except Exception: + cloudlog.exception("Failed to poll eSIM profiles") + self._enqueue(self._stop_polling) + + threading.Thread(target=worker, daemon=True).start() + + def _finish_poll(self, is_euicc: bool, profiles: list[Profile]): + self._polling = False + if self._busy: + return + if not is_euicc and self._is_euicc: + # is_euicc() is False on any AT error (e.g. SIM busy during a profile refresh); confirm on the next poll + self._is_euicc = None + return + self._is_euicc = is_euicc + self._set_profiles(profiles) + + def switch_profile(self, iccid: str): + def switch(lpa: LPABase): + execute_and_process_notifications(lpa, lambda: lpa.switch_profile(iccid)) + + # optimistic: list_profiles() can briefly return stale enabled state after a switch + self._set_profiles([replace(p, enabled=(p.iccid == iccid)) for p in self._profiles]) + self._run_operation(switch, "Failed to switch eSIM profile", relist=False) + + def delete_profile(self, iccid: str): + self._run_operation(lambda lpa: lpa.delete_profile(iccid), "Failed to delete eSIM profile") + + def nickname_profile(self, iccid: str, nickname: str): + self._run_operation(lambda lpa: lpa.nickname_profile(iccid, nickname), "Failed to update eSIM profile nickname") From 9946c521f78e3b2b2896d9cd1af53ecefa700c79 Mon Sep 17 00:00:00 2001 From: Pramish <139780421+pramishpy@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:16:21 -0500 Subject: [PATCH 036/122] modeld: build DM warp and model JITs for all camera configs (#38767) * modeld: build DM warp and model JITs for all camera configs In #38684, camera_configs on comma_arm64 was restricted to only the host device running SCons (selecting either _os_fisheye or _ar_ox_fisheye). However, prebuilt release and nightly bundles are built on TICI/tizi runners in Jenkins and distributed to both Comma 3/3X (TICI) and Comma 4 (MICI) devices. Because the build machine was TICI, dm_warp_1344x760_tinygrad.pkl was omitted from the packaged bundle, causing dmonitoringmodeld to fail with FileNotFoundError on Comma 4 and leaving driver-monitoring calibration stuck at 0%. Restore CAMERA_CONFIGS so both camera resolutions (1928x1208 and 1344x760) are built for all platforms. Fixes #38762 * simplify --------- Co-authored-by: ZwX1616 --- openpilot/selfdrive/modeld/SConscript | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index af10467529..41e9e05d0c 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -23,14 +23,12 @@ def estimate_pickle_max_size(onnx_size): # the ONNX size. Overestimating only adds an empty trailing chunk. return 2.0 * onnx_size + 10 * 1024 * 1024 +camera_configs = [(c.width, c.height) for c in (_ar_ox_fisheye, _os_fisheye)] + if arch == 'comma_arm64': - from openpilot.common.hardware import HARDWARE - camera = _os_fisheye if HARDWARE.get_device_type() == "mici" else _ar_ox_fisheye - camera_configs = [(camera.width, camera.height)] tg_backend = 'QCOM' tg_flags = f'DEV={tg_backend} IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1' else: - camera_configs = [(c.width, c.height) for c in (_ar_ox_fisheye, _os_fisheye)] tg_backend = 'CPU' tg_flags = f'DEV=CPU' if arch == 'Darwin' else 'DEV=CPU:LLVM' From 7bfe6ba4d0c70744321b698657c745f6f677c687 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:21:03 -0700 Subject: [PATCH 037/122] pandad: allow disabling driver camera IR offroad (#38812) --- openpilot/common/params_keys.h | 1 + openpilot/selfdrive/pandad/pandad.cc | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index ba6eae3dd5..4b87356aca 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -28,6 +28,7 @@ inline static std::unordered_map keys = { {"ControlsReady", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"CurrentBootlog", {PERSISTENT, STRING}}, {"CurrentRoute", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, STRING}}, + {"DisableDriverCameraIR", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"DisableLogging", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"DisablePowerDown", {PERSISTENT, BOOL}}, {"DisableUpdates", {PERSISTENT, BOOL}}, diff --git a/openpilot/selfdrive/pandad/pandad.cc b/openpilot/selfdrive/pandad/pandad.cc index d0b65586aa..8b54ca6eaf 100644 --- a/openpilot/selfdrive/pandad/pandad.cc +++ b/openpilot/selfdrive/pandad/pandad.cc @@ -328,8 +328,8 @@ void process_peripheral_state(Panda *panda, PubMaster *pm, bool no_fan_control, } } - // Disable IR on input timeout - if (nanos_since_boot() - last_cabin_camera_t > 1e9) { + // Disable IR on input timeout or when requested offroad. + if (nanos_since_boot() - last_cabin_camera_t > 1e9 || (!is_onroad && params.getBool("DisableDriverCameraIR"))) { ir_pwr = 0; } From 60d8ae2d4f1a55a44188b3ba0b31a6e8d07122c5 Mon Sep 17 00:00:00 2001 From: ZwX1616 Date: Tue, 8 Sep 2026 15:27:11 -0700 Subject: [PATCH 038/122] modeld/SConscript: fix chunk_targets estimate (#38821) --- openpilot/selfdrive/modeld/SConscript | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index 41e9e05d0c..9a10fed585 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -81,7 +81,7 @@ for chestnut in [False, True] if CHESTNUT else [False]: f'--onnx {File(f"models/{file_prefix}driving_supercombo.onnx").abspath} ' f'--output {target_pkl_path} --frame-skip {frame_skip}') onnx_sizes_sum = sum(os.path.getsize(f) for f in driving_onnx_deps) - chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum)) + chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum * len(camera_configs))) def do_compile(target, source, env, command=cmd, pkl=target_pkl_path, chunks=chunk_targets): from openpilot.system.hardware.chestnut.flash import link_up # chestnut can enumerate before its PCIe link is up due to varying 12V power behavior across cars From 23d0474b95aa8f903e7561195c8aa586029773c3 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Tue, 8 Sep 2026 15:37:12 -0700 Subject: [PATCH 039/122] compile big model in jenkins CI (#38822) compile chestnut in ci --- Jenkinsfile | 5 +++++ openpilot/selfdrive/test/chestnut.sh | 7 +++++++ openpilot/selfdrive/test/setup_device_ci.sh | 6 ++++++ 3 files changed, 18 insertions(+) create mode 100755 openpilot/selfdrive/test/chestnut.sh diff --git a/Jenkinsfile b/Jenkinsfile index af33edb821..f1450c304b 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -252,6 +252,11 @@ node { step("test amp", "./openpilot/common/hardware/comma/tests/test_amplifier.py"), ]) }, + 'chestnut': { + deviceStage("chestnut", "mici-chestnut-ci", ["UNSAFE=1", "CHESTNUT=1"], [ + step("compile big model", "./openpilot/selfdrive/test/chestnut.sh"), + ]) + }, ) } diff --git a/openpilot/selfdrive/test/chestnut.sh b/openpilot/selfdrive/test/chestnut.sh new file mode 100755 index 0000000000..747a2b275b --- /dev/null +++ b/openpilot/selfdrive/test/chestnut.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -e + +TARGET=openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl.chunkmanifest +rm -f "$TARGET" +scons --cache-disable "$TARGET" +test -s "$TARGET" diff --git a/openpilot/selfdrive/test/setup_device_ci.sh b/openpilot/selfdrive/test/setup_device_ci.sh index 60631ce037..8558a38bc9 100755 --- a/openpilot/selfdrive/test/setup_device_ci.sh +++ b/openpilot/selfdrive/test/setup_device_ci.sh @@ -58,6 +58,12 @@ chmod +x $CONTINUE_PATH export GIT_LFS_SKIP_SMUDGE=1 pull_lfs() { + if [ -n "${CHESTNUT:-}" ] + then + git lfs pull --exclude='' + return + fi + # The big driving model is not used on these devices yet. Keep its pointer in # the worktree, but don't download or copy the 1.8 GB LFS object. LFS_EXCLUDE="openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx" From 96be8c7eb57740f429da75932d57ffe0ac83811d Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Tue, 8 Sep 2026 16:46:19 -0700 Subject: [PATCH 040/122] check dependency footprint (#38825) --- scripts/lint/check_dependencies.py | 37 ++++++++++++++++++++++++++++++ scripts/lint/lint.sh | 2 ++ 2 files changed, 39 insertions(+) create mode 100755 scripts/lint/check_dependencies.py diff --git a/scripts/lint/check_dependencies.py b/scripts/lint/check_dependencies.py new file mode 100755 index 0000000000..f3707035b3 --- /dev/null +++ b/scripts/lint/check_dependencies.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +import sys +import tomllib +import importlib.metadata +from pathlib import Path + + +def main() -> int: + if sys.prefix == sys.base_prefix: + print("Dependency checks require a virtual environment. Run tools/op.sh setup first.") + return 1 + + project = tomllib.loads((Path(__file__).resolve().parents[2] / "pyproject.toml").read_text())["project"] + direct = len(project["dependencies"]) + sum(len(deps) for deps in project["optional-dependencies"].values()) + # Count each installed package once, including transitive dependencies and all extras. + packages = {dist.metadata["Name"].lower().replace("_", "-") for dist in importlib.metadata.distributions()} + # Logical file sizes avoid filesystem block-size differences. Don't follow links to the interpreter or source tree. + size = sum(path.stat().st_size for path in Path(sys.prefix).rglob("*") if not path.is_symlink() and path.is_file()) + + """ + This test prevents our depency footprint from growing. + These values are *not* intended to be increased, and we + expect to strictly drive these down over time. + """ + failed = False + for name, value, limit in ( + ("Direct dependencies (all extras)", direct, 37), + ("Total dependencies", len(packages), 65), + ("Venv size (MiB)", size / 1024**2, 550), + ): + print(f"{name}: {value:g} (limit: {limit})") + failed |= value > limit + return int(failed) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/lint/lint.sh b/scripts/lint/lint.sh index bfd93f23a9..fe3644d670 100755 --- a/scripts/lint/lint.sh +++ b/scripts/lint/lint.sh @@ -46,6 +46,7 @@ function run_tests() { PYTHON_FILES=$2 run "ruff" ruff check openpilot --quiet + run "check_dependencies" python3 $DIR/check_dependencies.py run "check_indentation" $DIR/check_indentation.py $PYTHON_FILES run "check_added_large_files" $DIR/check_added_large_files.py --maxkb=120 $ALL_FILES run "check_shebang_scripts_are_executable" $DIR/check_shebang_scripts_are_executable.py $ALL_FILES @@ -67,6 +68,7 @@ function help() { echo "" echo -e "${BOLD}${UNDERLINE}Tests:${NC}" echo -e " ${BOLD}ruff${NC}" + echo -e " ${BOLD}check_dependencies${NC}" echo -e " ${BOLD}check_indentation${NC}" echo -e " ${BOLD}ty${NC}" echo -e " ${BOLD}codespell${NC}" From f23e65768cd2b1bb59939990edef21d9be4ea860 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Tue, 8 Sep 2026 17:06:12 -0700 Subject: [PATCH 041/122] ui: resize status icons (#38749) * ui: resize status icons * Update orange GPU icon to 108px height * Trigger CI after LFS upload * ci --- .../assets/icons_mici/chestnut_orange.png | 4 ++-- openpilot/selfdrive/ui/mici/layouts/home.py | 24 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/openpilot/selfdrive/assets/icons_mici/chestnut_orange.png b/openpilot/selfdrive/assets/icons_mici/chestnut_orange.png index d3982abf87..41316e610c 100644 --- a/openpilot/selfdrive/assets/icons_mici/chestnut_orange.png +++ b/openpilot/selfdrive/assets/icons_mici/chestnut_orange.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:845c40ff0d37612e8f2f482a36845744b5ae91ce2fcfc8117990d7d278b59820 -size 13079 +oid sha256:bf97a6738b294ac0aed9b2d075916cee0b7d3215bcd23381760900ede6a92748 +size 13256 diff --git a/openpilot/selfdrive/ui/mici/layouts/home.py b/openpilot/selfdrive/ui/mici/layouts/home.py index 553fa37f8c..9517701b50 100644 --- a/openpilot/selfdrive/ui/mici/layouts/home.py +++ b/openpilot/selfdrive/ui/mici/layouts/home.py @@ -77,21 +77,21 @@ class AlertsPill(Widget): class NetworkIcon(Widget): def __init__(self): super().__init__() - self.set_rect(rl.Rectangle(0, 0, 54, 44)) # max size of all icons + self.set_rect(rl.Rectangle(0, 0, 60, 47)) # max size of all icons self._net_type = NetworkType.none self._net_strength = 0 - self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 50, 44) - self._wifi_none_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_none.png", 50, 37) - self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 50, 37) - self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 50, 37) - self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 50, 37) + self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 54, 47) + self._wifi_none_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_none.png", 54, 40) + self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 54, 40) + self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 54, 40) + self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 54, 40) - self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 54, 36) - self._cell_low_txt = gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 54, 36) - self._cell_medium_txt = gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 54, 36) - self._cell_high_txt = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 54, 36) - self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 54, 36) + self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 60, 40) + self._cell_low_txt = gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 60, 40) + self._cell_medium_txt = gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 60, 40) + self._cell_high_txt = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 60, 40) + self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 60, 40) def _update_state(self): device_state = ui_state.sm['deviceState'] @@ -141,7 +141,7 @@ class MiciHomeLayout(Widget): self._experimental_icon = IconWidget("icons_mici/experimental_mode.png", (48, 48)) self._usb_icon = IconWidget("icons_mici/usb.png", (62, 40)) - self._chestnut_icon = IconWidget("icons_mici/chestnut_green.png", (68, 40)) + self._chestnut_icon = IconWidget("icons_mici/chestnut_green.png", (54, 40)) self._chestnut_loading_icon = IconWidget("icons_mici/chestnut.png", (68, 40)) self._chestnut_failed_icon = IconWidget("icons_mici/chestnut_orange.png", (68, 40)) self._mic_icon = IconWidget("icons_mici/microphone.png", (32, 46)) From dd4615850cd8e265ba25c16fa14ec98368599c64 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Tue, 8 Sep 2026 18:05:03 -0700 Subject: [PATCH 042/122] replay chestnut in CI (#38826) run model replay on chestnut in CI --- Jenkinsfile | 3 ++- openpilot/selfdrive/test/chestnut.sh | 2 +- openpilot/selfdrive/test/process_replay/model_replay.py | 9 +++++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index f1450c304b..b6b3eec5e3 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -254,7 +254,8 @@ node { }, 'chestnut': { deviceStage("chestnut", "mici-chestnut-ci", ["UNSAFE=1", "CHESTNUT=1"], [ - step("compile big model", "./openpilot/selfdrive/test/chestnut.sh"), + step("build", "./openpilot/selfdrive/test/chestnut.sh"), + step("model replay", "openpilot/selfdrive/test/process_replay/model_replay.py --chestnut"), ]) }, diff --git a/openpilot/selfdrive/test/chestnut.sh b/openpilot/selfdrive/test/chestnut.sh index 747a2b275b..1ffd12c9e4 100755 --- a/openpilot/selfdrive/test/chestnut.sh +++ b/openpilot/selfdrive/test/chestnut.sh @@ -3,5 +3,5 @@ set -e TARGET=openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl.chunkmanifest rm -f "$TARGET" -scons --cache-disable "$TARGET" +SCONSFLAGS="-j2 --cache-disable" ./openpilot/system/manager/build.py test -s "$TARGET" diff --git a/openpilot/selfdrive/test/process_replay/model_replay.py b/openpilot/selfdrive/test/process_replay/model_replay.py index 927c9b38f1..47ca3fb104 100755 --- a/openpilot/selfdrive/test/process_replay/model_replay.py +++ b/openpilot/selfdrive/test/process_replay/model_replay.py @@ -25,6 +25,8 @@ SEGMENT = 4 START_FRAME = 0 END_FRAME = 60 +CHESTNUT = "--chestnut" in sys.argv + SEND_EXTRA_INPUTS = bool(int(os.getenv("SEND_EXTRA_INPUTS", "0"))) DATA_TOKEN = os.getenv("CI_ARTIFACTS_TOKEN","") @@ -39,7 +41,7 @@ EXEC_TIMINGS = [ ] def get_log_fn(test_route, ref="master"): - return f"{test_route}_model_tici_{ref}.zst" + return f"{test_route}_model_{'chestnut' if CHESTNUT else 'tici'}_{ref}.zst" def plot(proposed, master, title, tmp): proposed = list(proposed) @@ -170,6 +172,8 @@ def model_replay(lr, frs): msgs = modeld_msgs + dmonitoringmodeld_msgs chestnut = any(m.modelV2.big for m in modeld_msgs if m.which() == "modelV2") + if CHESTNUT: + assert chestnut and all(m.modelV2.big for m in modeld_msgs if m.which() == "modelV2"), "Chestnut replay must run the big model without fallback" header = ['model', 'max instant', 'max instant allowed', 'average', 'max average allowed', 'test result'] rows = [] @@ -285,7 +289,8 @@ if __name__ == "__main__": diff_short, diff_long, failed = format_diff(results, log_paths, 'master') if "CI" in os.environ: - comment_replay_report(log_msgs, cmp_log, log_msgs) + if not CHESTNUT: + comment_replay_report(log_msgs, cmp_log, log_msgs) failed = False print(diff_long) print('-------------\n'*5) From 1fb3edb7dd568b3ba0e304ac60d91d38a85a1b76 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:53:33 -0700 Subject: [PATCH 043/122] qrcode: test alignment pattern positions (#38829) --- openpilot/common/tests/test_qrcode.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 openpilot/common/tests/test_qrcode.py diff --git a/openpilot/common/tests/test_qrcode.py b/openpilot/common/tests/test_qrcode.py new file mode 100644 index 0000000000..2ff5b0846c --- /dev/null +++ b/openpilot/common/tests/test_qrcode.py @@ -0,0 +1,14 @@ +import unittest + +from openpilot.common import qrcode as qr +from openpilot.common.test import OpenpilotTestCase + + +class TestQRCode(OpenpilotTestCase): + def test_alignment_positions(self): + assert qr._alignment_positions(7) == [6, 22, 38] + assert qr._alignment_positions(40) == [6, 30, 58, 86, 114, 142, 170] + + @unittest.expectedFailure # the step between patterns rounds the wrong way for version 32 + def test_alignment_positions_v32(self): + assert qr._alignment_positions(32) == [6, 34, 60, 86, 112, 138] From a4a24d5d87a90f01f69665da623d4e027b923edb Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:05:18 -0700 Subject: [PATCH 044/122] qrcode: fix alignment pattern positions for version 32 (#38827) The closed-form step between alignment patterns rounds the wrong way for version 32 (28 instead of 26). Use the form that is right for every version. The encoder only emits versions 1-20, so its output is unchanged. --- openpilot/common/qrcode.py | 2 +- openpilot/common/tests/test_qrcode.py | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/openpilot/common/qrcode.py b/openpilot/common/qrcode.py index b6b345c227..f77d8a29ee 100644 --- a/openpilot/common/qrcode.py +++ b/openpilot/common/qrcode.py @@ -108,7 +108,7 @@ def _alignment_positions(version: int) -> list[int]: if version == 1: return [] count = version // 7 + 2 - step = ((version * 4 + count * 2 + 1) // (count * 2 - 2)) * 2 + step = (version * 8 + count * 3 + 5) // (count * 4 - 4) * 2 return [6] + [version * 4 + 10 - step * i for i in range(count - 1)][::-1] diff --git a/openpilot/common/tests/test_qrcode.py b/openpilot/common/tests/test_qrcode.py index 2ff5b0846c..6c7d8c6f47 100644 --- a/openpilot/common/tests/test_qrcode.py +++ b/openpilot/common/tests/test_qrcode.py @@ -1,5 +1,3 @@ -import unittest - from openpilot.common import qrcode as qr from openpilot.common.test import OpenpilotTestCase @@ -7,8 +5,5 @@ from openpilot.common.test import OpenpilotTestCase class TestQRCode(OpenpilotTestCase): def test_alignment_positions(self): assert qr._alignment_positions(7) == [6, 22, 38] - assert qr._alignment_positions(40) == [6, 30, 58, 86, 114, 142, 170] - - @unittest.expectedFailure # the step between patterns rounds the wrong way for version 32 - def test_alignment_positions_v32(self): assert qr._alignment_positions(32) == [6, 34, 60, 86, 112, 138] + assert qr._alignment_positions(40) == [6, 30, 58, 86, 114, 142, 170] From ef769c173d9d5034114280f0b98a351c80ebcc3e Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:15:39 -0700 Subject: [PATCH 045/122] qrcode: generalize EC, format, and GF tables (#38828) Replace the level-L-only Reed-Solomon parameters, the single hardcoded format word, and the bitwise GF(256) multiply with the full EC table, the 32 format words, and log/antilog tables. Block splitting and interleaving move into _block_lengths and _interleaved. This is the structure a decoder needs to read any version, level, and mask; the encoder's module matrices are unchanged and test_encoder pins them. --- openpilot/common/qrcode.py | 91 ++++++++++++++++++++++++-------------- 1 file changed, 58 insertions(+), 33 deletions(-) diff --git a/openpilot/common/qrcode.py b/openpilot/common/qrcode.py index f77d8a29ee..634beb94e0 100644 --- a/openpilot/common/qrcode.py +++ b/openpilot/common/qrcode.py @@ -4,13 +4,41 @@ import numpy as np import pyray as rl -# Indexes are QR versions. These are the only two Reed-Solomon parameters needed -# for error-correction level L. -_ECC_LEN = (0, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28) -_NUM_BLOCKS = (0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8) +# (ec codewords per block, block count) for levels L, M, Q, H, versions 1-40 +_EC = [ + ((7, 1), (10, 1), (13, 1), (17, 1)), ((10, 1), (16, 1), (22, 1), (28, 1)), ((15, 1), (26, 1), (18, 2), (22, 2)), + ((20, 1), (18, 2), (26, 2), (16, 4)), ((26, 1), (24, 2), (18, 4), (22, 4)), ((18, 2), (16, 4), (24, 4), (28, 4)), + ((20, 2), (18, 4), (18, 6), (26, 5)), ((24, 2), (22, 4), (22, 6), (26, 6)), ((30, 2), (22, 5), (20, 8), (24, 8)), + ((18, 4), (26, 5), (24, 8), (28, 8)), ((20, 4), (30, 5), (28, 8), (24, 11)), ((24, 4), (22, 8), (26, 10), (28, 11)), + ((26, 4), (22, 9), (24, 12), (22, 16)), ((30, 4), (24, 9), (20, 16), (24, 16)), ((22, 6), (24, 10), (30, 12), (24, 18)), + ((24, 6), (28, 10), (24, 17), (30, 16)), ((28, 6), (28, 11), (28, 16), (28, 19)), ((30, 6), (26, 13), (28, 18), (28, 21)), + ((28, 7), (26, 14), (26, 21), (26, 25)), ((28, 8), (26, 16), (30, 20), (28, 25)), ((28, 8), (26, 17), (28, 23), (30, 25)), + ((28, 9), (28, 17), (30, 23), (24, 34)), ((30, 9), (28, 18), (30, 25), (30, 30)), ((30, 10), (28, 20), (30, 27), (30, 32)), + ((26, 12), (28, 21), (30, 29), (30, 35)), ((28, 12), (28, 23), (28, 34), (30, 37)), ((30, 12), (28, 25), (30, 34), (30, 40)), + ((30, 13), (28, 26), (30, 35), (30, 42)), ((30, 14), (28, 28), (30, 38), (30, 45)), ((30, 15), (28, 29), (30, 40), (30, 48)), + ((30, 16), (28, 31), (30, 43), (30, 51)), ((30, 17), (28, 33), (30, 45), (30, 54)), ((30, 18), (28, 35), (30, 48), (30, 57)), + ((30, 19), (28, 37), (30, 51), (30, 60)), ((30, 19), (28, 38), (30, 53), (30, 63)), ((30, 20), (28, 40), (30, 56), (30, 66)), + ((30, 21), (28, 43), (30, 59), (30, 70)), ((30, 22), (28, 45), (30, 62), (30, 74)), ((30, 24), (28, 47), (30, 65), (30, 77)), + ((30, 25), (28, 49), (30, 68), (30, 81)), +] -# 15 format-info bits for level L (01) with mask 0: ((0x08 << 10) | bch_remainder) ^ 0x5412 -_FORMAT_BITS = 0b111011111000100 +# GF(256) with the QR polynomial x^8 + x^4 + x^3 + x^2 + 1: powers of alpha and their logs +_EXP = [1] +for _ in range(254): + _EXP.append(_EXP[-1] << 1 ^ (0x11D if _EXP[-1] & 0x80 else 0)) +_LOG = {v: i for i, v in enumerate(_EXP)} + + +def _bch_format(data: int) -> int: + v = data << 10 + for shift in range(14, 9, -1): + if v >> shift & 1: + v ^= 0x537 << (shift - 10) + return (data << 10 | v) ^ 0x5412 + + +# 15-bit format info indexed by (level bits << 3 | mask). Level bits: L=01, M=00, Q=11, H=10. +_FORMATS = [_bch_format(d) for d in range(32)] def _raw_modules(version: int) -> int: @@ -21,8 +49,24 @@ def _raw_modules(version: int) -> int: return result - (36 if version >= 7 else 0) +def _block_lengths(version: int, level: int) -> list[int]: + """Data codewords per Reed-Solomon block. The last blocks may be one longer.""" + ec, nblocks = _EC[version - 1][level] + total = _raw_modules(version) // 8 - ec * nblocks + return [total // nblocks + (i >= nblocks - total % nblocks) for i in range(nblocks)] + + +def _interleaved(version: int, level: int) -> list[tuple[int, int]]: + """(block, index within block) of each transmitted codeword: data column-major, then ECC column-major.""" + ec, nblocks = _EC[version - 1][level] + lens = _block_lengths(version, level) + data = [(b, i) for i in range(max(lens)) for b in range(nblocks) if i < lens[b]] + ecc = [(b, lens[b] + i) for i in range(ec) for b in range(nblocks)] + return data + ecc + + def _capacity(version: int) -> int: - return _raw_modules(version) // 8 - _ECC_LEN[version] * _NUM_BLOCKS[version] + return sum(_block_lengths(version, 0)) def _append_bits(bits: list[int], value: int, length: int) -> None: @@ -49,37 +93,18 @@ def _data_codewords(data: bytes, version: int) -> bytes: def _codewords(data: bytes, version: int) -> bytes: """Split data codewords into Reed-Solomon blocks and interleave data + ECC.""" data = _data_codewords(data, version) - num_blocks = _NUM_BLOCKS[version] - ecc_len = _ECC_LEN[version] - raw_codewords = _raw_modules(version) // 8 - short_len = raw_codewords // num_blocks - num_short = num_blocks - raw_codewords % num_blocks - divisor = _divisor(ecc_len) - blocks: list[tuple[bytes, bytes]] = [] + divisor = _divisor(_EC[version - 1][0][0]) + blocks = [] offset = 0 - for i in range(num_blocks): - length = short_len - ecc_len + (0 if i < num_short else 1) + for length in _block_lengths(version, 0): block = data[offset:offset + length] - blocks.append((block, _remainder(block, divisor))) + blocks.append(block + _remainder(block, divisor)) offset += length - result = bytearray() - for i in range(short_len - ecc_len + 1): - for block, _ in blocks: - result.extend(block[i:i + 1]) - for i in range(ecc_len): - for _, ecc in blocks: - result.append(ecc[i]) - return bytes(result) + return bytes(blocks[b][i] for b, i in _interleaved(version, 0)) def _multiply(x: int, y: int) -> int: - result = 0 - for _ in range(8): - result = (result << 1) ^ (0x11D if result & 0x80 else 0) - if y & 0x80: - result ^= x - y <<= 1 - return result + return _EXP[(_LOG[x] + _LOG[y]) % 255] if x and y else 0 def _divisor(degree: int) -> bytes: @@ -171,7 +196,7 @@ class _Qr: def _format(self) -> None: for i in range(15): - bit = ((_FORMAT_BITS >> i) & 1) != 0 + bit = ((_FORMATS[1 << 3 | 0] >> i) & 1) != 0 # level L, mask 0 y_pos = i if i < 6 else i + 1 if i < 8 else self.size - 15 + i self._set_function(8, y_pos, bit) x_pos = self.size - 1 - i if i < 8 else 15 - i if i < 9 else 14 - i From 66b4dbe2a13fee7868f737ca73f1559920d098dd Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:23:55 -0700 Subject: [PATCH 046/122] qrcode: add QR decoder (#38814) * qrcode: add QR decoder Decodes a QR code from a grayscale image or a module matrix: adaptive binarization, finder pattern search by run ratios, perspective sampling with alignment pattern refinement, format info, unmasking, block de-interleaving, Reed-Solomon correction, and numeric, alphanumeric, byte (with ECI), and Kanji segments. Fixtures are packed matrices from python-qrcode 8.2 covering every version and level, plus Segno 1.6.6 matrices for the ECI cases. * qrcode: tune decoder for the device Measured on a comma mici with real cabin frames downsampled to 672x380, the size the eSIM screen scans at. Per frame: 30 ms -> 8 ms with no code in view, 42 ms -> 13 ms with a code. - binarize: fixed two-radius fill from one integral image instead of an iterative outward fill (a dark cabin is almost all flat tiles), tile stats on a contiguous layout, flat tiles decided whole so sensor noise cannot become speckle, uint8 threshold compare - finder search: scan every 4th row, build column runs only at candidate columns - alignment search starts at a 2-module radius - Reed-Solomon syndromes through table lookups, placement order cached * qrcode: simplify decoder Fold the function-module mask into _data_coords and the format coordinates into _read_format, evaluate masks directly on the data coordinates, compact the ECI, numeric, and alphanumeric parsing, and flatten the retry loop in decode. No behavior change. --- openpilot/common/qrcode.py | 498 +++++++++++++++++- openpilot/common/tests/fixtures/qrcode_0.npz | Bin 0 -> 67335 bytes openpilot/common/tests/fixtures/qrcode_1.npz | Bin 0 -> 64985 bytes openpilot/common/tests/fixtures/qrcode_2.npz | Bin 0 -> 65540 bytes openpilot/common/tests/fixtures/qrcode_3.npz | Bin 0 -> 65135 bytes .../common/tests/fixtures/qrcode_eci.npz | Bin 0 -> 1810 bytes openpilot/common/tests/test_qrcode.py | 169 ++++++ 7 files changed, 666 insertions(+), 1 deletion(-) create mode 100644 openpilot/common/tests/fixtures/qrcode_0.npz create mode 100644 openpilot/common/tests/fixtures/qrcode_1.npz create mode 100644 openpilot/common/tests/fixtures/qrcode_2.npz create mode 100644 openpilot/common/tests/fixtures/qrcode_3.npz create mode 100644 openpilot/common/tests/fixtures/qrcode_eci.npz diff --git a/openpilot/common/qrcode.py b/openpilot/common/qrcode.py index 634beb94e0..895daece39 100644 --- a/openpilot/common/qrcode.py +++ b/openpilot/common/qrcode.py @@ -1,4 +1,7 @@ -"""Small QR encoder for the UI's byte-mode, error-correction-level-L codes.""" +"""QR code encoding, decoding, and UI textures.""" + +import functools +import itertools import numpy as np import pyray as rl @@ -241,3 +244,496 @@ def make_texture(data: str, inverted: bool = False) -> rl.Texture: rl_image.mipmaps = 1 rl_image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8 return rl.load_texture_from_image(rl_image) + + +# ---- Symbol structure for decoding ---- + + +class QRError(Exception): + pass + + +_LEVELS = (1, 0, 3, 2) # format info level bits -> column in _EC + +_MASKS = [ + lambda i, j: (i + j) % 2 == 0, + lambda i, j: i % 2 == 0, + lambda i, j: j % 3 == 0, + lambda i, j: (i + j) % 3 == 0, + lambda i, j: (i // 2 + j // 3) % 2 == 0, + lambda i, j: (i * j) % 2 + (i * j) % 3 == 0, + lambda i, j: ((i * j) % 2 + (i * j) % 3) % 2 == 0, + lambda i, j: ((i + j) % 2 + (i * j) % 3) % 2 == 0, +] + +_ALIGNMENT = np.ones((5, 5), dtype=bool) +_ALIGNMENT[1:4, 1:4] = False +_ALIGNMENT[2, 2] = True + + +def _gf_inv(a: int) -> int: + return _EXP[-_LOG[a] % 255] + + +@functools.lru_cache +def _data_coords(version: int) -> tuple[np.ndarray, np.ndarray]: + """(rows, cols) of the data and error correction modules in placement order: two-column zigzag from the right.""" + dim = version * 4 + 17 + func = np.zeros((dim, dim), dtype=bool) # finder, timing, alignment, format, and version modules + func[:9, :9] = func[:9, dim - 8:] = func[dim - 8:, :9] = True + func[6, :] = func[:, 6] = True + positions = _alignment_positions(version) + for r, c in itertools.product(positions, positions): + if (r, c) not in ((6, 6), (6, dim - 7), (dim - 7, 6)): + func[r - 2:r + 3, c - 2:c + 3] = True + if version >= 7: + func[:6, dim - 11:dim - 8] = func[dim - 11:dim - 8, :6] = True + ys = np.arange(dim) + rows, cols = [], [] + # the vertical timing column is skipped, so the pairs left of it start at odd columns + for i, right in enumerate(col if col > 6 else col - 1 for col in range(dim - 1, 0, -2)): + r = np.repeat(ys[::-1] if i % 2 == 0 else ys, 2) + c = np.tile((right, right - 1), dim) + keep = ~func[r, c] + rows.append(r[keep]) + cols.append(c[keep]) + return np.concatenate(rows), np.concatenate(cols) + + +# ---- Matrix decoding ---- + + +def _poly_eval(p: list[int], x: int) -> int: + # p is highest degree first + y = 0 + for c in p: + y = _multiply(y, x) ^ c + return y + + +_EXP_TABLE = np.array(_EXP) +_LOG_TABLE = np.array([_LOG.get(v, 0) for v in range(256)]) + + +def _syndromes(msg: list[int], nsym: int) -> list[int]: + """syn[i] = msg(alpha^i), msg highest degree first.""" + m = np.array(msg) + exponents = np.arange(nsym)[:, None] * (len(msg) - 1 - np.arange(len(msg))) + return np.bitwise_xor.reduce(_EXP_TABLE[(_LOG_TABLE[m] + exponents) % 255] * (m != 0), axis=1).tolist() + + +def _rs_correct(msg: list[int], nsym: int) -> list[int]: + """Corrects up to nsym // 2 errors in a Reed-Solomon codeword, in place.""" + n = len(msg) + syn = _syndromes(msg, nsym) + if not any(syn): + return msg + + # Berlekamp-Massey, sigma is lowest degree first + sigma, prev, L, m, b = [1], [1], 0, 1, 1 + for r in range(nsym): + d = syn[r] + for i in range(1, L + 1): + d ^= _multiply(sigma[i], syn[r - i]) + if d == 0: + m += 1 + continue + coef = _multiply(d, _gf_inv(b)) + shifted = [0] * m + prev + saved = sigma[:] + sigma = sigma + [0] * max(0, len(shifted) - len(sigma)) + for i, c in enumerate(shifted): + sigma[i] ^= _multiply(coef, c) + if 2 * L <= r: + L, prev, b, m = r + 1 - L, saved, d, 1 + else: + m += 1 + sigma = sigma[:L + 1] + if 2 * L > nsym: + raise QRError("too many errors") + + # Chien search: codeword position p has locator alpha^(n-1-p) + positions = [p for p in range(n) if _poly_eval(sigma[::-1], _EXP[(p - n + 1) % 255]) == 0] + if len(positions) != L: + raise QRError("error locator mismatch") + + # solve syn[i] = sum_k e_k * X_k^i for the magnitudes e_k + xlog = [(n - 1 - p) % 255 for p in positions] + A = [[_EXP[(xlog[k] * i) % 255] for k in range(L)] + [syn[i]] for i in range(L)] + for col in range(L): + piv = next((r for r in range(col, L) if A[r][col]), None) + if piv is None: + raise QRError("singular") + A[col], A[piv] = A[piv], A[col] + inv = _gf_inv(A[col][col]) + A[col] = [_multiply(inv, v) for v in A[col]] + for r in range(L): + if r != col and A[r][col]: + f = A[r][col] + A[r] = [a ^ _multiply(f, c) for a, c in zip(A[r], A[col], strict=True)] + for k, p in enumerate(positions): + msg[p] ^= A[k][L] + + if any(_syndromes(msg, nsym)): + raise QRError("uncorrectable") + return msg + + +def _read_format(m: np.ndarray) -> int: + """Returns the closest format info (level bits << 3 | mask) from either copy.""" + dim = m.shape[0] + copies = ([(8, i) for i in range(6)] + [(8, 7), (8, 8), (7, 8)] + [(5 - i, 8) for i in range(6)], + [(dim - 1 - i, 8) for i in range(7)] + [(8, dim - 8 + i) for i in range(8)]) # (row, col), msb first + candidates = [] + for coords in copies: + bits = int("".join(str(int(m[r, c])) for r, c in coords), 2) + candidates += [((bits ^ f).bit_count(), i) for i, f in enumerate(_FORMATS)] + distance, fmt = min(candidates) + if distance > 3: + raise QRError("bad format info") + return fmt + + +_ALNUM = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:" + +_ECI_ENCODINGS = { + 0: "cp437", 2: "cp437", 1: "iso8859-1", 3: "iso8859-1", + **{i + 2: f"iso8859-{i}" for i in range(2, 17) if i != 12}, + 20: "shift_jis", 21: "cp1250", 22: "cp1251", 23: "cp1252", 24: "cp1256", + 25: "utf-16-be", 26: "utf-8", 27: "ascii", 170: "ascii", 28: "big5", 29: "gb18030", 30: "euc_kr", +} + + +class _Bits: + def __init__(self, data: list[int]): + self._value = int.from_bytes(bytes(data), "big") + self.remaining = len(data) * 8 + + def read(self, n: int) -> int: + if n > self.remaining: + raise QRError("bitstream underflow") + self.remaining -= n + return self._value >> self.remaining & (1 << n) - 1 + + def read_below(self, n: int, limit: int) -> int: + v = self.read(n) + if v >= limit: + raise QRError("value out of range") + return v + + +def _parse_data(data: list[int], version: int) -> str: + bits = _Bits(data) + out: list[str] = [] + encoding = None + band = 0 if version <= 9 else 1 if version <= 26 else 2 + while bits.remaining >= 4: + mode = bits.read(4) + if mode == 0: + break + if mode == 7: # ECI character set assignment + first = bits.read(8) + extra = 0 if first < 0x80 else 8 if first < 0xC0 else 16 if first < 0xE0 else -1 # 1, 2, or 3 byte assignment + if extra < 0: + raise QRError("bad ECI assignment") + assignment = (first & 0x7F >> extra // 8) << extra | bits.read(extra) + encoding = _ECI_ENCODINGS.get(assignment) + if encoding is None: + raise QRError(f"unsupported ECI assignment {assignment}") + elif mode == 1: + n = bits.read((10, 12, 14)[band]) + while n > 0: + k = min(n, 3) # 3 digits in 10 bits, the last 2 or 1 in 7 or 4 + out.append(f"{bits.read_below((4, 7, 10)[k - 1], 10 ** k):0{k}d}") + n -= k + elif mode == 2: + n = bits.read((9, 11, 13)[band]) + while n > 0: + k = min(n, 2) # 2 characters in 11 bits, a last one in 6 + v = bits.read_below((6, 11)[k - 1], 45 ** k) + out.append(_ALNUM[v // 45] * (k - 1) + _ALNUM[v % 45]) + n -= k + elif mode == 4: + n = bits.read((8, 16, 16)[band]) + segment = bytes(bits.read(8) for _ in range(n)) + try: + out.append(segment.decode(encoding or "utf-8")) + except UnicodeDecodeError as e: + if encoding is not None: + raise QRError("invalid ECI byte segment") from e + out.append(segment.decode("latin-1")) + elif mode == 8: + n = bits.read((8, 10, 12)[band]) + for _ in range(n): + v = bits.read(13) + c = (v // 0xC0) << 8 | v % 0xC0 + c += 0x8140 if c < 0x1F00 else 0xC140 + try: + out.append(c.to_bytes(2, "big").decode("shift_jis")) + except UnicodeDecodeError as e: + raise QRError("invalid Kanji character") from e + else: + raise QRError(f"unsupported mode {mode}") + return "".join(out) + + +def decode_matrix(m: np.ndarray) -> str: + """Decodes a square boolean module matrix (True = dark) without a quiet zone.""" + dim = m.shape[0] + if m.shape != (dim, dim) or dim % 4 != 1 or not 21 <= dim <= 177: + raise QRError("bad matrix size") + version = (dim - 17) // 4 + + fmt = _read_format(m) + level = _LEVELS[fmt >> 3] + rows, cols = _data_coords(version) + bits = m[rows, cols] ^ _MASKS[fmt & 7](rows, cols) + codewords = np.packbits(bits[:len(bits) // 8 * 8]).tolist() + + ec, _ = _EC[version - 1][level] + lens = _block_lengths(version, level) + blocks = [[0] * (n + ec) for n in lens] + for (b, i), codeword in zip(_interleaved(version, level), codewords, strict=True): + blocks[b][i] = codeword + + data: list[int] = [] + for block, n in zip(blocks, lens, strict=True): + data += _rs_correct(block, ec)[:n] + return _parse_data(data, version) + + +# ---- Image decoding ---- + + +def _box_sums(a: np.ndarray, radii: tuple[int, ...]) -> list[np.ndarray]: + """Sums over (2r + 1)^2 neighborhoods of the last two axes, edge padded, from one integral image.""" + P = max(radii) + lead = [(0, 0)] * (a.ndim - 2) + cs = np.pad(np.cumsum(np.cumsum(np.pad(a, lead + [(P, P), (P, P)], mode="edge"), -2), -1), lead + [(1, 0), (1, 0)]) + H, W = a.shape[-2:] + out = [] + for r in radii: + lo, hi = P - r, P + r + 1 + out.append(cs[..., hi:hi + H, hi:hi + W] - cs[..., lo:lo + H, hi:hi + W] - cs[..., hi:hi + H, lo:lo + W] + cs[..., lo:lo + H, lo:lo + W]) + return out + + +def _binarize(gray: np.ndarray) -> np.ndarray: + """Adaptive threshold: each pixel against the mean of the surrounding tiles that have contrast.""" + h, w = gray.shape + if h < 21 or w < 21: + raise QRError("image too small") + B = max(8, min(h, w) // 128 * 2) + H, W = -(-h // B), -(-w // B) + padded = np.pad(gray, ((0, H * B - h), (0, W * B - w)), mode="edge") + # block statistics from a subsample are plenty + sub = np.ascontiguousarray(padded[::2, ::2].reshape(H, B // 2, W, B // 2).transpose(0, 2, 1, 3)).reshape(H, W, -1) + blocks = sub.sum(axis=2, dtype=np.uint32) / sub.shape[2] + known = sub.max(axis=2) - sub.min(axis=2) >= 32 + # Flat tiles cannot estimate their own threshold: use the tiles with contrast nearby, then + # further out, then the global midrange. A flat tile is then all dark or all light. + est = np.full((H, W), (blocks.min() + blocks.max()) / 2) + filled = np.zeros((H, W), dtype=bool) + for total, count in _box_sums(np.stack((known * blocks, known.astype(float))), (2, 6)): + fill = ~filled & (count > 0) + est[fill] = total[fill] / count[fill] + filled |= fill + thr = np.where(known, np.minimum(est, 254) + 1, np.where(blocks <= est, 255, 0)).astype(np.uint8) + return (padded.reshape(H, B, W, B) < thr[:, None, :, None]).reshape(H * B, W * B)[:h, :w] + + +class _Runs: + """Run-length table of a padded, flattened binary image with a per-pixel run index.""" + + def __init__(self, padded: np.ndarray): + self.flat = padded.ravel() + self.lines, self.stride = padded.shape + change = self.flat[1:] != self.flat[:-1] + self.starts = np.concatenate(([0], np.flatnonzero(change) + 1)) + self.lengths = np.diff(np.append(self.starts, self.flat.size)).astype(np.int32) + + def run_at(self, line: np.ndarray, pos: np.ndarray) -> np.ndarray: + """Index of the run containing the pixel at `pos` along `line`.""" + return np.searchsorted(self.starts, line * self.stride + pos + 1, side="right") - 1 + + @staticmethod + def _match(lengths: list[np.ndarray], ratios: tuple[int, ...]) -> tuple[np.ndarray, np.ndarray]: + """Checks windows of runs against the ratios, given the length of each run. Returns (ok, module size).""" + S = sum(ratios) + total = sum(lengths[1:], start=lengths[0]) + ok = total >= 2 * S # modules need to be at least 2 px + for L, r in zip(lengths, ratios, strict=True): + ok &= np.abs(2 * S * L - 2 * r * total) <= r * total # integer form of |L - r * total / S| <= r * total / (2 * S) + return ok, total / S + + def scan(self, ratios: tuple[int, ...]) -> np.ndarray: + """Returns the indices of all dark runs starting a window of runs matching the ratios.""" + n = len(ratios) + N = len(self.lengths) - n + 1 + if N <= 0: + return np.zeros(0, dtype=int) + ok, _ = self._match([self.lengths[k:N + k] for k in range(n)], ratios) + ok &= self.flat[self.starts[:N]] + first = np.flatnonzero(ok) + return first[self.starts[first] // self.stride == self.starts[first + n - 1] // self.stride] + + def check(self, first: np.ndarray, ratios: tuple[int, ...]) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Checks the run windows starting at run index `first`. Returns (ok, center position along the line, module size).""" + n, half = len(ratios), len(ratios) // 2 + ok = (first >= 0) & (first + n <= len(self.starts)) + idx = np.clip(first[:, None] + np.arange(n), 0, len(self.starts) - 1) + matched, module = self._match([self.lengths[idx[:, k]] for k in range(n)], ratios) + ok &= matched & self.flat[self.starts[idx[:, 0]]] + ok &= self.starts[idx[:, 0]] // self.stride == self.starts[idx[:, -1]] // self.stride + center = self.starts[idx[:, half]] % self.stride - 1 + self.lengths[idx[:, half]] / 2 + return ok, center, module + + +def _find_patterns(binary: np.ndarray, ratios: tuple[int, ...]) -> list[tuple[float, float, float]]: + """Finds dark/light run patterns with the given module ratios. Returns (x, y, module size).""" + half = len(ratios) // 2 + step = 2 # the center rows of a 2 px finder pattern still get scanned twice + rows_t = _Runs(np.pad(binary[::step], ((0, 0), (1, 1)))) + first = rows_t.scan(ratios) + if len(first) == 0: + return [] + _, cx, hmod = rows_t.check(first, ratios) + row = rows_t.starts[first] // rows_t.stride * step + + xi = cx.astype(int) + xs, col = np.unique(xi, return_inverse=True) + cols_t = _Runs(np.pad(binary[:, xs].T, ((0, 0), (1, 1)))) + ok, cy, vmod = cols_t.check(cols_t.run_at(col, row) - half, ratios) + ok &= (0.5 <= vmod / hmod) & (vmod / hmod <= 2) + line = np.clip(np.rint(cy / step), 0, rows_t.lines - 1).astype(int) + ok2, cx2, hmod2 = rows_t.check(rows_t.run_at(line, xi) - half, ratios) + ok &= ok2 & (0.5 <= hmod2 / vmod) & (hmod2 / vmod <= 2) + + found: list[list[float]] = [] # [x, y, module, count] + for x, y, module in zip(cx2[ok], cy[ok], (hmod2[ok] + vmod[ok]) / 2, strict=True): + for f in found: + if abs(f[0] - x) <= f[2] and abs(f[1] - y) <= f[2] and 0.5 <= f[2] / module <= 2: + c = f[3] + f[0], f[1], f[2], f[3] = (f[0] * c + x) / (c + 1), (f[1] * c + y) / (c + 1), (f[2] * c + module) / (c + 1), c + 1 + break + else: + found.append([x, y, module, 1]) + found.sort(key=lambda f: -f[3]) + return [(f[0], f[1], f[2]) for f in found if f[3] >= 2] + + +def _pick_finders(patterns: list[tuple[float, float, float]]) -> tuple[np.ndarray, np.ndarray, np.ndarray, float]: + """Returns (top-left, top-right, bottom-left) centers and the module size of the most square-looking triple.""" + best = None + for a, b, c in itertools.combinations(patterns[:10], 3): + mods = sorted((a[2], b[2], c[2])) + if mods[2] / mods[0] > 1.5: + continue + pts = [np.array(p[:2]) for p in (a, b, c)] + d = [np.linalg.norm(pts[(i + 1) % 3] - pts[(i + 2) % 3]) for i in range(3)] + tl = int(np.argmax(d)) # opposite the hypotenuse + p1, p2 = pts[(tl + 1) % 3], pts[(tl + 2) % 3] + v1, v2 = p1 - pts[tl], p2 - pts[tl] + n1, n2 = np.linalg.norm(v1), np.linalg.norm(v2) + if n1 == 0 or n2 == 0: + continue + cos = abs(np.dot(v1, v2)) / (n1 * n2) + if cos > 0.35 or not 0.6 <= n1 / n2 <= 1.6: + continue + score = cos + abs(np.log(n1 / n2)) + np.log(mods[2] / mods[0]) + if best is not None and score >= best[0]: + continue + if v1[0] * v2[1] - v1[1] * v2[0] < 0: + p1, p2 = p2, p1 + best = (score, pts[tl], p1, p2, float(sum(mods) / 3)) + if best is None: + raise QRError("no finder patterns") + return best[1:] + + +def _perspective(src: np.ndarray, dst: np.ndarray) -> np.ndarray: + """Homography mapping the four src points onto the four dst points.""" + A = [row for (x, y), (u, v) in zip(src, dst, strict=True) + for row in ([x, y, 1, 0, 0, 0, -u * x, -u * y], [0, 0, 0, x, y, 1, -v * x, -v * y])] + try: + h = np.linalg.solve(np.array(A, dtype=float), np.asarray(dst, dtype=float).ravel()) + except np.linalg.LinAlgError as e: + raise QRError("degenerate geometry") from e + return np.append(h, 1).reshape(3, 3) + + +def _transform(H: np.ndarray, pts: np.ndarray) -> np.ndarray: + p = np.column_stack((pts, np.ones(len(pts)))) @ H.T + return p[:, :2] / p[:, 2:3] + + +def _match_alignment(binary: np.ndarray, est: np.ndarray, offs: np.ndarray, r: int, module: float) -> np.ndarray | None: + h, w = binary.shape + dy = np.arange(max(0, int(est[1]) - r), min(h, int(est[1]) + r)) - est[1] + dx = np.arange(max(0, int(est[0]) - r), min(w, int(est[0]) + r)) - est[0] + if len(dy) == 0 or len(dx) == 0: + return None + y = np.rint(est[1] + dy[:, None, None] + offs[None, None, :, 1]).astype(int) + x = np.rint(est[0] + dx[None, :, None] + offs[None, None, :, 0]).astype(int) + valid = ((y >= 0) & (y < h) & (x >= 0) & (x < w)).all(axis=2) + samples = binary[np.clip(y, 0, h - 1), np.clip(x, 0, w - 1)] + score = np.where(valid, (samples == _ALIGNMENT.ravel()).sum(axis=2), 0) + if score.max() < 23: + return None + hits = np.argwhere(score == score.max()) + centers = np.column_stack((est[0] + dx[hits[:, 1]], est[1] + dy[hits[:, 0]])) + closest = centers[np.argmin(np.linalg.norm(centers - est, axis=1))] + return centers[np.linalg.norm(centers - closest, axis=1) <= module / 2].mean(axis=0) + + +def _locate_alignment(binary: np.ndarray, H: np.ndarray, center: float, module: float) -> np.ndarray | None: + """Template matches the 5x5 alignment pattern around its position estimated from H.""" + grid = np.mgrid[-2:3, -2:3].reshape(2, -1).T[:, ::-1] + center # (25, 2) module coords (x, y) + pts = _transform(H, grid) + # the affine estimate can be off in both position and local scale under perspective + for radius in (2, 4, 8, 16): + for scale in (1.0, 0.8, 1.25, 0.65, 1.5): + found = _match_alignment(binary, pts[12], (pts - pts[12]) * scale, int(module * radius), module) + if found is not None: + return found + return None + + +def _sample(binary: np.ndarray, tl: np.ndarray, tr: np.ndarray, bl: np.ndarray, module: float, dim: int, use_alignment: bool) -> np.ndarray: + src = np.array([(3.5, 3.5), (dim - 3.5, 3.5), (3.5, dim - 3.5), (dim - 3.5, dim - 3.5)]) + dst = np.array([tl, tr, bl, tr + bl - tl]) + H = _perspective(src, dst) + if use_alignment and dim > 21: + align = _locate_alignment(binary, H, dim - 6.5, module) + if align is not None: + src[3], dst[3] = (dim - 6.5, dim - 6.5), align + H = _perspective(src, dst) + + rows, cols = np.mgrid[0:dim, 0:dim] + pts = _transform(H, np.column_stack((cols.ravel() + 0.5, rows.ravel() + 0.5))) + xy = np.rint(pts).astype(int) + h, w = binary.shape + if (xy < 0).any() or (xy[:, 0] >= w).any() or (xy[:, 1] >= h).any(): + raise QRError("code extends outside image") + return binary[xy[:, 1], xy[:, 0]].reshape(dim, dim) + + +def decode(gray: np.ndarray) -> str | None: + """Decodes the QR code in a 2D uint8 grayscale image. Modules need to be at least 2 px. + Returns None if nothing could be decoded.""" + try: + binary = _binarize(gray) + tl, tr, bl, module = _pick_finders(_find_patterns(binary, (1, 1, 3, 1, 1))) + except QRError: + return None + + d = (np.linalg.norm(tr - tl) + np.linalg.norm(bl - tl)) / 2 + dim = int(round((d / module + 7 - 17) / 4)) * 4 + 17 + dims = [cand for cand in (dim, dim - 4, dim + 4) if 21 <= cand <= 177] + for cand, use_alignment, transpose in itertools.product(dims, (True, False), (False, True)): + try: + m = _sample(binary, tl, tr, bl, module, cand, use_alignment) + return decode_matrix(m.T if transpose else m) + except QRError: + pass + return None diff --git a/openpilot/common/tests/fixtures/qrcode_0.npz b/openpilot/common/tests/fixtures/qrcode_0.npz new file mode 100644 index 0000000000000000000000000000000000000000..96f550e5f439d278daf2229532464a4bd343c753 GIT binary patch literal 67335 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�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%0A8eMO4(kk{PsChp6l#(` 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

{@U4myW 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 edCn>z{u?K3c2-QsJN_QyI4bp57DQW2jrMo-! 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⋙hffI8A9#N#;jCY#)#qjamhi} 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 np.ndarray: + bits = np.unpackbits(FIXTURES[key]) + size = math.isqrt(len(bits)) + return bits[:size * size].reshape(size, size).astype(bool) + + +def render(matrix: np.ndarray, box: int = 6, border: int = 4) -> np.ndarray: + img = np.repeat(np.repeat(np.pad(matrix, border), box, axis=0), box, axis=1) + return np.where(img, 0, 255).astype(np.uint8) + + +def make(data: str, version: int | None = None, level: int = 0, box: int = 6, border: int = 4): + matrix = fixture(hashlib.sha256(f"{version}:{level}:{data}".encode()).hexdigest()) + return matrix, render(matrix, box, border) + + +def warp(img: np.ndarray, H: np.ndarray) -> np.ndarray: + """Bilinear resampling through the output -> input homography H, white outside the image.""" + h, w = img.shape + rows, cols = np.mgrid[0:h, 0:w] + pts = qr._transform(H, np.column_stack((cols.ravel() + 0.5, rows.ravel() + 0.5))) - 0.5 + x0, y0 = np.floor(pts[:, 0]).astype(int), np.floor(pts[:, 1]).astype(int) + fx, fy = pts[:, 0] - x0, pts[:, 1] - y0 + padded = np.pad(img.astype(float), 1, constant_values=255) + + def at(y, x): + return padded[np.clip(y + 1, 0, h + 1), np.clip(x + 1, 0, w + 1)] + + out = at(y0, x0) * (1 - fx) * (1 - fy) + at(y0, x0 + 1) * fx * (1 - fy) + at(y0 + 1, x0) * (1 - fx) * fy + at(y0 + 1, x0 + 1) * fx * fy + return np.clip(out, 0, 255).astype(np.uint8).reshape(h, w) + + +def rotate(img: np.ndarray, angle: float) -> np.ndarray: + h, w = img.shape + t = np.radians(angle) + R = np.array([[np.cos(t), -np.sin(t)], [np.sin(t), np.cos(t)]]) + center = np.array([w / 2, h / 2]) + corners = np.array([(0, 0), (w, 0), (w, h), (0, h)], dtype=float) + return warp(img, qr._perspective((corners - center) @ R.T + center, corners)) + class TestQRCode(OpenpilotTestCase): def test_alignment_positions(self): assert qr._alignment_positions(7) == [6, 22, 38] assert qr._alignment_positions(32) == [6, 34, 60, 86, 112, 138] assert qr._alignment_positions(40) == [6, 30, 58, 86, 114, 142, 170] + + def test_all_versions(self): + for version in range(1, 41): + for level in range(4): + with self.subTest(version=version, level=level): + data = "".join(chr(ord("a") + i % 26) for i in range(version)) + matrix, img = make(data, version, level, box=3) + assert qr.decode_matrix(matrix) == data + assert qr.decode(img) == data + + def test_modes(self): + for data in ["0123456789012345", "HELLO WORLD $1.50", LPA, "こんにちは", "ünïcødé", "mixed 123 ABC xyz"]: + with self.subTest(data=data): + matrix, img = make(data) + assert qr.decode_matrix(matrix) == data + assert qr.decode(img) == data + + def test_error_correction(self): + matrix, _ = make(LPA, level=2) + rng = np.random.default_rng(0) + flipped = matrix.copy() + for r, c in rng.integers(9, matrix.shape[0] - 9, size=(40, 2)): + flipped[r, c] ^= True + assert qr.decode_matrix(flipped) == LPA + + def test_large_modules(self): + for data in ["0123456789012345", "HELLO WORLD $1.50", LPA, "mixed 123 ABC xyz"]: + for box in [16, 20, 24, 32]: + for dark, light in [(0, 255), (60, 200), (140, 250)]: + with self.subTest(data=data, box=box, dark=dark): + _, img = make(data, box=box) + img = np.where(img == 0, dark, light).astype(np.uint8) + assert qr.decode(img) == data + + def test_image_edges(self): + # a code touching the image edge must not lose the rows and columns left over from tiling + matrix, _ = make(LPA) + for size in (200, 203): + with self.subTest(size=size): + img = np.full((size, size), 255, dtype=np.uint8) + code = render(matrix, box=5, border=0) + img[size - code.shape[0]:, size - code.shape[1]:] = code + assert qr.decode(img) == LPA + + def test_eci(self): + # qrcode_eci.npz: packed Segno 1.6.6 matrices, generated with mode='byte', + # eci=True, micro=False and the named encoding. Mixed also includes numeric, + # alphanumeric, and Kanji segments after changing the byte encoding twice. + cases = { + "iso8859-5": "Привет", "utf-16-be": "héllo", "utf-8": "こんにちは", + "shift_jis": "日本語", "cp1251": "Привет", "iso8859-1": "héllo", + "mixed": "hélloПривет日本語123ABC漢字", + } + for encoding, expected in cases.items(): + with self.subTest(encoding=encoding): + matrix = fixture(encoding) + assert qr.decode_matrix(matrix) == expected + assert qr.decode(render(matrix)) == expected + + def test_parse_data(self): + def parse(stream: str) -> str: + stream += '0' * (-len(stream) % 8) + return qr._parse_data([int(stream[i:i + 8], 2) for i in range(0, len(stream), 8)], 1) + + def eci(assignment: str, payload: bytes = b'A') -> str: + return parse('0111' + assignment + '0100' + f'{len(payload):08b}' + ''.join(f'{b:08b}' for b in payload) + '0000') + + # ASCII assignment 170 uses the two-byte ECI representation. + assert eci('1000000010101010') == 'A' + for assignment in ['00001110', '1000001111100111', '110000010000000000000000', '11100000']: + with self.subTest(assignment=assignment), self.assertRaises(qr.QRError): + eci(assignment) + with self.assertRaises(qr.QRError): + eci('00011010', b'\xff') # Invalid UTF-8 must not fall back to Latin-1. + + # out-of-range numeric, alphanumeric, and Kanji values are format errors, not crashes + for stream in ['0001' + '0000000011' + '1111111111', '0001' + '0000000010' + '1111111', + '0010' + '000000010' + '11111111111', '0010' + '000000001' + '111111', + '1000' + '00000001' + '0000000111111']: + with self.subTest(stream=stream), self.assertRaises(qr.QRError): + parse(stream) + + def test_rotation(self): + for angle in [0, 90, 180, 270, 25, 110]: + with self.subTest(angle=angle): + _, img = make(LPA, box=8, border=12) + assert qr.decode(rotate(img, angle)) == LPA + + def test_mirrored(self): + _, img = make(LPA) + assert qr.decode(img[:, ::-1]) == LPA + + def test_perspective_and_noise(self): + _, img = make(LPA, box=10, border=8) + h, w = img.shape + corners = np.array([(40, 60), (w - 20, 30), (w - 60, h - 40), (30, h - 90)]) + arr = warp(img, qr._perspective(corners, np.array([(0, 0), (w, 0), (w, h), (0, h)]))).astype(float) + rng = np.random.default_rng(1) + arr = arr * 0.6 + 60 + rng.normal(0, 12, arr.shape) # low contrast + noise + # uneven lighting + arr += np.linspace(-40, 40, w)[None, :] + assert qr.decode(np.clip(arr, 0, 255).astype(np.uint8)) == LPA + + def test_no_code(self): + rng = np.random.default_rng(2) + assert qr.decode(rng.integers(0, 256, size=(240, 320), dtype=np.uint8)) is None + assert qr.decode(np.full((240, 320), 200, dtype=np.uint8)) is None + + def test_encoder_roundtrip(self): + for version in range(1, 21): + with self.subTest(version=version): + assert qr.decode_matrix(np.array(qr._Qr(version, b"hello").modules)) == "hello" From d70df6736625c88d87fe301b93359d117ea9221e Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:39:24 -0700 Subject: [PATCH 047/122] mici: process eSIM notifications after profile deletion (#38832) --- openpilot/system/ui/lib/cellular_manager.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpilot/system/ui/lib/cellular_manager.py b/openpilot/system/ui/lib/cellular_manager.py index 97f20b5bea..afbea475ca 100644 --- a/openpilot/system/ui/lib/cellular_manager.py +++ b/openpilot/system/ui/lib/cellular_manager.py @@ -145,7 +145,10 @@ class CellularManager: self._run_operation(switch, "Failed to switch eSIM profile", relist=False) def delete_profile(self, iccid: str): - self._run_operation(lambda lpa: lpa.delete_profile(iccid), "Failed to delete eSIM profile") + def delete(lpa: LPABase): + execute_and_process_notifications(lpa, lambda: lpa.delete_profile(iccid)) + + self._run_operation(delete, "Failed to delete eSIM profile") def nickname_profile(self, iccid: str, nickname: str): self._run_operation(lambda lpa: lpa.nickname_profile(iccid, nickname), "Failed to update eSIM profile nickname") From 179718dcb0c71460da7ed67bb7cd4156324ac8e1 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:16:17 -0700 Subject: [PATCH 048/122] esim: mici profile download UI (#38813) * esim: MICI profile download UI * Show temporary feedback for non-LPA QR codes * Scan eSIM QR codes every 250 ms * Shrink eSIM error text and log displayed errors * Activate downloaded eSIM through the profile tap flow * Let the eSIM connectivity error wrap naturally * Release add-profile button feedback immediately * Check internet before opening the eSIM QR scanner * Use UI network state and standard connectivity dialog for eSIM * Drop LPA prime settings change from eSIM download UI * Scope eSIM UI constants to their owning classes * Remove unused ot codespell exception * esim: simplify profile download operation and UI state * esim: share activation code validation with QR scanner * esim: match QR scanner video to cabin preview * esim: show errors in a scrollable text dialog * esim: require nonblank profile nicknames --- openpilot/common/esim/lpa.py | 2 +- .../mici/layouts/settings/network/esim_ui.py | 195 +++++++++++++++++- openpilot/selfdrive/ui/mici/widgets/dialog.py | 10 +- openpilot/system/ui/lib/cellular_manager.py | 3 + 4 files changed, 197 insertions(+), 13 deletions(-) diff --git a/openpilot/common/esim/lpa.py b/openpilot/common/esim/lpa.py index fb128c32e1..600869fcb7 100644 --- a/openpilot/common/esim/lpa.py +++ b/openpilot/common/esim/lpa.py @@ -613,7 +613,7 @@ def parse_lpa_activation_code(activation_code: str) -> tuple[str, str]: if not activation_code.startswith("LPA:"): raise ValueError("Invalid activation code format") parts = activation_code[4:].split("$") - if len(parts) != 3: + if len(parts) != 3 or not all(parts): raise ValueError("Invalid activation code format") return parts[1], parts[2] diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index 0e115b232f..9af7bcde88 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -1,14 +1,27 @@ +import threading + +import numpy as np import pyray as rl from collections.abc import Callable +from openpilot.cereal import log +from openpilot.cereal.visionipc import VisionStreamType + +from openpilot.common import qrcode +from openpilot.common.swaglog import cloudlog +from openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog import CabinCameraView +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets.nav_widget import NavWidget from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigInputDialog, BigConfirmationDialog from openpilot.common.esim.base import Profile +from openpilot.common.esim.lpa import parse_lpa_activation_code from openpilot.system.ui.lib.application import DEFAULT_TEXT_COLOR, FontWeight, MousePos, TextAlignment, gui_app from openpilot.system.ui.lib.cellular_manager import CellularManager from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.widgets.label import gui_label -from openpilot.system.ui.widgets.scroller import NavScroller +from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label +from openpilot.system.ui.widgets.scroller import NavRawScrollPanel, NavScroller class ProfileActionButton(Widget): @@ -40,6 +53,110 @@ class ProfileActionButton(Widget): gui_label(self._rect, "Aa", 30, color=color, alignment=TextAlignment.CENTER) +class QRScannerDialog(NavWidget): + SCAN_INTERVAL_S = 0.25 + INVALID_CODE_DURATION_S = 1.0 + + def __init__(self, on_qr_detected: Callable[[str], None]): + super().__init__() + self._on_qr_detected = on_qr_detected + self._camera_view = CabinCameraView("camerad", VisionStreamType.VISION_STREAM_CABIN) + self._detected = False + self._last_scan_time = 0.0 + self._invalid_code_until = 0.0 + self._scan_thread: threading.Thread | None = None + self._scan_result: str | None = None + self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + + def show_event(self): + super().show_event() + ui_state.params.put_bool("DisableDriverCameraIR", True) + ui_state.params.put_bool("IsDriverViewEnabled", True) + + def hide_event(self): + super().hide_event() + ui_state.params.put_bool("IsDriverViewEnabled", False) + ui_state.params.put_bool("DisableDriverCameraIR", False) + + def __del__(self): + self._camera_view.close() + + def _update_state(self): + super()._update_state() + + now = rl.get_time() + if self._detected or not self._camera_view.frame or now < self._invalid_code_until: + return + + if self._scan_thread is not None: + if self._scan_thread.is_alive(): + return + self._scan_thread = None + data = self._scan_result + if data is not None: + try: + parse_lpa_activation_code(data) + except ValueError: + self._invalid_code_until = now + self.INVALID_CODE_DURATION_S + self._last_scan_time = self._invalid_code_until + else: + self._detected = True + self.dismiss(lambda: self._on_qr_detected(data)) + return + + if now - self._last_scan_time < self.SCAN_INTERVAL_S: + return + self._last_scan_time = now + + frame = self._camera_view.frame + y = np.frombuffer(frame.data, dtype=np.uint8, count=frame.height * frame.stride).reshape(frame.height, frame.stride) + gray = y[:, :frame.width].copy() # the vision buffer is recycled under the scan thread + self._scan_thread = threading.Thread(target=self._scan, args=(gray,), daemon=True) + self._scan_thread.start() + + def _scan(self, gray: np.ndarray): + self._scan_result = qrcode.decode(gray) + + def _render(self, rect): + rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) + self._camera_view._render(rect) + + if not self._camera_view.frame: + gui_label(rect, tr("camera starting"), font_size=54, font_weight=FontWeight.BOLD, + alignment=TextAlignment.CENTER) + else: + label_y = rect.y + rect.height * 3 / 4 + label_rect = rl.Rectangle(rect.x, label_y + (rect.height - label_y) / 2 - 20, rect.width, 40) + text = "not an LPA code" if rl.get_time() < self._invalid_code_until else "hold QR code to camera" + gui_label(label_rect, text, font_size=32, font_weight=FontWeight.MEDIUM, + alignment=TextAlignment.CENTER, + color=rl.Color(255, 255, 255, int(255 * 0.9))) + + rl.end_scissor_mode() + + +class InstallingProfileDialog(BigDialog): + DOT_STEP = 0.6 + + def __init__(self): + super().__init__("installing profile", "please wait...") + self._show_time = 0.0 + + def show_event(self): + super().show_event() + self._nav_bar._alpha = 0.0 + self._show_time = rl.get_time() + + def _back_enabled(self) -> bool: + return False + + def _render(self, _): + t = (rl.get_time() - self._show_time) % (self.DOT_STEP * 2) + dots = "." * min(int(t / (self.DOT_STEP / 4)), 3) + self._card.set_value(f"please wait{dots}") + super()._render(_) + + class EsimProfileButton(BigButton): SUB_LABEL_DISABLED = rl.Color(255, 255, 255, int(255 * 0.585)) CHECK_ICON_COLOR = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) @@ -92,7 +209,8 @@ class EsimProfileButton(BigButton): def _on_rename(self): current = self._profile.nickname or "" - dlg = BigInputDialog("nickname", default_text=current, minimum_length=0, confirm_callback=self._on_nickname_entered) + dlg = BigInputDialog("nickname", default_text=current, confirm_callback=self._on_nickname_entered, + text_validator=lambda text: bool(text.strip())) gui_app.push_widget(dlg) def _on_delete(self): @@ -171,6 +289,25 @@ class EsimProfileButton(BigButton): self._rename_btn.set_touch_valid_callback(touch_callback) +class EsimErrorDialog(NavRawScrollPanel): + def __init__(self, error: str): + super().__init__() + self._title = UnifiedLabel("esim error", font_size=64, font_weight=FontWeight.BOLD) + self._error = UnifiedLabel(error, font_size=36, elide=False) + + def _render(self, rect: rl.Rectangle): + width = int(rect.width - 80) + title_height = self._title.get_content_height(width) + error_height = self._error.get_content_height(width) + offset = self._scroll_panel.update(rect, title_height + error_height + 100) + y = rect.y + 40 + offset + + rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) + self._title.render(rl.Rectangle(rect.x + 40, y, width, title_height)) + self._error.render(rl.Rectangle(rect.x + 40, y + title_height + 20, width, error_height)) + rl.end_scissor_mode() + + class EsimUI(NavScroller): def __init__(self, cellular_manager: CellularManager, profiles_enabled: Callable[[], bool]): super().__init__() @@ -178,7 +315,12 @@ class EsimUI(NavScroller): self._cellular_manager = cellular_manager self._profiles_enabled = profiles_enabled - self._cellular_manager.on_profiles_updated = self._update_buttons + self._add_profile_btn = BigButton("add profile", "scan QR code") + self._add_profile_btn.set_click_callback(self._on_add_profile) + self._scroller.add_widget(self._add_profile_btn) + self._installing_dialog: InstallingProfileDialog | None = None + + self._cellular_manager.on_profiles_updated = self._on_profiles_updated self._cellular_manager.on_operation_error = self._on_error def show_event(self): @@ -186,8 +328,18 @@ class EsimUI(NavScroller): self._update_buttons(re_sort=True) self._cellular_manager.refresh_profiles() + def _on_profiles_updated(self): + if self._installing_dialog: + existing = {btn.profile.iccid for btn in self._scroller.items if isinstance(btn, EsimProfileButton)} + added = [profile for profile in self._cellular_manager.profiles if profile.iccid not in existing] + # Start the normal tap-to-activate flow once the profile list is visible again. + self._installing_dialog.dismiss(lambda: self._on_profile_clicked(added[0]) if len(added) == 1 else None) + self._installing_dialog = None + + self._update_buttons() + def _update_buttons(self, re_sort: bool = False): - existing = {btn.profile.iccid: btn for btn in self._scroller.items} + existing = {btn.profile.iccid: btn for btn in self._scroller.items if isinstance(btn, EsimProfileButton)} buttons = [] for profile in self._cellular_manager.profiles: btn = existing.get(profile.iccid) @@ -204,8 +356,11 @@ class EsimUI(NavScroller): else: self._scroller.items[:] = [btn for btn in self._scroller.items if btn in buttons] + self._scroller.items.append(self._add_profile_btn) + def _move_profile_to_front(self, iccid: str | None, scroll: bool = False): - front_btn_idx = next((i for i, btn in enumerate(self._scroller.items) if btn.profile.iccid == iccid), None) if iccid else None + front_btn_idx = next((i for i, btn in enumerate(self._scroller.items) + if isinstance(btn, EsimProfileButton) and btn.profile.iccid == iccid), None) if iccid else None if front_btn_idx is not None and front_btn_idx > 0: self._scroller.move_item(front_btn_idx, 0) @@ -216,13 +371,37 @@ class EsimUI(NavScroller): def _update_state(self): super()._update_state() + self._add_profile_btn.set_enabled(not self._cellular_manager.busy and self._profiles_enabled()) active = self._cellular_manager.active_profile self._move_profile_to_front(active.iccid if active else None) - def _on_error(self, error: str): - dlg = BigDialog("esim error", error) + def _on_add_profile(self): + if self._cellular_manager.busy or not self._profiles_enabled(): + return + if ui_state.sm["deviceState"].networkType == log.DeviceState.NetworkType.none: + gui_app.push_widget(BigDialog("", tr("Ensure you're connected to the internet and try again."))) + return + gui_app.push_widget(QRScannerDialog(on_qr_detected=self._on_qr_scanned)) + + def _on_qr_scanned(self, lpa_code: str): + dlg = BigInputDialog("enter a nickname...", text_validator=lambda text: bool(text.strip()), + confirm_callback=lambda nickname: self._download_profile(lpa_code, nickname)) gui_app.push_widget(dlg) + def _download_profile(self, lpa_code: str, nickname: str): + self._installing_dialog = InstallingProfileDialog() + gui_app.push_widget(self._installing_dialog) + self._cellular_manager.download_profile(lpa_code, nickname.strip()) + + def _on_error(self, error: str): + cloudlog.error("eSIM error: %s", error) + dlg = EsimErrorDialog(error) + if self._installing_dialog: + self._installing_dialog.dismiss(lambda: gui_app.push_widget(dlg)) + self._installing_dialog = None + else: + gui_app.push_widget(dlg) + def _on_profile_clicked(self, profile: Profile): if self._cellular_manager.busy or not self._profiles_enabled(): return diff --git a/openpilot/selfdrive/ui/mici/widgets/dialog.py b/openpilot/selfdrive/ui/mici/widgets/dialog.py index 77dfa3cb97..c023369933 100644 --- a/openpilot/selfdrive/ui/mici/widgets/dialog.py +++ b/openpilot/selfdrive/ui/mici/widgets/dialog.py @@ -76,14 +76,15 @@ class BigInputDialog(BigDialogBase): default_text: str = "", minimum_length: int = 1, confirm_callback: Callable[[str], None] | None = None, - auto_return_to_letters: str = ""): + auto_return_to_letters: str = "", + text_validator: Callable[[str], bool] | None = None): super().__init__() self._hint_label = UnifiedLabel(hint, font_size=35, text_color=rl.Color(255, 255, 255, int(255 * 0.35)), font_weight=FontWeight.MEDIUM) self._keyboard = MiciKeyboard(auto_return_to_letters=auto_return_to_letters) self._keyboard.set_text(default_text) self._keyboard.set_enabled(lambda: self.enabled and not self.is_dismissing) # for nav stack + NavWidget - self._minimum_length = minimum_length + self._text_valid = lambda text: len(text) >= minimum_length and (text_validator is None or text_validator(text)) self._backspace_held_time: float | None = None @@ -100,7 +101,8 @@ class BigInputDialog(BigDialogBase): def confirm_callback_wrapper(): text = self._keyboard.text() - self.dismiss((lambda: confirm_callback(text)) if confirm_callback else None) + if self._text_valid(text): + self.dismiss((lambda: confirm_callback(text)) if confirm_callback else None) self._confirm_callback = confirm_callback_wrapper def _update_state(self): @@ -185,7 +187,7 @@ class BigInputDialog(BigDialogBase): self._rect.width - (text_field_rect.x + text_field_rect.width), self._top_left_button_rect.height) # draw enter button - self._enter_img_alpha.update(255 if len(text) >= self._minimum_length else 0) + self._enter_img_alpha.update(255 if self._text_valid(text) else 0) color = rl.Color(255, 255, 255, int(self._enter_img_alpha.x)) rl.draw_texture_ex(self._enter_img, rl.Vector2(self._rect.x + PADDING / 2, self._rect.y), 0.0, 1.0, color) color = rl.Color(255, 255, 255, 255 - int(self._enter_img_alpha.x)) diff --git a/openpilot/system/ui/lib/cellular_manager.py b/openpilot/system/ui/lib/cellular_manager.py index afbea475ca..7cd19c4cb3 100644 --- a/openpilot/system/ui/lib/cellular_manager.py +++ b/openpilot/system/ui/lib/cellular_manager.py @@ -150,5 +150,8 @@ class CellularManager: self._run_operation(delete, "Failed to delete eSIM profile") + def download_profile(self, qr: str, nickname: str | None = None): + self._run_operation(lambda lpa: lpa.download_profile(qr, nickname), "Failed to download eSIM profile") + def nickname_profile(self, iccid: str, nickname: str): self._run_operation(lambda lpa: lpa.nickname_profile(iccid, nickname), "Failed to update eSIM profile nickname") From 802a231bd20c6a0b0b88ebbb891970aee40cf1b5 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:34:37 -0700 Subject: [PATCH 049/122] mici: simplify eSIM connecting status (#38833) --- .../selfdrive/ui/mici/layouts/settings/network/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py index 4719feecb9..89c106e236 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -35,7 +35,7 @@ class EsimNetworkButton(BigButton): def _compute_state(self): cm = self._cellular_manager none_icon = self._cell_icons[NetworkStrength.unknown] - ip = cm.modem_state.get("ip_address") or "obtaining IP..." + ip = cm.modem_state.get("ip_address") or "connecting..." if cm.is_euicc is False: iccid = cm.modem_state.get("iccid") or "" if not iccid: From 14617070ab79a133dd22f1a00009f24b3ab2affc Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Wed, 9 Sep 2026 09:32:32 -0700 Subject: [PATCH 050/122] lil more comment --- tools/setup_dependencies.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tools/setup_dependencies.sh b/tools/setup_dependencies.sh index 5ad833a5ca..7af2180686 100755 --- a/tools/setup_dependencies.sh +++ b/tools/setup_dependencies.sh @@ -42,6 +42,10 @@ function install_linux_deps() { # dependencies should never be added to this list. # these are only for inflating bare docker images # to their desktop equivalents. + # + # from a real desktop OS image, we install uv + # then use that to install all the dependencies + # specified in pyproject.toml. # ------------------------------------------------ if [[ "$missing_linux_deps" -eq 0 ]]; then # the native package managers are slow, so skip if we can From 20bddff85706ef9a5ba778333a6327a8cdc759eb Mon Sep 17 00:00:00 2001 From: Amy Jeanes Date: Mon, 7 Sep 2026 23:56:47 +0000 Subject: [PATCH 051/122] Use LF line endings Split from https://github.com/commaai/openpilot/pull/38810. --- .gitattributes | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index 5cb404146d..2f8f3ece32 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,4 @@ -* text=auto +* text=auto eol=lf # to move existing files into LFS: # git add --renormalize . From 1e0914051fd22d963309b77996181d8b9a97e267 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Wed, 9 Sep 2026 09:57:22 -0700 Subject: [PATCH 052/122] bump msgq (#38836) --- msgq_repo | 2 +- pyproject.toml | 4 ++-- uv.lock | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/msgq_repo b/msgq_repo index 0e266c1dbc..326a9f5aa6 160000 --- a/msgq_repo +++ b/msgq_repo @@ -1 +1 @@ -Subproject commit 0e266c1dbcf7328beee3e57b4a8688555387c877 +Subproject commit 326a9f5aa6cf630f647fd6996106aa267dd01c2a diff --git a/pyproject.toml b/pyproject.toml index 7be8f97c0f..2c97e6444e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ tools = [ ] submodules = [ - "msgq", + "msgq-ipc", "opendbc", "pandacan", "rednose", @@ -155,7 +155,7 @@ override-dependencies = [ ] [tool.uv.sources] -msgq = { path = "msgq_repo", editable = true } +msgq-ipc = { path = "msgq_repo", editable = true } opendbc = { path = "opendbc_repo", editable = true } pandacan = { path = "panda", editable = true } rednose = { path = "rednose_repo", editable = true } diff --git a/uv.lock b/uv.lock index c27429f5f2..fe85a470bc 100644 --- a/uv.lock +++ b/uv.lock @@ -512,8 +512,8 @@ wheels = [ ] [[package]] -name = "msgq" -version = "0.0.1" +name = "msgq-ipc" +version = "1.1" source = { editable = "msgq_repo" } [package.metadata] @@ -618,7 +618,7 @@ dependencies = [ [package.optional-dependencies] submodules = [ - { name = "msgq" }, + { name = "msgq-ipc" }, { name = "opendbc" }, { name = "pandacan" }, { name = "rednose" }, @@ -664,7 +664,7 @@ requires-dist = [ { name = "inputs" }, { name = "jeepney" }, { name = "matplotlib", marker = "extra == 'tools'" }, - { name = "msgq", marker = "extra == 'submodules'", editable = "msgq_repo" }, + { name = "msgq-ipc", marker = "extra == 'submodules'", editable = "msgq_repo" }, { name = "numpy", specifier = ">=2.0" }, { name = "opendbc", marker = "extra == 'submodules'", editable = "opendbc_repo" }, { name = "pandacan", marker = "extra == 'submodules'", editable = "panda" }, From ff2f78c3e83c2104edca826a9af78445e47afee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Wed, 9 Sep 2026 14:31:06 -0700 Subject: [PATCH 053/122] Move Git LFS hosting to Hugging Face (#38824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Move LFS asset transfers to xet-core * Limit migration to Git LFS hosting on Hugging Face --------- Co-authored-by: Harald Schäfer <6804392+haraschax@users.noreply.github.com> --- .lfsconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.lfsconfig b/.lfsconfig index 42dfa2d944..784a2d8705 100644 --- a/.lfsconfig +++ b/.lfsconfig @@ -1,4 +1,4 @@ [lfs] - url = https://gitlab.com/commaai/openpilot-lfs.git/info/lfs - pushurl = ssh://git@gitlab.com/commaai/openpilot-lfs.git + url = https://huggingface.co/commaai/openpilot-lfs.git/info/lfs + pushurl = https://huggingface.co/commaai/openpilot-lfs.git/info/lfs locksverify = false From d9ecb0b678e95d88dfbb7a1c4fe7b07a0247b09b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Wed, 9 Sep 2026 18:14:47 -0700 Subject: [PATCH 054/122] Revert "Move Git LFS hosting to Hugging Face" (#38837) --- .lfsconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.lfsconfig b/.lfsconfig index 784a2d8705..42dfa2d944 100644 --- a/.lfsconfig +++ b/.lfsconfig @@ -1,4 +1,4 @@ [lfs] - url = https://huggingface.co/commaai/openpilot-lfs.git/info/lfs - pushurl = https://huggingface.co/commaai/openpilot-lfs.git/info/lfs + url = https://gitlab.com/commaai/openpilot-lfs.git/info/lfs + pushurl = ssh://git@gitlab.com/commaai/openpilot-lfs.git locksverify = false From b97a2d82018efe4a65cc961ea962c30df88d47b0 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:21:52 -0700 Subject: [PATCH 055/122] eSIM: require internet to delete a profile (#38838) * Guard eSIM profile deletion with internet connectivity check * Simplify eSIM delete connectivity guard --- .../selfdrive/ui/mici/layouts/settings/network/esim_ui.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index 9af7bcde88..b54edec427 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -219,6 +219,9 @@ class EsimProfileButton(BigButton): def _delete_profile(self): if not self._locked and not self._cellular_manager.busy and self._show_delete_btn: + if ui_state.sm["deviceState"].networkType == log.DeviceState.NetworkType.none: + gui_app.push_widget(BigDialog("", tr("Ensure you're connected to the internet and try again."))) + return self._cellular_manager.delete_profile(self._profile.iccid) def _on_nickname_entered(self, nickname: str): From 0cd3f0f20bcf837d9fdcff3266730ee625f54784 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 10 Sep 2026 11:18:54 -0700 Subject: [PATCH 056/122] remove old manifest before rebuild (#38842) invalidate manifest before rebuilt --- openpilot/common/file_chunker.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpilot/common/file_chunker.py b/openpilot/common/file_chunker.py index 2d080c3fff..5bf30de9ab 100755 --- a/openpilot/common/file_chunker.py +++ b/openpilot/common/file_chunker.py @@ -24,6 +24,7 @@ def chunk_file(path, targets): manifest_path, *chunk_paths = targets actual_num_chunks = max(1, math.ceil(os.path.getsize(path) / CHUNK_SIZE)) assert len(chunk_paths) >= actual_num_chunks, f"Allowed {len(chunk_paths)} chunks but needs at least {actual_num_chunks}, for path {path}" + Path(manifest_path).unlink(missing_ok=True) with open(path, 'rb') as f: for chunk_path in chunk_paths: with open(chunk_path, 'wb') as out: From 68d829c7c9e7485f03d64f1f201f8cda137ee9d0 Mon Sep 17 00:00:00 2001 From: Zeph Date: Thu, 10 Sep 2026 14:14:54 -0500 Subject: [PATCH 057/122] selfdrived: no localizer alerts from capnp defaults (#38840) selfdrived: don't alert on a message that was never received posenetInvalid, locationdTemporaryError and paramsdTemporaryError are raised off the capnp defaults of deviceMotion and vehicleParameters before either message has ever arrived. When modeld is down, locationd and paramsd never publish, the defaults read as a nan posenet speed, and that NoEntryAlert can hide the process not running alert that names modeld. Same guard as the big model alert in #38500. --- openpilot/selfdrive/selfdrived/selfdrived.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpilot/selfdrive/selfdrived/selfdrived.py b/openpilot/selfdrive/selfdrived/selfdrived.py index 638448d897..f7636ce427 100755 --- a/openpilot/selfdrive/selfdrived/selfdrived.py +++ b/openpilot/selfdrive/selfdrived/selfdrived.py @@ -398,11 +398,12 @@ class SelfdriveD: self.logged_comm_issue = None if not self.CP.notCar and not big_model_settling: # localization has nothing to work with during the load - if not self.sm['deviceMotion'].posenetOK: + # the defaults of a message that was never received are not a localizer failure + if self.sm.seen['deviceMotion'] and not self.sm['deviceMotion'].posenetOK: self.events.add(EventName.posenetInvalid) - if not self.sm['deviceMotion'].inputsOK: + if self.sm.seen['deviceMotion'] and not self.sm['deviceMotion'].inputsOK: self.events.add(EventName.locationdTemporaryError) - if (not self.sm['vehicleParameters'].valid and cal_status == log.ExtrinsicsCalibration.Status.calibrated and + if (self.sm.seen['vehicleParameters'] and not self.sm['vehicleParameters'].valid and cal_status == log.ExtrinsicsCalibration.Status.calibrated and not TESTING_CLOSET and (not SIMULATION or REPLAY)): self.events.add(EventName.paramsdTemporaryError) From f174e39d7a799d2523da0650e043973a3389451e Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 10 Sep 2026 13:19:44 -0700 Subject: [PATCH 058/122] add powertest for mici (#38843) --- Jenkinsfile | 1 + .../common/hardware/comma/power_monitor.py | 13 +++++++----- openpilot/selfdrive/test/test_power_draw.py | 21 +++++++++++++++---- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index b6b3eec5e3..2aa522e9d3 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -256,6 +256,7 @@ node { deviceStage("chestnut", "mici-chestnut-ci", ["UNSAFE=1", "CHESTNUT=1"], [ step("build", "./openpilot/selfdrive/test/chestnut.sh"), step("model replay", "openpilot/selfdrive/test/process_replay/model_replay.py --chestnut"), + step("test power draw", "./openpilot/selfdrive/test/test_power_draw.py"), ]) }, diff --git a/openpilot/common/hardware/comma/power_monitor.py b/openpilot/common/hardware/comma/power_monitor.py index 296290dae8..9ae2c9e138 100755 --- a/openpilot/common/hardware/comma/power_monitor.py +++ b/openpilot/common/hardware/comma/power_monitor.py @@ -9,22 +9,25 @@ from openpilot.common.realtime import Ratekeeper from openpilot.common.filter_simple import FirstOrderFilter -def read_power(): +def read_power(panda=None): + if panda is not None and panda.get_type() == panda.HW_TYPE_CUATRO: + health = panda.health() + return health['voltage'] * health['current'] / 1e6 with open("/sys/bus/i2c/devices/0-0040/hwmon/hwmon1/power1_input") as f: return int(f.read()) / 1e6 -def sample_power(seconds=5) -> list[float]: +def sample_power(seconds=5, panda=None) -> list[float]: rate = 123 rk = Ratekeeper(rate, print_delay_threshold=None) pwrs = [] for _ in range(rate*seconds): - pwrs.append(read_power()) + pwrs.append(read_power(panda)) rk.keep_time() return pwrs -def get_power(seconds=5): - pwrs = sample_power(seconds) +def get_power(seconds=5, panda=None): + pwrs = sample_power(seconds, panda) return np.mean(pwrs) def wait_for_power(min_pwr, max_pwr, min_secs_in_range, timeout): diff --git a/openpilot/selfdrive/test/test_power_draw.py b/openpilot/selfdrive/test/test_power_draw.py index 1d0be24ef0..d8b234fe60 100755 --- a/openpilot/selfdrive/test/test_power_draw.py +++ b/openpilot/selfdrive/test/test_power_draw.py @@ -5,6 +5,8 @@ import time import unittest import numpy as np from dataclasses import dataclass +from panda import Panda +from openpilot.common.hardware import HARDWARE from openpilot.common.test import OpenpilotTestCase from openpilot.common.utils import tabulate @@ -14,11 +16,14 @@ from opendbc.car.car_helpers import get_demo_car_params from openpilot.common.mock import mock_messages from openpilot.common.params import Params from openpilot.common.hardware.comma.power_monitor import get_power +from openpilot.selfdrive.modeld.helpers import chestnut_present from openpilot.system.manager.process_config import managed_processes from openpilot.system.manager.manager import manager_cleanup SAMPLE_TIME = 2 # seconds to sample power MAX_WARMUP_TIME = 30 # seconds to wait for SAMPLE_TIME consecutive valid samples +MICI = HARDWARE.get_device_type() == "mici" +CHESTNUT = chestnut_present() @dataclass class Proc: @@ -33,9 +38,10 @@ class Proc: return '+'.join(self.procs) +# MICI readings exclude the separately powered Chestnut GPU. PROCS = [ - Proc(['camerad'], 1.65, atol=0.4, msgs=['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState']), - Proc(['modeld'], 1.5, atol=0.2, msgs=['modelV2']), + Proc(['camerad'], 0.85 if MICI else 1.65, atol=0.4, msgs=['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState']), + Proc(['modeld'], 0.45 if MICI and CHESTNUT else 1.5, atol=0.2, msgs=['modelV2']), Proc(['dmonitoringmodeld'], 0.65, atol=0.35, msgs=['driverStateV2']), Proc(['encoderd'], 0.23, msgs=[]), ] @@ -46,6 +52,13 @@ class TestPowerDraw(OpenpilotTestCase): def setup_method(self): Params().put("CarParams", get_demo_car_params().to_bytes(), block=True) + self.panda = None + if MICI: + HARDWARE.reset_internal_panda() + self.addCleanup(HARDWARE.reset_internal_panda) + Panda.wait_for_panda(None, 30) + self.panda = Panda(cli=False) + self.addCleanup(self.panda.close) def teardown_method(self): manager_cleanup() @@ -78,7 +91,7 @@ class TestPowerDraw(OpenpilotTestCase): start_time = time.monotonic() while (time.monotonic() - start_time) < MAX_WARMUP_TIME: - power = get_power(1) + power = get_power(1, self.panda) iteration_msg_counts = {} for msg,sock in socks.items(): iteration_msg_counts[msg] = len(messaging.drain_sock_raw(sock)) @@ -97,7 +110,7 @@ class TestPowerDraw(OpenpilotTestCase): @mock_messages(['deviceMotion']) def test_camera_procs(self, subtests): - baseline = get_power() + baseline = get_power(panda=self.panda) prev = baseline used = {} From 633117b2a9c43aa3fb36d7a4eb58bf0c98b2688d Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 10 Sep 2026 14:02:18 -0700 Subject: [PATCH 059/122] test_onroad in chestnut CI (#38844) test chestnut with live camera frames --- Jenkinsfile | 1 + openpilot/selfdrive/test/test_onroad.py | 44 ++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/Jenkinsfile b/Jenkinsfile index 2aa522e9d3..d94e3aa2be 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -256,6 +256,7 @@ node { deviceStage("chestnut", "mici-chestnut-ci", ["UNSAFE=1", "CHESTNUT=1"], [ step("build", "./openpilot/selfdrive/test/chestnut.sh"), step("model replay", "openpilot/selfdrive/test/process_replay/model_replay.py --chestnut"), + step("onroad tests", "./openpilot/selfdrive/test/test_onroad.py TestChestnutOnroad", [timeout: 120]), step("test power draw", "./openpilot/selfdrive/test/test_power_draw.py"), ]) }, diff --git a/openpilot/selfdrive/test/test_onroad.py b/openpilot/selfdrive/test/test_onroad.py index f524046abe..0b64b3fb39 100755 --- a/openpilot/selfdrive/test/test_onroad.py +++ b/openpilot/selfdrive/test/test_onroad.py @@ -20,8 +20,12 @@ from openpilot.common.basedir import BASEDIR from openpilot.common.timeout import Timeout from openpilot.common.params import Params from openpilot.selfdrive.selfdrived.events import EVENTS, ET -from openpilot.selfdrive.test.helpers import set_params_enabled, release_only +from openpilot.selfdrive.test.helpers import set_params_enabled, release_only, processes_context, log_collector +from openpilot.common.hardware import HARDWARE from openpilot.common.hardware.hw import Paths +from openpilot.common.mock import mock_messages +from opendbc.car.car_helpers import get_demo_car_params +from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled from openpilot.tools.lib.logreader import LogReader from openpilot.tools.lib.log_time_series import msgs_to_time_series @@ -457,5 +461,43 @@ class TestOnroad(OpenpilotTestCase): f"Not engageable for whole segment:\n- selfdriveState.engageable: {Counter(eng)}\n- No entry events: {no_entries}" +@unittest.skipUnless(HARDWARE.get_device_type() == "mici", "requires MICI") +class TestChestnutOnroad(OpenpilotTestCase): + COMMA_HARDWARE_TEST = True + + @mock_messages(['deviceMotion']) + def test_camera_models(self, subtests): + assert chestnut_present() and chestnut_compiled() + Params().put("CarParams", get_demo_car_params().to_bytes(), block=True) + services = ['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState', 'modelV2', 'driverStateV2'] + sm = messaging.SubMaster(services) + pm = messaging.PubMaster(['deviceState']) + device_state = messaging.new_message('deviceState') + device_state.deviceState.deviceType = HARDWARE.get_device_type() + device_state_bytes = device_state.to_bytes() + with processes_context(['camerad', 'calibrationd', 'modeld', 'dmonitoringmodeld']): + with Timeout(60, "camera models didn't start"): + while not all(sm.seen.values()) or not sm.valid['modelV2']: + pm.send('deviceState', device_state_bytes) + sm.update(1000) + with log_collector(services) as (logs, _): + time.sleep(TEST_DURATION) + + msgs = {s: [m for m in logs if m.which() == s] for s in services} + for service, messages in msgs.items(): + with subtests.test(service=service): + expected = TEST_DURATION * SERVICE_LIST[service].frequency + assert np.isclose(len(messages), expected, rtol=0.05, atol=2), f"{service}: expected {expected}, got {len(messages)}" + assert all(m.valid for m in messages) + frame_ids = [getattr(m, service).frameId for m in messages] + assert np.all(np.diff(frame_ids) > 0), f"{service}: repeated or reordered frames" + + camera_frames = {m.narrowRoadCameraState.frameId for m in msgs['narrowRoadCameraState']} + model_frames = {m.modelV2.frameId for m in msgs['modelV2']} + assert len(camera_frames & model_frames) >= TEST_DURATION * SERVICE_LIST['modelV2'].frequency * 0.9 + assert all(m.modelV2.big for m in msgs['modelV2']), "Chestnut fell back to the small model" + assert all(np.isfinite(m.modelV2.position.x).all() for m in msgs['modelV2']) + + if __name__ == "__main__": unittest.main() From 443967ddadc6ca5c27d416dea48f114102c22428 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 10 Sep 2026 14:17:21 -0700 Subject: [PATCH 060/122] enable caching in chestnut CI (#38845) enable caching --- openpilot/selfdrive/test/chestnut.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/selfdrive/test/chestnut.sh b/openpilot/selfdrive/test/chestnut.sh index 1ffd12c9e4..8ccc6722de 100755 --- a/openpilot/selfdrive/test/chestnut.sh +++ b/openpilot/selfdrive/test/chestnut.sh @@ -3,5 +3,5 @@ set -e TARGET=openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl.chunkmanifest rm -f "$TARGET" -SCONSFLAGS="-j2 --cache-disable" ./openpilot/system/manager/build.py +SCONSFLAGS="-j4" ./openpilot/system/manager/build.py test -s "$TARGET" From 511c1f1e4e0ddd7a3006431799b81979ff65bf41 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 10 Sep 2026 14:28:12 -0700 Subject: [PATCH 061/122] chestnut updater: close files explicitly (#38846) chestnut: close USB sysfs files after reading --- openpilot/system/hardware/chestnut/flash.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpilot/system/hardware/chestnut/flash.py b/openpilot/system/hardware/chestnut/flash.py index f0d828031d..d2c8e1dfa3 100755 --- a/openpilot/system/hardware/chestnut/flash.py +++ b/openpilot/system/hardware/chestnut/flash.py @@ -63,9 +63,9 @@ def find_chestnut(): found = [] for d in glob.glob("/sys/bus/usb/devices/*"): try: - vid_pid = (open(d + "/idVendor").read().strip(), open(d + "/idProduct").read().strip()) + vid_pid = (Path(d, "idVendor").read_text().strip(), Path(d, "idProduct").read_text().strip()) if vid_pid in VID_PIDS + ROM_VID_PIDS: - found.append((d, vid_pid, open(d + "/product").read().strip())) + found.append((d, vid_pid, Path(d, "product").read_text().strip())) except OSError: pass if len(found) > 1: @@ -101,7 +101,7 @@ def unbind_drivers(path): def open_device(path): - bus, dev = int(open(path + "/busnum").read()), int(open(path + "/devnum").read()) + bus, dev = int(Path(path, "busnum").read_text()), int(Path(path, "devnum").read_text()) return os.open(f"/dev/bus/usb/{bus:03d}/{dev:03d}", os.O_RDWR) From 9b899fdaa217533042affa43b566758b42bedac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Thu, 10 Sep 2026 16:47:40 -0700 Subject: [PATCH 062/122] Reapply Git LFS hosting on Hugging Face (#38841) --- .lfsconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.lfsconfig b/.lfsconfig index 42dfa2d944..784a2d8705 100644 --- a/.lfsconfig +++ b/.lfsconfig @@ -1,4 +1,4 @@ [lfs] - url = https://gitlab.com/commaai/openpilot-lfs.git/info/lfs - pushurl = ssh://git@gitlab.com/commaai/openpilot-lfs.git + url = https://huggingface.co/commaai/openpilot-lfs.git/info/lfs + pushurl = https://huggingface.co/commaai/openpilot-lfs.git/info/lfs locksverify = false From e9711bc51d10fa16b59a30475830464d24bb0c41 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 10 Sep 2026 17:26:33 -0700 Subject: [PATCH 063/122] longer SSH keepalive for chestnut model load (#38851) ci: allow longer SSH keepalive gaps --- Jenkinsfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jenkinsfile b/Jenkinsfile index d94e3aa2be..0f4945dab6 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -12,7 +12,7 @@ def retryWithDelay(int maxRetries, int delay, Closure body) { def device(String ip, String step_label, String cmd) { withCredentials([file(credentialsId: 'id_rsa', variable: 'key_file')]) { def ssh_cmd = """ -ssh -o ControlMaster=auto -o ControlPath=/tmp/ssh_control_%C -o ControlPersist=yes -o ConnectTimeout=5 -o ServerAliveInterval=5 -o ServerAliveCountMax=2 -o BatchMode=yes -o StrictHostKeyChecking=no -i ${key_file} 'comma@${ip}' exec /usr/bin/bash <<'END' +ssh -o ControlMaster=auto -o ControlPath=/tmp/ssh_control_%C -o ControlPersist=yes -o ConnectTimeout=5 -o ServerAliveInterval=5 -o ServerAliveCountMax=12 -o BatchMode=yes -o StrictHostKeyChecking=no -i ${key_file} 'comma@${ip}' exec /usr/bin/bash <<'END' set -e From e6f401b5cfb16e6d13990fb71b00cbc573634642 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 10 Sep 2026 17:38:04 -0700 Subject: [PATCH 064/122] build nightly-chestnut (#38849) build nightly-chestnut --- .github/workflows/release.yaml | 4 ++++ Jenkinsfile | 8 +++++++- openpilot/system/hardware/chestnut/status.py | 2 +- tools/release/build_release.sh | 2 +- tools/release/build_stripped.sh | 3 +++ 5 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 26b28ab330..a5a91a482b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -34,3 +34,7 @@ jobs: - run: ./tools/op.sh setup - name: Push master-ci run: BRANCH=__nightly tools/release/build_stripped.sh + - name: Push chestnut nightly + run: | + git lfs pull --exclude='' + INCLUDE_BIG_MODEL=1 BRANCH=__nightly-chestnut tools/release/build_stripped.sh diff --git a/Jenkinsfile b/Jenkinsfile index 0f4945dab6..d46d599457 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -169,7 +169,7 @@ node { env.GIT_BRANCH = checkout(scm).GIT_BRANCH env.GIT_COMMIT = checkout(scm).GIT_COMMIT - def excludeBranches = ['__nightly', 'devel', 'devel-staging', + def excludeBranches = ['__nightly', '__nightly-chestnut', 'devel', 'devel-staging', 'release-tizi', 'release-tizi-staging', 'release-mici', 'release-mici-staging', 'testing-closet*', 'hotfix-*'] def excludeRegex = excludeBranches.join('|').replaceAll('\\*', '.*') @@ -201,6 +201,12 @@ node { ) } + if (env.BRANCH_NAME == '__nightly-chestnut') { + deviceStage("build nightly-chestnut", "mici-chestnut-ci", [], [ + step("build nightly-chestnut", "SCONSFLAGS=-j4 INCLUDE_BIG_MODEL=1 RELEASE_BRANCH=nightly-chestnut $SOURCE_DIR/tools/release/build_release.sh TestChestnutOnroad"), + ]) + } + if (!env.BRANCH_NAME.matches(excludeRegex)) { parallel ( 'onroad tests': { diff --git a/openpilot/system/hardware/chestnut/status.py b/openpilot/system/hardware/chestnut/status.py index c3321a3971..8c38987dff 100644 --- a/openpilot/system/hardware/chestnut/status.py +++ b/openpilot/system/hardware/chestnut/status.py @@ -4,7 +4,7 @@ from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, is_chestnut_usb_ from openpilot.selfdrive.modeld.helpers import chestnut_compiled -CHESTNUT_RELEASE_BRANCHES = ("release-chestnut", "release-chestnut-staging") +CHESTNUT_RELEASE_BRANCHES = ("release-chestnut", "release-chestnut-staging", "nightly-chestnut") CHESTNUT_POWERED_VOLTAGE = 5000 GPU_TEMP_LIMIT = 100. MEMORY_TEMP_LIMIT = 95. diff --git a/tools/release/build_release.sh b/tools/release/build_release.sh index 4bc5dd2e68..cced9f7a54 100755 --- a/tools/release/build_release.sh +++ b/tools/release/build_release.sh @@ -89,7 +89,7 @@ git -c core.compression=0 -c gc.auto=0 commit -m "openpilot v$VERSION" # Run tests cd $BUILD_DIR -RELEASE=1 ./openpilot/selfdrive/test/test_onroad.py +RELEASE=1 ./openpilot/selfdrive/test/test_onroad.py "$@" #tools/test_runner.py openpilot/selfdrive/car/tests/test_car_interfaces.py echo "[-] pushing release T=$SECONDS" diff --git a/tools/release/build_stripped.sh b/tools/release/build_stripped.sh index ba4c847375..a216dfe11c 100755 --- a/tools/release/build_stripped.sh +++ b/tools/release/build_stripped.sh @@ -78,6 +78,9 @@ fi if [ ! -z "$BRANCH" ]; then echo "[-] Pushing to $BRANCH T=$SECONDS" + # Reset hooks since releases exclude .venv + git config --local core.hooksPath .git/hooks + git lfs update --force # uploading the larger pack is faster than spending CPU to optimize it git -c pack.window=0 -c pack.depth=0 -c pack.compression=0 push -f origin tmp:$BRANCH fi From 2a70b40cdc315ba47ee8e2dc7c87d10269cb2478 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 10 Sep 2026 18:14:23 -0700 Subject: [PATCH 065/122] rebuild models when helpers change (#38853) rebuild models when serialization helpers change --- openpilot/selfdrive/modeld/SConscript | 1 + 1 file changed, 1 insertion(+) diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index 9a10fed585..d2dacc8245 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -62,6 +62,7 @@ modeld_dir = Dir("#openpilot/selfdrive/modeld").abspath compile_modeld_script = [ File(f"{modeld_dir}/compile_modeld.py"), File(f"{modeld_dir}/get_model_metadata.py"), + File(f"{modeld_dir}/helpers.py"), File("#openpilot/system/camerad/cameras/nv12_info.py"), File("#openpilot/common/hardware/hw.py"), ] From f87e170bee9d90676fbe1eae7deff45ab7f6d610 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 10 Sep 2026 18:17:40 -0700 Subject: [PATCH 066/122] reject incomplete model buffers (#38852) --- openpilot/selfdrive/modeld/helpers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpilot/selfdrive/modeld/helpers.py b/openpilot/selfdrive/modeld/helpers.py index d081050055..0fdd1629ba 100644 --- a/openpilot/selfdrive/modeld/helpers.py +++ b/openpilot/selfdrive/modeld/helpers.py @@ -41,7 +41,8 @@ def load_oob(f): def buffers(): while (h := f.read(8)): pb = pickle.PickleBuffer(bytearray(struct.unpack(' Date: Thu, 10 Sep 2026 20:57:57 -0700 Subject: [PATCH 067/122] enable alpha long on nightly-chestnut (#38854) panda debug build --- Jenkinsfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jenkinsfile b/Jenkinsfile index d46d599457..d2eeaa1600 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -203,7 +203,7 @@ node { if (env.BRANCH_NAME == '__nightly-chestnut') { deviceStage("build nightly-chestnut", "mici-chestnut-ci", [], [ - step("build nightly-chestnut", "SCONSFLAGS=-j4 INCLUDE_BIG_MODEL=1 RELEASE_BRANCH=nightly-chestnut $SOURCE_DIR/tools/release/build_release.sh TestChestnutOnroad"), + step("build nightly-chestnut", "SCONSFLAGS=-j4 INCLUDE_BIG_MODEL=1 PANDA_DEBUG_BUILD=1 RELEASE_BRANCH=nightly-chestnut $SOURCE_DIR/tools/release/build_release.sh TestChestnutOnroad"), ]) } From 6c410eee37fed231a2d61c0b8d24edb3c90cb5ec Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 10 Sep 2026 21:06:22 -0700 Subject: [PATCH 068/122] validate compiled model (#38855) * validate serialized models after writing --- openpilot/selfdrive/modeld/compile_modeld.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpilot/selfdrive/modeld/compile_modeld.py b/openpilot/selfdrive/modeld/compile_modeld.py index d851c3cc86..52be6897c0 100755 --- a/openpilot/selfdrive/modeld/compile_modeld.py +++ b/openpilot/selfdrive/modeld/compile_modeld.py @@ -329,4 +329,7 @@ if __name__ == "__main__": with open(args.output, "wb") as f: dump_oob(out, f) + with open(args.output, "rb") as f: + load_oob(f) + assert not f.read(1), "unexpected model buffer data" print(f"Saved JITs to {args.output} ({os.path.getsize(args.output) / 1e6:.2f} MB)") From 6107da1cd655e3b2ac44c0b28728ab0619e42eb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Thu, 10 Sep 2026 21:24:09 -0700 Subject: [PATCH 069/122] Cinque v2 (#38850) 1a421175-db71-4e3d-9d62-e2166421b02b/12864 --- openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx b/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx index af98beabcb..13e3d32f47 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx +++ b/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e8d821733be15ebe9e27498bc27ad8bbbd741980ece37d77f377294010b8ff28 -size 765950064 +oid sha256:09d080f36965bb2a0790500452bd328aa03c484d0222aa79d1ad9f021a522aec +size 766040736 From 73720bfd08f7e74d614e3947029f82840201ba23 Mon Sep 17 00:00:00 2001 From: Bhavya Gada Date: Fri, 11 Sep 2026 15:29:13 -0700 Subject: [PATCH 070/122] Get MetaDrive simulator working on macOS (#38830) --- openpilot/selfdrive/modeld/SConscript | 5 +++-- openpilot/system/hardware/hardwared.py | 9 +++++--- openpilot/system/ui/lib/wifi_manager.py | 3 ++- openpilot/tools/sim/lib/camerad.py | 28 +++++++++++++++---------- 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index d2dacc8245..2f3e19a315 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -29,8 +29,9 @@ if arch == 'comma_arm64': tg_backend = 'QCOM' tg_flags = f'DEV={tg_backend} IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1' else: - tg_backend = 'CPU' - tg_flags = f'DEV=CPU' if arch == 'Darwin' else 'DEV=CPU:LLVM' + tg_backend = 'METAL' if arch == 'Darwin' else 'CPU' + # JIT=2 disables graph batching, which produces incorrect outputs after buffers change. + tg_flags = 'DEV=METAL JIT=2' if arch == 'Darwin' else 'DEV=CPU:LLVM' tg_devices = { # which device to put jit inputs to at runtime 'openpilot.selfdrive.modeld.dmonitoringmodeld': { diff --git a/openpilot/system/hardware/hardwared.py b/openpilot/system/hardware/hardwared.py index 11fe41400d..8a5436b66f 100755 --- a/openpilot/system/hardware/hardwared.py +++ b/openpilot/system/hardware/hardwared.py @@ -194,7 +194,7 @@ def hw_state_thread(end_event, hw_queue): def hardware_thread(end_event, hw_queue) -> None: - system_stats = LinuxSystemStats() + system_stats = LinuxSystemStats() if sys.platform == "linux" else None pm = messaging.PubMaster(['deviceState']) sm = messaging.SubMaster(["peripheralState", "gpsLocationExternal", "selfdriveState", "pandaStates", "chestnutState"], poll="pandaStates") @@ -287,10 +287,13 @@ def hardware_thread(end_event, hw_queue) -> None: except queue.Empty: pass + memory_usage = system_stats.memory_usage_percent() if system_stats is not None else 0. + cpu_usage = system_stats.cpu_usage_percent() if system_stats is not None else [] + msg.deviceState.freeSpacePercent = get_available_percent(default=100.0) - msg.deviceState.memoryUsagePercent = int(round(system_stats.memory_usage_percent())) + msg.deviceState.memoryUsagePercent = int(round(memory_usage)) msg.deviceState.gpuUsagePercent = int(round(HARDWARE.get_gpu_usage_percent())) - online_cpu_usage = [int(round(n)) for n in system_stats.cpu_usage_percent()] + online_cpu_usage = [int(round(n)) for n in cpu_usage] offline_cpu_usage = [0., ] * (len(msg.deviceState.cpuTempC) - len(online_cpu_usage)) msg.deviceState.cpuUsagePercent = online_cpu_usage + offline_cpu_usage diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 26474be942..cd487f5636 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -203,7 +203,8 @@ class WifiManager: self._scan_lock = threading.Lock() self._scan_thread = threading.Thread(target=self._network_scanner, daemon=True) self._state_thread = threading.Thread(target=self._monitor_state, daemon=True) - self._initialize() + if not self._exit: + self._initialize() atexit.register(self.stop) def _initialize(self): diff --git a/openpilot/tools/sim/lib/camerad.py b/openpilot/tools/sim/lib/camerad.py index 206e4fdf90..7d8782d9a8 100644 --- a/openpilot/tools/sim/lib/camerad.py +++ b/openpilot/tools/sim/lib/camerad.py @@ -4,6 +4,7 @@ from openpilot.cereal.visionipc import VisionStreamType from msgq.visionipc import VisionIpcServer from openpilot.cereal import messaging +from openpilot.system.camerad.cameras.nv12_info import get_nv12_info from openpilot.tools.sim.lib.common import W, H @@ -23,17 +24,20 @@ def rgb_to_nv12(rgb): g_sub = (g[0::2, 0::2] + g[0::2, 1::2] + g[1::2, 0::2] + g[1::2, 1::2] + 2) >> 2 b_sub = (b[0::2, 0::2] + b[0::2, 1::2] + b[1::2, 0::2] + b[1::2, 1::2] + 2) >> 2 - # U and V planes + # Interleave U and V planes for NV12 format u = np.clip((b_sub * 56 - g_sub * 37 - r_sub * 19 + 0x8080) >> 8, 0, 255).astype(np.uint8) v = np.clip((r_sub * 56 - g_sub * 47 - b_sub * 9 + 0x8080) >> 8, 0, 255).astype(np.uint8) + uv = np.stack((u, v), axis=-1).reshape(h // 2, w) - # Interleave UV for NV12 format - uv = np.empty((h // 2, w), dtype=np.uint8) - uv[:, 0::2] = u - uv[:, 1::2] = v + # Copy the visible image into the aligned NV12 buffer + stride, y_height, uv_height, size = get_nv12_info(w, h) + nv12 = np.zeros(size, dtype=np.uint8) + planes = nv12[:stride * (y_height + uv_height)].reshape(-1, stride) - return np.concatenate([y.ravel(), uv.ravel()]).tobytes() + planes[:h, :w] = y + planes[y_height:y_height + h // 2, :w] = uv + return nv12.tobytes() class Camerad: """Simulates the camerad daemon""" @@ -44,9 +48,12 @@ class Camerad: self.frame_wide_id = 0 self.vipc_server = VisionIpcServer("camerad") - self.vipc_server.create_buffers(VisionStreamType.VISION_STREAM_NARROW_ROAD, 5, W, H) + stride, y_height, _, size = get_nv12_info(W, H) + buffer_args = (5, W, H, size, stride, stride * y_height) + + self.vipc_server.create_buffers_with_sizes(VisionStreamType.VISION_STREAM_NARROW_ROAD, *buffer_args) if dual_camera: - self.vipc_server.create_buffers(VisionStreamType.VISION_STREAM_WIDE_ROAD, 5, W, H) + self.vipc_server.create_buffers_with_sizes(VisionStreamType.VISION_STREAM_WIDE_ROAD, *buffer_args) self.vipc_server.start_listener() @@ -65,10 +72,9 @@ class Camerad: return rgb_to_nv12(rgb) def _send_yuv(self, yuv, frame_id, pub_type, yuv_type): - eof = int(frame_id * 0.05 * 1e9) - self.vipc_server.send(yuv_type, yuv, frame_id, eof, eof) - dat = messaging.new_message(pub_type, valid=True) + self.vipc_server.send(yuv_type, yuv, frame_id, dat.logMonoTime, dat.logMonoTime) + msg = { "frameId": frame_id, "transform": [1.0, 0.0, 0.0, From 6965492e66c924b66e5796c5916cbf868e1f1775 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Fri, 11 Sep 2026 17:25:33 -0700 Subject: [PATCH 071/122] clean up after jenkins disconnect (#38865) * fix SSH build cleanup * simplify SSH cleanup --- Jenkinsfile | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index d2eeaa1600..a5dfa86fb8 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -12,13 +12,13 @@ def retryWithDelay(int maxRetries, int delay, Closure body) { def device(String ip, String step_label, String cmd) { withCredentials([file(credentialsId: 'id_rsa', variable: 'key_file')]) { def ssh_cmd = """ -ssh -o ControlMaster=auto -o ControlPath=/tmp/ssh_control_%C -o ControlPersist=yes -o ConnectTimeout=5 -o ServerAliveInterval=5 -o ServerAliveCountMax=12 -o BatchMode=yes -o StrictHostKeyChecking=no -i ${key_file} 'comma@${ip}' exec /usr/bin/bash <<'END' +ssh -o ControlMaster=no -o ControlPath=none -o ConnectTimeout=5 -o ServerAliveInterval=5 -o ServerAliveCountMax=12 -o BatchMode=yes -o StrictHostKeyChecking=no -i ${key_file} 'comma@${ip}' exec setpriv --pdeathsig HUP /usr/bin/bash <<'END' set -e export TERM=xterm-256color -shopt -s huponexit # kill all child processes when the shell exits +trap 'kill 0' HUP # stop this process group on SSH disconnect export CI=1 export PYTHONWARNINGS=error @@ -69,7 +69,8 @@ export LD_LIBRARY_PATH="\$(python -c 'import ffmpeg; print(ffmpeg.LIB_DIR)'):/us ln -snf ${env.TEST_DIR} /data/pythonpath cd ${env.TEST_DIR} || true -time ${cmd} +time ( ${cmd} ) & +wait \$! END""" sh script: ssh_cmd, label: step_label From 0cf294d85fbaffd94a83f08536cf7a1bb3e75c80 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Fri, 11 Sep 2026 20:13:00 -0700 Subject: [PATCH 072/122] ui: add descriptions for settings (#38867) --- .../ui/mici/layouts/settings/developer.py | 39 ++++---- .../ui/mici/layouts/settings/device.py | 17 ++-- .../mici/layouts/settings/network/__init__.py | 8 +- .../settings/network/network_layout.py | 14 ++- .../ui/mici/layouts/settings/software.py | 7 +- .../ui/mici/layouts/settings/toggles.py | 34 +++++-- openpilot/selfdrive/ui/mici/widgets/button.py | 92 +++++++++++-------- openpilot/selfdrive/ui/mici/widgets/dialog.py | 27 ++++++ openpilot/system/ui/widgets/__init__.py | 19 ++++ 9 files changed, 176 insertions(+), 81 deletions(-) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/developer.py b/openpilot/selfdrive/ui/mici/layouts/settings/developer.py index 2d7dc0899d..0b52e48bff 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/developer.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/developer.py @@ -23,8 +23,6 @@ class AlphaLongConfirmPage(NavScroller): GreyBigButton("", "WARNING: alpha longitudinal control may disable Automatic Emergency Braking (AEB)"), GreyBigButton("", "On this car, openpilot defaults to the stock system's built-in ACC."), GreyBigButton("", "Enabling this will switch to openpilot longitudinal control."), - GreyBigButton("", "Using Experimental mode is recommended with openpilot longitudinal control alpha."), - GreyBigButton("", "Changing this setting will restart openpilot if the car is powered on."), accept, ]) @@ -63,28 +61,31 @@ class DeveloperLayoutMici(NavScroller): txt_ssh = gui_app.texture("icons_mici/settings/developer/ssh.png", 56, 64) github_username = ui_state.params.get("GithubUsername") or "" - self._ssh_keys_btn = BigButton("SSH keys", "Not set" if not github_username else github_username, icon=txt_ssh) + self._ssh_keys_btn = BigButton("SSH keys", "Not set" if not github_username else github_username, icon=txt_ssh, + description="Grant SSH access to all public keys in your GitHub settings. Only enter your own username.") self._ssh_keys_btn.set_click_callback(ssh_keys_callback) # adb, ssh, ssh keys, debug mode, joystick debug mode, longitudinal maneuver mode, ip address # ******** Main Scroller ******** - self._adb_toggle = BigCircleParamControl(gui_app.texture("icons_mici/adb_short.png", 82, 82), "AdbEnabled", icon_offset=(0, 12)) - self._ssh_toggle = BigCircleParamControl(gui_app.texture("icons_mici/ssh_short.png", 82, 82), "SshEnabled", icon_offset=(0, 12)) - self._joystick_toggle = BigToggle("joystick debug mode", - initial_state=ui_state.params.get_bool("JoystickDebugMode"), - toggle_callback=self._on_joystick_debug_mode) - self._long_maneuver_toggle = BigToggle("longitudinal maneuver mode", - initial_state=ui_state.params.get_bool("LongitudinalManeuverMode"), - toggle_callback=self._on_long_maneuver_mode) - self._lat_maneuver_toggle = BigToggle("lateral maneuver mode", - initial_state=ui_state.params.get_bool("LateralManeuverMode"), - toggle_callback=self._on_lat_maneuver_mode) - self._alpha_long_toggle = BigToggle("alpha longitudinal", - initial_state=ui_state.params.get_bool("AlphaLongitudinalEnabled"), - toggle_callback=self._on_alpha_long_enabled) + self._adb_toggle = BigCircleParamControl(gui_app.texture("icons_mici/adb_short.png", 82, 82), "AdbEnabled", icon_offset=(0, 12), + description="Use Android Debug Bridge (ADB) over USB or the network.", title="enable ADB") + self._ssh_toggle = BigCircleParamControl(gui_app.texture("icons_mici/ssh_short.png", 82, 82), "SshEnabled", icon_offset=(0, 12), + description="Access the device remotely using your SSH keys.", title="enable SSH") + self._joystick_toggle = BigToggle("joystick debug\nmode", initial_state=ui_state.params.get_bool("JoystickDebugMode"), + toggle_callback=self._on_joystick_debug_mode, description="Control the car with a joystick for debugging.") + self._long_maneuver_toggle = BigToggle("longitudinal maneuver mode", initial_state=ui_state.params.get_bool("LongitudinalManeuverMode"), + toggle_callback=self._on_long_maneuver_mode, + description="Run longitudinal maneuvers for testing gas and brake control.") + self._lat_maneuver_toggle = BigToggle("lateral maneuver mode", initial_state=ui_state.params.get_bool("LateralManeuverMode"), + toggle_callback=self._on_lat_maneuver_mode, + description="Run lateral maneuvers for testing steering control.") + self._alpha_long_toggle = BigToggle("alpha longitudinal", initial_state=ui_state.params.get_bool("AlphaLongitudinalEnabled"), + toggle_callback=self._on_alpha_long_enabled, + description="Use alpha openpilot longitudinal control instead of stock ACC. This may disable Automatic Emergency " + + "Braking (AEB).") self._debug_mode_toggle = BigParamControl("ui debug mode", "ShowDebugInfo", - toggle_callback=lambda checked: (gui_app.set_show_touches(checked), - gui_app.set_show_fps(checked))) + toggle_callback=lambda checked: (gui_app.set_show_touches(checked), gui_app.set_show_fps(checked)), + description="Show touch locations and the UI frame rate.") self._scroller.add_widgets([ self._adb_toggle, diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/device.py b/openpilot/selfdrive/ui/mici/layouts/settings/device.py index 6038ff7d35..c038f5fddc 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/device.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/device.py @@ -1,6 +1,7 @@ import os import pyray as rl from collections.abc import Callable +from typing import Union from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params @@ -77,15 +78,16 @@ def _engaged_confirmation_click(callback: Callable, action_text: str, icon: rl.T class EngagedConfirmationCircleButton(BigCircleButton): def __init__(self, title: str, icon: rl.Texture, callback: Callable[[], None], exit_on_confirm: bool = True, - red: bool = False, icon_offset: tuple[int, int] = (0, 0)): - super().__init__(icon, red, icon_offset) + red: bool = False, icon_offset: tuple[int, int] = (0, 0), *, description: str = ""): + super().__init__(icon, red, icon_offset, description=description, title=title) self.set_click_callback(lambda: _engaged_confirmation_click(callback, title, icon, exit_on_confirm=exit_on_confirm, red=red)) class EngagedConfirmationButton(BigButton): def __init__(self, text: str, action_text: str, icon: rl.Texture, callback: Callable[[], None], - exit_on_confirm: bool = True, red: bool = False): - super().__init__(text, "", icon) + exit_on_confirm: bool = True, red: bool = False, *, description: str = "", + description_icon: Union[rl.Texture, None] = None): + super().__init__(text, "", icon, description=description, description_icon=description_icon) self.set_click_callback(lambda: _engaged_confirmation_click(callback, action_text, icon, exit_on_confirm=exit_on_confirm, red=red)) @@ -177,7 +179,9 @@ class DeviceLayoutMici(NavScroller): params.put_bool("OnroadCycleRequested", True, block=True) reset_calibration_btn = EngagedConfirmationButton("reset calibration", "reset", gui_app.texture("icons_mici/settings/device/lkas.png", 122, 64), - reset_calibration_callback) + reset_calibration_callback, + description="Mount the device within 4° left or right and 5° up or 9° down. openpilot calibrates " + + "continuously; resetting is rarely needed. Resetting clears learned calibration.") reboot_btn = EngagedConfirmationCircleButton("reboot", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70), reboot_callback, exit_on_confirm=False) @@ -189,7 +193,8 @@ class DeviceLayoutMici(NavScroller): regulatory_btn = BigButton("regulatory info", "", gui_app.texture("icons_mici/settings/device/info.png", 64, 64)) regulatory_btn.set_click_callback(self._on_regulatory) - cabin_cam_btn = BigButton("driver\ncamera preview", "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64)) + cabin_cam_btn = BigButton("driver\ncamera preview", "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64), + description="Preview the cabin camera to check driver monitoring visibility. The vehicle must be off.") cabin_cam_btn.set_click_callback(lambda: gui_app.push_widget(CabinCameraDialog())) cabin_cam_btn.set_enabled(lambda: ui_state.is_offroad()) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py index 89c106e236..bc297ed0e0 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -13,7 +13,7 @@ NetworkType = log.DeviceState.NetworkType class EsimNetworkButton(BigButton): - def __init__(self, cellular_manager: CellularManager): + def __init__(self, cellular_manager: CellularManager, *, description: str = ""): self._cellular_manager = cellular_manager self._cell_icons = { NetworkStrength.unknown: gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 64, 47), @@ -22,7 +22,7 @@ class EsimNetworkButton(BigButton): NetworkStrength.good: gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 64, 47), NetworkStrength.great: gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 64, 47), } - super().__init__("esim", "loading...", self._cell_icons[NetworkStrength.unknown], scroll=True) + super().__init__("esim", "loading...", self._cell_icons[NetworkStrength.unknown], scroll=True, description=description) def _update_state(self): super()._update_state() @@ -54,7 +54,7 @@ class EsimNetworkButton(BigButton): class WifiNetworkButton(BigButton): - def __init__(self, wifi_manager: WifiManager): + def __init__(self, wifi_manager: WifiManager, *, description: str = ""): self._wifi_manager = wifi_manager self._lock_txt = gui_app.texture("icons_mici/settings/network/new/lock.png", 28, 36) self._draw_lock = False @@ -64,7 +64,7 @@ class WifiNetworkButton(BigButton): self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 64, 47) self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 64, 47) - super().__init__("wi-fi", "not connected", self._wifi_slash_txt, scroll=True) + super().__init__("wi-fi", "not connected", self._wifi_slash_txt, scroll=True, description=description) def _update_state(self): super()._update_state() diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py index d991ed5806..4203336469 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py @@ -29,7 +29,8 @@ class NetworkLayoutMici(NavScroller): self._network_metered_btn.set_enabled(False) self._wifi_manager.set_tethering_active(checked) - self._tethering_toggle_btn = BigToggle("enable tethering", "", toggle_callback=tethering_toggle_callback) + self._tethering_toggle_btn = BigToggle("enable tethering", "", toggle_callback=tethering_toggle_callback, + description="Share the device’s internet connection through a Wi-Fi hotspot.") def tethering_password_callback(password: str): if password: @@ -59,7 +60,9 @@ class NetworkLayoutMici(NavScroller): # TODO: signal for current network metered type when changing networks, this is wrong until you press it once # TODO: disable when not connected - self._network_metered_btn = BigMultiToggle("network usage", ["default", "metered", "unmetered"], select_callback=network_metered_callback) + self._network_metered_btn = BigMultiToggle("network usage", ["default", "metered", "unmetered"], select_callback=network_metered_callback, + description="Metered prevents large uploads on this Wi-Fi connection. Default uses the network’s detected " + + "setting.") self._network_metered_btn.set_enabled(False) self._wifi_button = WifiNetworkButton(self._wifi_manager) @@ -76,14 +79,15 @@ class NetworkLayoutMici(NavScroller): # ******** Advanced settings ******** # ******** Roaming toggle ******** - self._roaming_btn = BigParamControl("enable roaming", "GsmRoaming") + self._roaming_btn = BigParamControl("enable roaming", "GsmRoaming", description="Allow cellular data roaming.") # ******** APN settings ******** - self._apn_btn = BigButton("apn settings", "edit") + self._apn_btn = BigButton("apn settings", "edit", + description="Set the access point name required by your cellular carrier. Leave blank for automatic configuration.") self._apn_btn.set_click_callback(self._edit_apn) # ******** Cellular metered toggle ******** - self._cellular_metered_btn = BigParamControl("cellular metered", "GsmMetered") + self._cellular_metered_btn = BigParamControl("cellular metered", "GsmMetered", description="Prevent large uploads over the cellular connection.") # Main scroller ---------------------------------- self._scroller.add_widgets([ diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/software.py b/openpilot/selfdrive/ui/mici/layouts/settings/software.py index 539ebaca11..7bf17713a3 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/software.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/software.py @@ -242,7 +242,8 @@ class BranchSelectPage(NavScroller): class TargetBranchButton(BigButton): def __init__(self, check_update_btn: CheckUpdateButton): - super().__init__("target branch", ui_state.params.get("UpdaterTargetBranch") or "") + super().__init__("target branch", ui_state.params.get("UpdaterTargetBranch") or "", + description="Select the software branch to download on the next update check.") self._check_update_btn = check_update_btn self.set_click_callback(self._on_click) self.set_visible(not ui_state.params.get_bool("IsTestedBranch")) @@ -276,7 +277,9 @@ class SoftwareLayoutMici(NavScroller): uninstall_openpilot_btn = EngagedConfirmationButton("uninstall openpilot", "uninstall", gui_app.texture("icons_mici/settings/device/uninstall.png", 64, 64), - uninstall_openpilot_callback, exit_on_confirm=False) + uninstall_openpilot_callback, exit_on_confirm=False, + description="Remove openpilot from this device.", + description_icon=gui_app.texture("icons_mici/setup/factory_reset.png", 64, 64)) check_update_btn = CheckUpdateButton() self._scroller.add_widgets([ diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py index 4d5113448d..40a6188579 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py @@ -41,15 +41,33 @@ class TogglesLayoutMici(NavScroller): def __init__(self): super().__init__() - self._personality_toggle = BigMultiParamToggle("driving personality", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"]) - self._experimental_btn = BigToggle("experimental mode", initial_state=ui_state.params.get_bool("ExperimentalMode"), - toggle_callback=self._on_experimental_mode) + self._personality_toggle = BigMultiParamToggle("driving personality", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"], + description="Standard is recommended.\n" + + "Aggressive follows closer, with firmer gas and braking.\n" + + "Relaxed leaves more space.\n" + + "Use the steering wheel distance button on supported cars.") + self._experimental_btn = BigToggle("experimental mode", description_icon=gui_app.texture("icons_mici/experimental_mode.png", 64, 64), + initial_state=ui_state.params.get_bool("ExperimentalMode"), toggle_callback=self._on_experimental_mode, + description="Let the driving model control gas and brakes.\n" + + "Includes stopping for red lights and stop signs.\n" + + "Set speed is a maximum, not a target.\n" + + "These are alpha features. Expect mistakes.\n" + + "The path colors show acceleration and braking.") is_metric_toggle = BigParamControl("use metric units", "IsMetric") - ldw_toggle = BigParamControl("lane departure warnings", "IsLdwEnabled") - always_on_dm_toggle = BigParamControl("always-on driver monitor", "AlwaysOnDM") - record_front = BigParamControl("record & upload cabin camera", "RecordFront", toggle_callback=restart_needed_callback) - record_mic = BigParamControl("record & upload mic audio", "RecordAudio", toggle_callback=restart_needed_callback) - enable_openpilot = BigParamControl("enable openpilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback) + ldw_toggle = BigParamControl("lane departure warnings", "IsLdwEnabled", + description="Warn when you drift across a detected lane line.\n" + + "Only above 31 mph (50 km/h), with no turn signal.") + always_on_dm_toggle = BigParamControl("always-on driver monitor", "AlwaysOnDM", description="Monitor the driver even when openpilot is not engaged.") + record_front = BigParamControl("record & upload cabin camera", "RecordFront", + description_icon=gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64), + toggle_callback=restart_needed_callback, description="Upload cabin camera data to help improve driver monitoring.") + record_mic = BigParamControl("record & upload mic audio", "RecordAudio", description_icon=gui_app.texture("icons_mici/microphone.png", 64, 64), + toggle_callback=restart_needed_callback, + description="Record microphone audio while driving.\n" + + "Audio is included in dashcam videos in comma connect.") + enable_openpilot = BigParamControl("enable openpilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback, + description="Enable to use openpilot driver assistance.\n" + + "Disable to use your car's stock driver assistance.") self._scroller.add_widgets([ self._personality_toggle, diff --git a/openpilot/selfdrive/ui/mici/widgets/button.py b/openpilot/selfdrive/ui/mici/widgets/button.py index 59a3f95191..4f395ae9bc 100644 --- a/openpilot/selfdrive/ui/mici/widgets/button.py +++ b/openpilot/selfdrive/ui/mici/widgets/button.py @@ -29,9 +29,41 @@ class ScrollState(Enum): POST_SCROLL = 2 -class BigCircleButton(Widget): - def __init__(self, icon: rl.Texture, red: bool = False, icon_offset: tuple[int, int] = (0, 0)): +class BaseButton(Widget): + def __init__(self, description: str, title: str, icon: Union[rl.Texture, None] = None): super().__init__() + self._shake_start: float | None = None + if description: + # Dialogs also use buttons; import lazily to avoid a circular import. + from openpilot.selfdrive.ui.mici.widgets.dialog import SettingDescriptionDialog + self.set_long_press_callback(lambda: gui_app.push_widget(SettingDescriptionDialog(title, description, icon))) + else: + self.set_long_press_callback(self.trigger_shake) + + def trigger_shake(self): + self._shake_start = rl.get_time() + + @property + def _shake_offset(self) -> float: + SHAKE_DURATION = 0.5 + SHAKE_AMPLITUDE = 24.0 + SHAKE_FREQUENCY = 32.0 + if self._shake_start is None: + return 0.0 + t = rl.get_time() - self._shake_start + if t > SHAKE_DURATION: + return 0.0 + decay = 1.0 - t / SHAKE_DURATION + return decay * SHAKE_AMPLITUDE * math.sin(t * SHAKE_FREQUENCY) + + def set_position(self, x: float, y: float) -> None: + super().set_position(x + self._shake_offset, y) + +class BigCircleButton(BaseButton): + def __init__(self, icon: rl.Texture, red: bool = False, icon_offset: tuple[int, int] = (0, 0), + *, description: str = "", + description_icon: Union[rl.Texture, None] = None, title: str = ""): + super().__init__(description, title, description_icon or icon) self._red = red self._icon_offset = icon_offset @@ -73,8 +105,9 @@ class BigCircleButton(Widget): class BigCircleToggle(BigCircleButton): - def __init__(self, icon: rl.Texture, toggle_callback: Callable | None = None, icon_offset: tuple[int, int] = (0, 0)): - super().__init__(icon, False, icon_offset=icon_offset) + def __init__(self, icon: rl.Texture, toggle_callback: Callable | None = None, icon_offset: tuple[int, int] = (0, 0), + *, description: str = "", description_icon: Union[rl.Texture, None] = None, title: str = ""): + super().__init__(icon, False, icon_offset=icon_offset, description=description, description_icon=description_icon, title=title) self._toggle_callback = toggle_callback # State @@ -103,14 +136,16 @@ class BigCircleToggle(BigCircleButton): 0, 1.0, rl.WHITE) -class BigButton(Widget): +class BigButton(BaseButton): LABEL_HORIZONTAL_PADDING = 40 LABEL_VERTICAL_PADDING = 23 # visually matches 30 in figma """A lightweight stand-in for the Qt BigButton, drawn & updated each frame.""" - def __init__(self, text: str, value: str = "", icon: Union[rl.Texture, None] = None, scroll: bool = False): - super().__init__() + def __init__(self, text: str, value: str = "", icon: Union[rl.Texture, None] = None, scroll: bool = False, + *, description: str = "", + description_icon: Union[rl.Texture, None] = None): + super().__init__(description, text, description_icon or icon or None) self.set_rect(rl.Rectangle(0, 0, 402, 180)) self.text = text self.value = value @@ -119,7 +154,6 @@ class BigButton(Widget): self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps) self._click_delay = 0.075 - self._shake_start: float | None = None self._grow_animation_until: float | None = None self._rotate_icon_t: float | None = None @@ -187,28 +221,9 @@ class BigButton(Widget): def get_text(self): return self.text - def trigger_shake(self): - self._shake_start = rl.get_time() - def trigger_grow_animation(self, duration: float = 0.65): self._grow_animation_until = rl.get_time() + duration - @property - def _shake_offset(self) -> float: - SHAKE_DURATION = 0.5 - SHAKE_AMPLITUDE = 24.0 - SHAKE_FREQUENCY = 32.0 - if self._shake_start is None: - return 0.0 - t = rl.get_time() - self._shake_start - if t > SHAKE_DURATION: - return 0.0 - decay = 1.0 - t / SHAKE_DURATION - return decay * SHAKE_AMPLITUDE * math.sin(t * SHAKE_FREQUENCY) - - def set_position(self, x: float, y: float) -> None: - super().set_position(x + self._shake_offset, y) - def _handle_background(self) -> tuple[rl.Texture, float, float, float]: if self._grow_animation_until is not None: if rl.get_time() >= self._grow_animation_until: @@ -272,8 +287,10 @@ class BigButton(Widget): class BigToggle(BigButton): - def __init__(self, text: str, value: str = "", initial_state: bool = False, toggle_callback: Callable | None = None): - super().__init__(text, value, "") + def __init__(self, text: str, value: str = "", initial_state: bool = False, toggle_callback: Callable | None = None, + *, description: str = "", + description_icon: Union[rl.Texture, None] = None): + super().__init__(text, value, "", description=description, description_icon=description_icon) self._checked = initial_state self._toggle_callback = toggle_callback @@ -308,8 +325,8 @@ class BigToggle(BigButton): class BigMultiToggle(BigToggle): def __init__(self, text: str, options: list[str], toggle_callback: Callable | None = None, - select_callback: Callable | None = None): - super().__init__(text, "", toggle_callback=toggle_callback) + select_callback: Callable | None = None, *, description: str = "", description_icon: Union[rl.Texture, None] = None): + super().__init__(text, "", toggle_callback=toggle_callback, description=description, description_icon=description_icon) assert len(options) > 0 self._options = options self._select_callback = select_callback @@ -374,9 +391,9 @@ class GreyBigButton(BigButton): class BigMultiParamToggle(BigMultiToggle): def __init__(self, text: str, param: str, options: list[str], toggle_callback: Callable | None = None, - select_callback: Callable | None = None): + select_callback: Callable | None = None, *, description: str = "", description_icon: Union[rl.Texture, None] = None): assert Params is not None - super().__init__(text, options, toggle_callback, select_callback) + super().__init__(text, options, toggle_callback, select_callback, description=description, description_icon=description_icon) self._param = param self._params = Params() @@ -392,9 +409,10 @@ class BigMultiParamToggle(BigMultiToggle): class BigParamControl(BigToggle): - def __init__(self, text: str, param: str, toggle_callback: Callable | None = None): + def __init__(self, text: str, param: str, toggle_callback: Callable | None = None, *, description: str = "", + description_icon: Union[rl.Texture, None] = None): assert Params is not None - super().__init__(text, "", toggle_callback=toggle_callback) + super().__init__(text, "", toggle_callback=toggle_callback, description=description, description_icon=description_icon) self.param = param self.params = Params() self.set_checked(self.params.get_bool(self.param, False)) @@ -410,9 +428,9 @@ class BigParamControl(BigToggle): # TODO: param control base class class BigCircleParamControl(BigCircleToggle): def __init__(self, icon: rl.Texture, param: str, toggle_callback: Callable | None = None, - icon_offset: tuple[int, int] = (0, 0)): + icon_offset: tuple[int, int] = (0, 0), *, description: str = "", description_icon: Union[rl.Texture, None] = None, title: str = ""): assert Params is not None - super().__init__(icon, toggle_callback, icon_offset=icon_offset) + super().__init__(icon, toggle_callback, icon_offset=icon_offset, description=description, description_icon=description_icon, title=title) self._param = param self.params = Params() self.set_checked(self.params.get_bool(self._param, False)) diff --git a/openpilot/selfdrive/ui/mici/widgets/dialog.py b/openpilot/selfdrive/ui/mici/widgets/dialog.py index c023369933..d5da6178f1 100644 --- a/openpilot/selfdrive/ui/mici/widgets/dialog.py +++ b/openpilot/selfdrive/ui/mici/widgets/dialog.py @@ -1,9 +1,11 @@ import abc import math +import re import pyray as rl from typing import Union from collections.abc import Callable from openpilot.system.ui.widgets.nav_widget import NavWidget +from openpilot.system.ui.widgets.scroller import NavScroller from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets.mici_keyboard import MiciKeyboard from openpilot.system.ui.lib.text_measure import measure_text_cached @@ -37,6 +39,31 @@ class BigDialog(BigDialogBase): )) +class SettingDescriptionDialog(NavScroller): + def __init__(self, title: str, description: str, icon: Union[rl.Texture, None] = None): + super().__init__() + cards = [GreyBigButton(title, "scroll for details", icon or gui_app.texture("icons_mici/setup/green_info.png", 64, 64))] + # Explicit lines are authored cards; otherwise prefer sentence boundaries. + paragraphs = description.splitlines() if "\n" in description else re.split(r"(?<=[.!?])\s+", description.strip()) + # Measure each card so longer text still fits with the actual font and padding. + for sentence in paragraphs: + card = GreyBigButton("", "") + words: list[str] = [] + for word in sentence.split(): + card.set_value(" ".join([*words, word])) + height = card._sub_label.get_content_height(card._subtitle_width_hint()) + if words and height > card.rect.height - 2 * card.LABEL_VERTICAL_PADDING: + card.set_value(" ".join(words)) + cards.append(card) + card = GreyBigButton("", "") + words = [] + words.append(word) + if words: + card.set_value(" ".join(words)) + cards.append(card) + self._scroller.add_widgets(cards) + + class BigConfirmationDialog(BigDialogBase): def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable[[], None], exit_on_confirm: bool = True, red: bool = False): diff --git a/openpilot/system/ui/widgets/__init__.py b/openpilot/system/ui/widgets/__init__.py index 4e13920d60..e2e44c2543 100644 --- a/openpilot/system/ui/widgets/__init__.py +++ b/openpilot/system/ui/widgets/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations import abc +import time import pyray as rl from enum import IntEnum from typing import Protocol, TypeVar @@ -51,6 +52,8 @@ class Widget(abc.ABC): self._click_delay: float | None = None # seconds to hold is_pressed after release self._click_release_time: float | None = None self._click_callback: Callable[[], None] | None = None + self._long_press_callback: Callable[[], None] | None = None + self._press_started: list[float | None] = [None] * MAX_TOUCH_SLOTS self._multi_touch = False self.__was_awake = True @@ -92,6 +95,9 @@ class Widget(abc.ABC): """Set a callback to be called when the widget is clicked.""" self._click_callback = click_callback + def set_long_press_callback(self, callback: Callable[[], None]) -> None: + self._long_press_callback = callback + def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: """Set a callback to determine if the widget can be clicked.""" self._touch_valid_callback = touch_callback @@ -136,6 +142,7 @@ class Widget(abc.ABC): self._process_mouse_events() else: # TODO: ideally we emit release events when going disabled + self._press_started = [None] * MAX_TOUCH_SLOTS self.__is_pressed = [False] * MAX_TOUCH_SLOTS self.__tracking_is_pressed = [False] * MAX_TOUCH_SLOTS @@ -160,6 +167,7 @@ class Widget(abc.ABC): # Allows touch to leave the rect and come back in focus if mouse did not release if mouse_event.left_pressed and touch_valid: if mouse_in_rect: + self._press_started[mouse_event.slot] = mouse_event.t self._handle_mouse_press(mouse_event.pos) self.__is_pressed[mouse_event.slot] = True self.__tracking_is_pressed[mouse_event.slot] = True @@ -185,9 +193,20 @@ class Widget(abc.ABC): # Mouse/touch left our rect but may come back into focus later elif not mouse_in_rect: + self._press_started[mouse_event.slot] = None self.__is_pressed[mouse_event.slot] = False self._handle_mouse_event(mouse_event) + if self._long_press_callback is not None and touch_valid: + for slot, started in enumerate(self._press_started): + if started is not None and self.__is_pressed[slot] and time.monotonic() - started >= 0.45: + # Clear tracking before opening help so release cannot activate a toggle or action. + self._press_started[slot] = None + self.__is_pressed[slot] = False + self.__tracking_is_pressed[slot] = False + self._long_press_callback() + break + def _layout(self) -> None: """Optionally lay out child widgets separately. This is called before rendering.""" From dd226ac79191357910c91508fbabcc872a358e79 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Sat, 12 Sep 2026 12:41:14 -0700 Subject: [PATCH 073/122] update chestnut fw in CI (#38876) Update Chestnut firmware before CI build --- openpilot/selfdrive/test/chestnut.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpilot/selfdrive/test/chestnut.sh b/openpilot/selfdrive/test/chestnut.sh index 8ccc6722de..fdb1967429 100755 --- a/openpilot/selfdrive/test/chestnut.sh +++ b/openpilot/selfdrive/test/chestnut.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -e +sudo python3 openpilot/system/hardware/chestnut/flash.py + TARGET=openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl.chunkmanifest rm -f "$TARGET" SCONSFLAGS="-j4" ./openpilot/system/manager/build.py From f1ba169fb5a87d9ff664ed2dd6add68fb29ec26a Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sat, 12 Sep 2026 13:45:51 -0700 Subject: [PATCH 074/122] remove old profiler setups --- tools/scripts/profiling/clpeak/.gitignore | 1 - tools/scripts/profiling/clpeak/build.sh | 22 ----------------- .../profiling/clpeak/run_continuously.patch | 13 ---------- tools/scripts/profiling/palanteer/.gitignore | 2 -- tools/scripts/profiling/palanteer/setup.sh | 24 ------------------- tools/scripts/profiling/perfetto/.gitignore | 7 ------ tools/scripts/profiling/perfetto/build.sh | 11 --------- tools/scripts/profiling/perfetto/copy.sh | 6 ----- tools/scripts/profiling/perfetto/record.sh | 8 ------- tools/scripts/profiling/perfetto/server.sh | 6 ----- tools/scripts/profiling/perfetto/traces.sh | 5 ---- tools/scripts/profiling/py-spy/profile.sh | 21 ---------------- tools/scripts/profiling/snapdragon/.gitignore | 1 - tools/scripts/profiling/snapdragon/README.md | 13 ---------- .../profiling/snapdragon/setup-agnos.sh | 7 ------ .../profiling/snapdragon/setup-profiler.sh | 14 ----------- 16 files changed, 161 deletions(-) delete mode 100644 tools/scripts/profiling/clpeak/.gitignore delete mode 100755 tools/scripts/profiling/clpeak/build.sh delete mode 100644 tools/scripts/profiling/clpeak/run_continuously.patch delete mode 100644 tools/scripts/profiling/palanteer/.gitignore delete mode 100755 tools/scripts/profiling/palanteer/setup.sh delete mode 100644 tools/scripts/profiling/perfetto/.gitignore delete mode 100755 tools/scripts/profiling/perfetto/build.sh delete mode 100755 tools/scripts/profiling/perfetto/copy.sh delete mode 100755 tools/scripts/profiling/perfetto/record.sh delete mode 100755 tools/scripts/profiling/perfetto/server.sh delete mode 100755 tools/scripts/profiling/perfetto/traces.sh delete mode 100755 tools/scripts/profiling/py-spy/profile.sh delete mode 100644 tools/scripts/profiling/snapdragon/.gitignore delete mode 100644 tools/scripts/profiling/snapdragon/README.md delete mode 100755 tools/scripts/profiling/snapdragon/setup-agnos.sh delete mode 100755 tools/scripts/profiling/snapdragon/setup-profiler.sh diff --git a/tools/scripts/profiling/clpeak/.gitignore b/tools/scripts/profiling/clpeak/.gitignore deleted file mode 100644 index d575c207c1..0000000000 --- a/tools/scripts/profiling/clpeak/.gitignore +++ /dev/null @@ -1 +0,0 @@ -clpeak/ diff --git a/tools/scripts/profiling/clpeak/build.sh b/tools/scripts/profiling/clpeak/build.sh deleted file mode 100755 index 5206ed0c45..0000000000 --- a/tools/scripts/profiling/clpeak/build.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" -cd $DIR - -if [ ! -d "$DIR/clpeak" ]; then - git clone https://github.com/krrishnarraj/clpeak.git - - cd clpeak - git fetch - git checkout ec2d3e70e1abc7738b81f9277c7af79d89b2133b - git reset --hard origin/master - git submodule update --init --recursive --remote - - git apply ../run_continuously.patch -fi - -cd clpeak -mkdir build || true -cd build -cmake .. -cmake --build . diff --git a/tools/scripts/profiling/clpeak/run_continuously.patch b/tools/scripts/profiling/clpeak/run_continuously.patch deleted file mode 100644 index 075c65bd2a..0000000000 --- a/tools/scripts/profiling/clpeak/run_continuously.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/src/clpeak.cpp b/src/clpeak.cpp -index 8cb192b..b6fe6f5 100644 ---- a/src/clpeak.cpp -+++ b/src/clpeak.cpp -@@ -47,7 +47,7 @@ int clPeak::runAll() - - log->xmlOpenTag("clpeak"); - log->xmlAppendAttribs("os", OS_NAME); -- for (size_t p = 0; p < platforms.size(); p++) -+ for (size_t p = 0; p < platforms.size(); (p+1 % platforms.size())) - { - if (forcePlatform && (p != specifiedPlatform)) - continue; diff --git a/tools/scripts/profiling/palanteer/.gitignore b/tools/scripts/profiling/palanteer/.gitignore deleted file mode 100644 index 158e7b0759..0000000000 --- a/tools/scripts/profiling/palanteer/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -palanteer/ -viewer diff --git a/tools/scripts/profiling/palanteer/setup.sh b/tools/scripts/profiling/palanteer/setup.sh deleted file mode 100755 index 6f115dc86b..0000000000 --- a/tools/scripts/profiling/palanteer/setup.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash - -set -e - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" -cd $DIR - -if [ ! -d palanteer ]; then - git clone https://github.com/dfeneyrou/palanteer - pip install wheel - sudo apt install libunwind-dev libdw-dev -fi - -cd palanteer -git pull - -mkdir -p build -cd build -cmake .. -DCMAKE_BUILD_TYPE=Release -make -j$(nproc) - -pip install --force-reinstall python/dist/palanteer*.whl - -cp bin/palanteer $DIR/viewer diff --git a/tools/scripts/profiling/perfetto/.gitignore b/tools/scripts/profiling/perfetto/.gitignore deleted file mode 100644 index d86c89d462..0000000000 --- a/tools/scripts/profiling/perfetto/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -trace_* - -tracebox -trace_processor - -perfetto/ -configs/ diff --git a/tools/scripts/profiling/perfetto/build.sh b/tools/scripts/profiling/perfetto/build.sh deleted file mode 100755 index 24255ac3b0..0000000000 --- a/tools/scripts/profiling/perfetto/build.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -if [ ! -d perfetto ]; then - git clone https://android.googlesource.com/platform/external/perfetto/ -fi - -cd perfetto - -tools/install-build-deps --linux-arm -tools/gn gen --args='is_debug=false target_os="linux" target_cpu="arm64"' out/linux -tools/ninja -C out/linux tracebox traced traced_probes perfetto diff --git a/tools/scripts/profiling/perfetto/copy.sh b/tools/scripts/profiling/perfetto/copy.sh deleted file mode 100755 index b91d49a80b..0000000000 --- a/tools/scripts/profiling/perfetto/copy.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash - -DEST=tici:/data/openpilot/selfdrive/debug/profiling/perfetto - -scp -r perfetto/out/linux/tracebox $DEST -scp -r perfetto/test/configs $DEST diff --git a/tools/scripts/profiling/perfetto/record.sh b/tools/scripts/profiling/perfetto/record.sh deleted file mode 100755 index 9715a7e7e9..0000000000 --- a/tools/scripts/profiling/perfetto/record.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" -cd $DIR - -OUT=trace_ -sudo ./tracebox -o $OUT --txt -c configs/scheduling.cfg -sudo chown $USER:$USER $OUT diff --git a/tools/scripts/profiling/perfetto/server.sh b/tools/scripts/profiling/perfetto/server.sh deleted file mode 100755 index 103ad9bd84..0000000000 --- a/tools/scripts/profiling/perfetto/server.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash - -curl -LO https://get.perfetto.dev/trace_processor -chmod +x ./trace_processor - -./trace_processor --httpd diff --git a/tools/scripts/profiling/perfetto/traces.sh b/tools/scripts/profiling/perfetto/traces.sh deleted file mode 100755 index 17ca89da72..0000000000 --- a/tools/scripts/profiling/perfetto/traces.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash - -DEST=tici:/data/openpilot/selfdrive/debug/profiling/perfetto - -scp tici:/data/openpilot/selfdrive/debug/profiling/perfetto/trace_* . diff --git a/tools/scripts/profiling/py-spy/profile.sh b/tools/scripts/profiling/py-spy/profile.sh deleted file mode 100755 index 2fdbe8d16e..0000000000 --- a/tools/scripts/profiling/py-spy/profile.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash -set -e - -cd "$(dirname "$0")" - -# find process with name passed in (excluding this process) -for PID in $(pgrep -f $1); do - if [ "$PID" != "$$" ]; then - ps -p $PID -o args - TRACE_PID=$PID - break - fi -done - -if [ -z "$TRACE_PID" ]; then - echo "could not find PID for $1" - exit 1 -fi - -sudo env PATH=$PATH py-spy record -d 5 -o /tmp/perf$TRACE_PID.svg -p $TRACE_PID && -google-chrome /tmp/perf$TRACE_PID.svg diff --git a/tools/scripts/profiling/snapdragon/.gitignore b/tools/scripts/profiling/snapdragon/.gitignore deleted file mode 100644 index 80078842a0..0000000000 --- a/tools/scripts/profiling/snapdragon/.gitignore +++ /dev/null @@ -1 +0,0 @@ -SnapdragonProfiler/ \ No newline at end of file diff --git a/tools/scripts/profiling/snapdragon/README.md b/tools/scripts/profiling/snapdragon/README.md deleted file mode 100644 index 383d83ba9b..0000000000 --- a/tools/scripts/profiling/snapdragon/README.md +++ /dev/null @@ -1,13 +0,0 @@ -snapdragon profiler --------- - - -* download from https://developer.qualcomm.com/software/snapdragon-profiler/tools-archive (need a qc developer account) - * choose v2021.5 (verified working with 24.04 dev environment) -* unzip to openpilot/selfdrive/debug/profiling/snapdragon/SnapdragonProfiler -* run ```./setup-profiler.sh``` -* run ```./setup-agnos.sh``` -* run ```openpilot/selfdrive/debug/adb.sh``` on device -* run the ```adb connect xxx``` command that was given to you on local pc -* cd to SnapdragonProfiler and run ```./run_sdp.sh``` -* connect to device -> choose device you just setup diff --git a/tools/scripts/profiling/snapdragon/setup-agnos.sh b/tools/scripts/profiling/snapdragon/setup-agnos.sh deleted file mode 100755 index 9a781bf3ce..0000000000 --- a/tools/scripts/profiling/snapdragon/setup-agnos.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -# TODO: there's probably a better way to do this - -cd SnapdragonProfiler/service -mv android real_android -ln -s agl/ android diff --git a/tools/scripts/profiling/snapdragon/setup-profiler.sh b/tools/scripts/profiling/snapdragon/setup-profiler.sh deleted file mode 100755 index b97c1ae688..0000000000 --- a/tools/scripts/profiling/snapdragon/setup-profiler.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash - -# install depends -sudo apt update -sudo apt-get install libc++1 libc++abi1 default-jre android-tools-adb gtk-sharp2 - -# setup mono -sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 3FA7E0328081BFF6A14DA29AA6A19B38D3D831EF -sudo apt install apt-transport-https ca-certificates -echo "deb https://download.mono-project.com/repo/ubuntu stable-xenial main" | sudo tee /etc/apt/sources.list.d/mono-official-stable.list -sudo apt update -sudo apt-get install -y mono-complete - -echo "Setup successful, you should now be able to run the profiler with cd SnapdragonProfiler and ./run_sdp.sh" From 5cad99bf7a7e0013e44e3e243cd7c5636dc2d292 Mon Sep 17 00:00:00 2001 From: David <49467229+TheSecurityDev@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:06:20 -0500 Subject: [PATCH 075/122] fix(tools): allow spaces in workspace path for op.sh (#38839) --- tools/op.sh | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tools/op.sh b/tools/op.sh index f17714e620..183b4b1012 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -73,11 +73,11 @@ function op_get_openpilot_dir() { function op_install_post_commit() { op_get_openpilot_dir - if [[ ! -d $OPENPILOT_ROOT/.git/hooks/post-commit.d ]]; then - mkdir $OPENPILOT_ROOT/.git/hooks/post-commit.d - mv $OPENPILOT_ROOT/.git/hooks/post-commit $OPENPILOT_ROOT/.git/hooks/post-commit.d 2>/dev/null || true + if [[ ! -d "$OPENPILOT_ROOT/.git/hooks/post-commit.d" ]]; then + mkdir "$OPENPILOT_ROOT/.git/hooks/post-commit.d" + mv "$OPENPILOT_ROOT/.git/hooks/post-commit" "$OPENPILOT_ROOT/.git/hooks/post-commit.d" 2>/dev/null || true fi - cd $OPENPILOT_ROOT/.git/hooks + cd "$OPENPILOT_ROOT/.git/hooks" ln -sf ../../scripts/post-commit post-commit } @@ -103,7 +103,7 @@ function op_check_git() { fi echo "Checking for git lfs files..." - if [[ $(file -b $OPENPILOT_ROOT/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx) == "data" ]]; then + if [[ $(file -b "$OPENPILOT_ROOT/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx") == "data" ]]; then echo -e " ↳ [${GREEN}✔${NC}] git lfs files found." else echo -e " ↳ [${RED}✗${NC}] git lfs files not found! Run 'git lfs pull'" @@ -112,7 +112,7 @@ function op_check_git() { echo "Checking for git submodules..." for name in $(git config --file .gitmodules --get-regexp path | awk '{ print $2 }' | tr '\n' ' '); do - if [[ -z $(ls $OPENPILOT_ROOT/$name) ]]; then + if [[ -z $(ls "$OPENPILOT_ROOT/$name") ]]; then echo -e " ↳ [${RED}✗${NC}] git submodule $name not found! Run 'git submodule update --init --recursive'" return 1 fi @@ -134,10 +134,10 @@ function op_check_os() { function op_check_venv() { echo "Checking for venv..." - if [[ -f $OPENPILOT_ROOT/.venv/bin/activate ]]; then + if [[ -f "$OPENPILOT_ROOT/.venv/bin/activate" ]]; then echo -e " ↳ [${GREEN}✔${NC}] venv detected." else - echo -e " ↳ [${RED}✗${NC}] Can't activate venv in $OPENPILOT_ROOT. Assuming global env!" + echo -e " ↳ [${RED}✗${NC}] Can't activate venv in '$OPENPILOT_ROOT'. Assuming global env!" fi } @@ -147,7 +147,7 @@ function op_before_cmd() { fi op_get_openpilot_dir - cd $OPENPILOT_ROOT + cd "$OPENPILOT_ROOT" result="$((op_check_openpilot_dir ) 2>&1)" || (echo -e "$result" && return 1) result="${result}\n$(( op_check_git ) 2>&1)" || (echo -e "$result" && return 1) @@ -176,7 +176,7 @@ EOF echo -e " ↳ [${GREEN}✔${NC}] op installed successfully. Open a new shell to use it." op_get_openpilot_dir - cd $OPENPILOT_ROOT + cd "$OPENPILOT_ROOT" op_check_openpilot_dir op_check_os @@ -195,7 +195,7 @@ EOF echo "Installing dependencies..." st="$(date +%s)" SETUP_SCRIPT="tools/setup_dependencies.sh" - if ! $OPENPILOT_ROOT/$SETUP_SCRIPT; then + if ! "$OPENPILOT_ROOT/$SETUP_SCRIPT"; then echo -e " ↳ [${RED}✗${NC}] Dependencies installation failed!" return 1 fi @@ -230,7 +230,7 @@ function op_auth() { function op_activate_venv() { # bash 3.2 can't handle this without the 'set +e' set +e - source $OPENPILOT_ROOT/.venv/bin/activate &> /dev/null || true + source "$OPENPILOT_ROOT/.venv/bin/activate" &> /dev/null || true set -e # persist venv on PATH across GitHub Actions steps @@ -242,18 +242,18 @@ function op_activate_venv() { function op_venv() { op_before_cmd - if [[ ! -f $OPENPILOT_ROOT/.venv/bin/activate ]]; then - echo -e "No venv found in $OPENPILOT_ROOT" + if [[ ! -f "$OPENPILOT_ROOT/.venv/bin/activate" ]]; then + echo -e "No venv found in '$OPENPILOT_ROOT'" return 1 fi case $SHELL_NAME in "zsh") ZSHRC_DIR=$(mktemp -d 2>/dev/null || mktemp -d -t 'tmp_zsh') - echo "source $RC_FILE; source $OPENPILOT_ROOT/.venv/bin/activate" >> $ZSHRC_DIR/.zshrc + echo "source \"$RC_FILE\"; source \"$OPENPILOT_ROOT/.venv/bin/activate\"" >> $ZSHRC_DIR/.zshrc ZDOTDIR=$ZSHRC_DIR zsh ;; *) - bash --rcfile <(echo "source $RC_FILE; source $OPENPILOT_ROOT/.venv/bin/activate") ;; + bash --rcfile <(echo "source \"$RC_FILE\"; source \"$OPENPILOT_ROOT/.venv/bin/activate\"") ;; esac } From ad5c0b946d4c2200e532e9a4f36894a46d1d39dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20R=C4=85czy?= Date: Sat, 12 Sep 2026 14:13:08 -0700 Subject: [PATCH 076/122] tools: optimize route parsing (#38714) --- openpilot/tools/lib/route.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpilot/tools/lib/route.py b/openpilot/tools/lib/route.py index 98334a06c8..94ed7c1fa6 100644 --- a/openpilot/tools/lib/route.py +++ b/openpilot/tools/lib/route.py @@ -206,7 +206,8 @@ class Segment: class RouteName: def __init__(self, name_str: str): self._name_str = name_str - delim = next(c for c in self._name_str if c in ("|", "/")) + pipe, slash = name_str.find("|"), name_str.find("/") + delim = "|" if slash == -1 or 0 <= pipe < slash else "/" self._dongle_id, self._time_str = self._name_str.split(delim) assert len(self._dongle_id) == 16, self._name_str From 64b4dbf115c274ff874c38e1f3c903eea3f8d8ac Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sat, 12 Sep 2026 14:14:39 -0700 Subject: [PATCH 077/122] add shellcheck-like static analysis (#38877) --- launch_chffrplus.sh | 20 +-- openpilot/selfdrive/assets/prep-svg.sh | 4 +- openpilot/selfdrive/test/scons_build_test.sh | 4 +- openpilot/selfdrive/test/setup_device_ci.sh | 28 ++-- .../ui/translations/auto_translate.sh | 2 +- .../system/camerad/test/stress_restart.sh | 4 +- openpilot/tools/sim/launch_openpilot.sh | 2 +- scripts/apply-pr.sh | 4 +- scripts/checkout-pr.sh | 8 +- scripts/jenkins_loop_test.sh | 34 ++--- scripts/launch_corolla.sh | 2 +- scripts/lint/check_nomerge_comments.sh | 4 +- scripts/lint/check_shebang_format.sh | 6 +- scripts/lint/check_shell.py | 132 ++++++++++++++++++ scripts/lint/lint.sh | 55 +++++--- scripts/retry.sh | 4 +- tools/op.sh | 12 +- tools/release/build_release.sh | 14 +- tools/release/build_stripped.sh | 26 ++-- tools/release/check-dirty.sh | 2 +- tools/release/check-submodules.sh | 6 +- tools/scripts/adb_ssh.sh | 4 +- tools/setup.sh | 16 +-- tools/setup_dependencies.sh | 28 ++-- 24 files changed, 281 insertions(+), 140 deletions(-) create mode 100755 scripts/lint/check_shell.py diff --git a/launch_chffrplus.sh b/launch_chffrplus.sh index f30e03ca62..240bc3b648 100755 --- a/launch_chffrplus.sh +++ b/launch_chffrplus.sh @@ -18,21 +18,21 @@ function agnos_init { sudo chmod 660 /dev/adsprpc-smd /dev/ion /dev/kgsl-3d0 # Check if AGNOS update is required - if [ $(< /VERSION) != "$AGNOS_VERSION" ]; then + if [ "$(< /VERSION)" != "$AGNOS_VERSION" ]; then AGNOS_PY="$DIR/openpilot/common/hardware/comma/agnos.py" MANIFEST="$DIR/openpilot/system/hardware/comma/agnos.json" - if $AGNOS_PY --verify $MANIFEST; then + if "$AGNOS_PY" --verify "$MANIFEST"; then sudo reboot fi while true; do - $DIR/openpilot/common/hardware/comma/updater $AGNOS_PY $MANIFEST + "$DIR/openpilot/common/hardware/comma/updater" "$AGNOS_PY" "$MANIFEST" done fi } function launch { # Remove orphaned git lock if it exists on boot - [ -f "$DIR/.git/index.lock" ] && rm -f $DIR/.git/index.lock + [ -f "$DIR/.git/index.lock" ] && rm -f "$DIR/.git/index.lock" # Check to see if there's a valid overlay-based update available. Conditions # are as follows: @@ -44,7 +44,7 @@ function launch { # that completed successfully and synced to disk. if [ -f "${DIR}/.overlay_init" ]; then - find ${DIR}/.git -newer ${DIR}/.overlay_init | grep -q '.' 2> /dev/null + find "${DIR}/.git" -newer "${DIR}/.overlay_init" | grep -q '.' 2> /dev/null if [ $? -eq 0 ]; then echo "${DIR} has been modified, skipping overlay update installation" else @@ -53,9 +53,9 @@ function launch { echo "Valid overlay update found, installing" LAUNCHER_LOCATION="${BASH_SOURCE[0]}" - mv $DIR /data/safe_staging/old_openpilot - mv "${STAGING_ROOT}/finalized" $DIR - cd $DIR + mv "$DIR" /data/safe_staging/old_openpilot + mv "${STAGING_ROOT}/finalized" "$DIR" + cd "$DIR" echo "Restarting launch script ${LAUNCHER_LOCATION}" unset AGNOS_VERSION @@ -69,7 +69,7 @@ function launch { fi # handle pythonpath - ln -sfn $(pwd) /data/pythonpath + ln -sfn "$(pwd)" /data/pythonpath export PYTHONPATH="$PWD" # submodule package symlinks for PYTHONPATH imports on device. @@ -90,7 +90,7 @@ function launch { # start manager cd openpilot/system/manager - if [ ! -f $DIR/prebuilt ]; then + if [ ! -f "$DIR/prebuilt" ]; then ./build.py fi ./manager.py diff --git a/openpilot/selfdrive/assets/prep-svg.sh b/openpilot/selfdrive/assets/prep-svg.sh index 567c17da32..ee6ba0a3ac 100755 --- a/openpilot/selfdrive/assets/prep-svg.sh +++ b/openpilot/selfdrive/assets/prep-svg.sh @@ -23,8 +23,8 @@ done # sudo apt install inkscape -for svg in $(find $DIR -type f | grep svg$); do - bunx svgo $svg --multipass --pretty --indent 2 +for svg in $(find "$DIR" -type f | grep svg$); do + bunx svgo "$svg" --multipass --pretty --indent 2 # convert to PNG png="${svg%.svg}.png" diff --git a/openpilot/selfdrive/test/scons_build_test.sh b/openpilot/selfdrive/test/scons_build_test.sh index 6f6eafcad1..dfdaf6189f 100755 --- a/openpilot/selfdrive/test/scons_build_test.sh +++ b/openpilot/selfdrive/test/scons_build_test.sh @@ -3,7 +3,7 @@ set -e SCRIPT_DIR=$(dirname "$0") BASEDIR=$(realpath "$SCRIPT_DIR/../../../") -cd $BASEDIR +cd "$BASEDIR" # tests that our build system's dependencies are configured properly, # needs a machine with lots of cores @@ -11,7 +11,7 @@ cd $BASEDIR # helpful commands: # scons -Q --tree=derived -cd $BASEDIR/opendbc_repo/ +cd "$BASEDIR/opendbc_repo/" scons --clean scons --no-cache --random if ! scons -q; then diff --git a/openpilot/selfdrive/test/setup_device_ci.sh b/openpilot/selfdrive/test/setup_device_ci.sh index 8558a38bc9..a1dd88dcf4 100755 --- a/openpilot/selfdrive/test/setup_device_ci.sh +++ b/openpilot/selfdrive/test/setup_device_ci.sh @@ -29,7 +29,7 @@ if [ -d /data/safe_staging/ ]; then fi CONTINUE_PATH="/data/continue.sh" -tee $CONTINUE_PATH << EOF +tee "$CONTINUE_PATH" << EOF #!/usr/bin/env bash sudo abctl --set_success @@ -54,7 +54,7 @@ done sleep infinity EOF -chmod +x $CONTINUE_PATH +chmod +x "$CONTINUE_PATH" export GIT_LFS_SKIP_SMUDGE=1 pull_lfs() { @@ -87,16 +87,16 @@ pull_lfs() { safe_checkout() { # completely clean TEST_DIR - cd $SOURCE_DIR + cd "$SOURCE_DIR" # cleanup orphaned locks find .git -type f -name "*.lock" -exec rm {} + git reset --hard - git fetch --no-tags --no-recurse-submodules -j4 --verbose --depth 1 origin $GIT_COMMIT + git fetch --no-tags --no-recurse-submodules -j4 --verbose --depth 1 origin "$GIT_COMMIT" find . -maxdepth 1 -not -path './.git' -not -name '.' -not -name '..' -exec rm -rf '{}' \; - git reset --hard $GIT_COMMIT - git checkout $GIT_COMMIT + git reset --hard "$GIT_COMMIT" + git checkout "$GIT_COMMIT" git clean -xdff git submodule sync git submodule foreach --recursive "git reset --hard && git clean -xdff" @@ -106,22 +106,22 @@ safe_checkout() { pull_lfs echo "git checkout done, t=$SECONDS" - du -hs $SOURCE_DIR $SOURCE_DIR/.git + du -hs "$SOURCE_DIR" "$SOURCE_DIR/.git" - rsync -a --delete $SOURCE_DIR $TEST_DIR + rsync -a --delete "$SOURCE_DIR" "$TEST_DIR" } unsafe_checkout() {( set -e # checkout directly in test dir, leave old build products - cd $TEST_DIR + cd "$TEST_DIR" # cleanup orphaned locks find .git -type f -name "*.lock" -exec rm {} + - git fetch --no-tags --no-recurse-submodules -j8 --verbose --depth 1 origin $GIT_COMMIT - git checkout --force --no-recurse-submodules $GIT_COMMIT - git reset --hard $GIT_COMMIT + git fetch --no-tags --no-recurse-submodules -j8 --verbose --depth 1 origin "$GIT_COMMIT" + git checkout --force --no-recurse-submodules "$GIT_COMMIT" + git reset --hard "$GIT_COMMIT" git clean -dff git submodule sync git submodule foreach --recursive "git reset --hard && git clean -df" @@ -135,7 +135,7 @@ export GIT_PACK_THREADS=8 # set up environment if [ ! -d "$SOURCE_DIR" ]; then - git clone https://github.com/commaai/openpilot.git $SOURCE_DIR + git clone https://github.com/commaai/openpilot.git "$SOURCE_DIR" fi if [ ! -z "$UNSAFE" ]; then @@ -152,7 +152,7 @@ else fi # submodule package symlinks for PYTHONPATH imports on device (same as launch_chffrplus.sh) -cd $TEST_DIR +cd "$TEST_DIR" ln -sfn msgq_repo/msgq msgq ln -sfn opendbc_repo/opendbc opendbc ln -sfn rednose_repo/rednose rednose diff --git a/openpilot/selfdrive/ui/translations/auto_translate.sh b/openpilot/selfdrive/ui/translations/auto_translate.sh index 7238426c75..858e60eb26 100755 --- a/openpilot/selfdrive/ui/translations/auto_translate.sh +++ b/openpilot/selfdrive/ui/translations/auto_translate.sh @@ -4,7 +4,7 @@ set -euo pipefail DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd)" ROOT="$DIR/../../../" -cd $DIR +cd "$DIR" ./update_translations.py command -v codex >/dev/null || { diff --git a/openpilot/system/camerad/test/stress_restart.sh b/openpilot/system/camerad/test/stress_restart.sh index 0445dcba79..5f0f2dd2f9 100755 --- a/openpilot/system/camerad/test/stress_restart.sh +++ b/openpilot/system/camerad/test/stress_restart.sh @@ -4,6 +4,6 @@ while :; do ./camerad & pid="$!" sleep 2 - kill -2 $pid - wait $pid + kill -2 "$pid" + wait "$pid" done diff --git a/openpilot/tools/sim/launch_openpilot.sh b/openpilot/tools/sim/launch_openpilot.sh index 392f365d03..813711dd79 100755 --- a/openpilot/tools/sim/launch_openpilot.sh +++ b/openpilot/tools/sim/launch_openpilot.sh @@ -18,4 +18,4 @@ SCRIPT_DIR=$(dirname "$0") OPENPILOT_DIR=$SCRIPT_DIR/../../ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" -cd $OPENPILOT_DIR/system/manager && exec ./manager.py +cd "$OPENPILOT_DIR/system/manager" && exec ./manager.py diff --git a/scripts/apply-pr.sh b/scripts/apply-pr.sh index ad0af46b49..f4a29ec087 100755 --- a/scripts/apply-pr.sh +++ b/scripts/apply-pr.sh @@ -6,6 +6,6 @@ if [ $# -eq 0 ]; then fi BASE="https://github.com/commaai/openpilot/pull/" -PR_NUM="$(echo $1 | grep -o -E '[0-9]+')" +PR_NUM="$(echo "$1" | grep -o -E '[0-9]+')" -curl -L $BASE/$PR_NUM.patch | git apply -3 +curl -L "$BASE/$PR_NUM.patch" | git apply -3 diff --git a/scripts/checkout-pr.sh b/scripts/checkout-pr.sh index eeba816d88..5f5dc8b1e3 100755 --- a/scripts/checkout-pr.sh +++ b/scripts/checkout-pr.sh @@ -7,10 +7,10 @@ if [ $# -eq 0 ]; then fi BASE="https://github.com/commaai/openpilot/pull/" -PR_NUM="$(echo $1 | grep -o -E '[0-9]+')" +PR_NUM="$(echo "$1" | grep -o -E '[0-9]+')" BRANCH=tmp-pr${PR_NUM} -git branch -D -f $BRANCH || true -git fetch -u -f origin pull/$PR_NUM/head:$BRANCH -git switch $BRANCH +git branch -D -f "$BRANCH" || true +git fetch -u -f origin "pull/$PR_NUM/head:$BRANCH" +git switch "$BRANCH" git reset --hard FETCH_HEAD diff --git a/scripts/jenkins_loop_test.sh b/scripts/jenkins_loop_test.sh index 8073f4668c..6cbdafd355 100755 --- a/scripts/jenkins_loop_test.sh +++ b/scripts/jenkins_loop_test.sh @@ -11,7 +11,7 @@ BRANCH="master" RUNS="20" COOKIE_JAR=/tmp/cookies -CRUMB=$(curl -s --cookie-jar $COOKIE_JAR 'https://jenkins.comma.life/crumbIssuer/api/xml?xpath=concat(//crumbRequestField,":",//crumb)') +CRUMB=$(curl -s --cookie-jar "$COOKIE_JAR" 'https://jenkins.comma.life/crumbIssuer/api/xml?xpath=concat(//crumbRequestField,":",//crumb)') FIRST_LOOP=1 @@ -25,13 +25,13 @@ function loop() { if [[ $FIRST_LOOP ]]; then TEMP_DIR=$(mktemp -d) - GIT_LFS_SKIP_SMUDGE=1 git clone --quiet -b $BRANCH --depth=1 --no-tags git@github.com:commaai/openpilot $TEMP_DIR - git -C $TEMP_DIR checkout --quiet -b $JENKINS_BRANCH - echo "TESTING: $(date)" >> $TEMP_DIR/testing_jenkins - git -C $TEMP_DIR add testing_jenkins - git -C $TEMP_DIR commit --quiet -m "testing" - git -C $TEMP_DIR push --quiet -f origin $JENKINS_BRANCH - rm -rf $TEMP_DIR + GIT_LFS_SKIP_SMUDGE=1 git clone --quiet -b "$BRANCH" --depth=1 --no-tags git@github.com:commaai/openpilot "$TEMP_DIR" + git -C "$TEMP_DIR" checkout --quiet -b "$JENKINS_BRANCH" + echo "TESTING: $(date)" >> "$TEMP_DIR/testing_jenkins" + git -C "$TEMP_DIR" add testing_jenkins + git -C "$TEMP_DIR" commit --quiet -m "testing" + git -C "$TEMP_DIR" push --quiet -f origin "$JENKINS_BRANCH" + rm -rf "$TEMP_DIR" FIRST_BUILD=1 echo '' echo 'waiting on Jenkins...' @@ -40,15 +40,15 @@ function loop() { FIRST_LOOP="" fi - FIRST_BUILD=$(curl -s $API_ROUTE/api/json | jq .nextBuildNumber) + FIRST_BUILD=$(curl -s "$API_ROUTE/api/json" | jq .nextBuildNumber) LAST_BUILD=$((FIRST_BUILD+N-1)) - TEST_BUILDS=( $(seq $FIRST_BUILD $LAST_BUILD) ) + read -r -a TEST_BUILDS <<< "$(seq -s ' ' "$FIRST_BUILD" "$LAST_BUILD")" # Start N new builds - for i in ${TEST_BUILDS[@]}; + for i in "${TEST_BUILDS[@]}"; do echo "Starting build $i" - curl -s --output /dev/null --cookie $COOKIE_JAR -H "$CRUMB" -X POST $API_ROUTE/build?delay=0sec + curl -s --output /dev/null --cookie "$COOKIE_JAR" -H "$CRUMB" -X POST "$API_ROUTE/build?delay=0sec" sleep 5 done echo "" @@ -58,14 +58,14 @@ function loop() { sleep 30 count=0 - for i in ${TEST_BUILDS[@]}; + for i in "${TEST_BUILDS[@]}"; do - RES=$(curl -s -w "\n%{http_code}" --cookie $COOKIE_JAR -H "$CRUMB" $API_ROUTE/$i/api/json) + RES=$(curl -s -w "\n%{http_code}" --cookie "$COOKIE_JAR" -H "$CRUMB" "$API_ROUTE/$i/api/json") HTTP_CODE=$(tail -n1 <<< "$RES") JSON=$(sed '$ d' <<< "$RES") if [[ $HTTP_CODE == "200" ]]; then - STILL_RUNNING=$(echo $JSON | jq .inProgress) + STILL_RUNNING=$(echo "$JSON" | jq .inProgress) if [[ $STILL_RUNNING == "true" ]]; then echo -e "Build $i: ${YELLOW}still running${NC}" continue @@ -119,11 +119,11 @@ function _looper() { echo -e "You are about to start $RUNS Jenkins builds against the $BRANCH branch." echo -e "If you expect this to run overnight, ${UNDERLINE}${BOLD}unplug the cold reboot power switch${NC} from the testing closet before." echo "" - read -p "Press (y/Y) to confirm: " choice + read -r -p "Press (y/Y) to confirm: " choice if [[ "$choice" == "y" || "$choice" == "Y" ]]; then loop fi } -_looper $@ +_looper "$@" diff --git a/scripts/launch_corolla.sh b/scripts/launch_corolla.sh index 926569a1e0..8894caaf74 100755 --- a/scripts/launch_corolla.sh +++ b/scripts/launch_corolla.sh @@ -4,4 +4,4 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" export FINGERPRINT="TOYOTA_COROLLA_TSS2" export SKIP_FW_QUERY="1" -$DIR/../launch_openpilot.sh +"$DIR/../launch_openpilot.sh" diff --git a/scripts/lint/check_nomerge_comments.sh b/scripts/lint/check_nomerge_comments.sh index 6737d62a20..0dc3a7bd73 100755 --- a/scripts/lint/check_nomerge_comments.sh +++ b/scripts/lint/check_nomerge_comments.sh @@ -2,9 +2,9 @@ FAIL=0 -if grep -n '\(#\|//\)\([[:space:]]*\)NOMERGE' $@; then +if grep -n '\(#\|//\)\([[:space:]]*\)NOMERGE' "$@"; then echo -e "NOMERGE comments found! Remove them before merging\n" FAIL=1 fi -exit $FAIL +exit "$FAIL" diff --git a/scripts/lint/check_shebang_format.sh b/scripts/lint/check_shebang_format.sh index 89b95d5929..6a3657a10b 100755 --- a/scripts/lint/check_shebang_format.sh +++ b/scripts/lint/check_shebang_format.sh @@ -2,14 +2,14 @@ FAIL=0 -if grep '^#!.*python' $@ | grep -v '#!/usr/bin/env python3$'; then +if grep '^#!.*python' "$@" | grep -v '#!/usr/bin/env python3$'; then echo -e "Invalid shebang! Must use '#!/usr/bin/env python3'\n" FAIL=1 fi -if grep '^#!.*bash' $@ | grep -v '#!/usr/bin/env bash$'; then +if grep '^#!.*bash' "$@" | grep -v '#!/usr/bin/env bash$'; then echo -e "Invalid shebang! Must use '#!/usr/bin/env bash'" FAIL=1 fi -exit $FAIL +exit "$FAIL" diff --git a/scripts/lint/check_shell.py b/scripts/lint/check_shell.py new file mode 100755 index 0000000000..d34bd6e454 --- /dev/null +++ b/scripts/lint/check_shell.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""A minimal shellcheck-like static analysis tool for shell scripts. + +Covers syntax, unquoted expansions, scalar $@ assignments, and read -r. +""" +import re +import argparse +import subprocess +from pathlib import Path + +VARIABLE = re.compile(r"\$(?:\{[^}\n]*\}|[A-Za-z_]\w*|[@*0-9])") +ASSIGNMENT = re.compile(r"[A-Za-z_]\w*(?:\[[^]]*\])?\+?=") +OPAQUE = re.compile(r"\$?\(\([^\n]*?\)\)|\[\[.*?\]\]", re.DOTALL) +HEREDOC = re.compile(r"<<(-?)\s*('[^']+'|\"[^\"]+\"|\\?[A-Za-z_]\w*)") + + +def commands(text): + def scan(i=0, end="", pattern_group=False): + start, quote, words, expansions, documents = i, False, [], [], [] + cases = [] + while i < len(text): + c = text[i] + if c == "\\": + i += 2 + continue + if c == "'" and not quote: + i = text.find("'", i + 1) + 1 or len(text) + continue + if c == '"': + quote = not quote + elif (not quote or text.startswith("$((", i)) and (match := OPAQUE.match(text, i)): + i = match.end() + continue + elif text.startswith("$(", i): + if not quote: + expansions.append(i) + i = yield from scan(i + 2, ")") + continue + elif match := VARIABLE.match(text, i): + if not quote and not match[0].startswith("${#"): + expansions.append(i) + i = match.end() + continue + elif not quote: + if text.startswith("<<<", i): + i += 3 + continue + if c == "#" and i == start: + i = text.find("\n", i) + i = len(text) if i < 0 else i + start = i + continue + if not text.startswith("<<<", i) and (match := HEREDOC.match(text, i)): + documents.append((match[2].strip("'\"").lstrip("\\"), bool(match[1]))) + i = match.end() + continue + if c in " \t\r\n;|&()": + if start < i: + words.append((text[start:i], start, expansions)) + if len(words) >= 3 and words[0][0] == "case" and words[-1][0] == "in": + cases.append(True) + words = [] + if words and words[0][0] == "esac" and cases: + cases.pop() + words = [] + pattern = bool(cases and cases[-1]) + expansions = [] + if c in "\n;|&()": + if words and not (pattern or pattern_group) and (c != ")" or end): + yield words + words = [] + if c == end and not pattern: + return i + 1 + if c == ")" and pattern: + cases[-1] = False + if cases and (terminator := re.match(r";(?:;&|;|&)", text[i:])): + cases[-1] = True + i += len(terminator[0]) - 1 + if c == "(": + if pattern and i == start: + cases[-1] = False + i = yield from scan(i + 1, ")", pattern or pattern_group) + start = i + continue + if c == "\n": + for delimiter, strip_tabs in documents: + while i < len(text): + stop = text.find("\n", i + 1) + stop = len(text) if stop < 0 else stop + line, i = text[i + 1:stop], stop + if (line.lstrip("\t") if strip_tabs else line) == delimiter: + break + documents = [] + start = i + 1 + i += 1 + if start < i: + words.append((text[start:i], start, expansions)) + if words: + yield words + return i + yield from scan() + + +def check_text(text): + for words in commands(text): + command = next((w for w, _, _ in words if not ASSIGNMENT.match(w) and w not in {"if", "then", "elif", "while", "until", "do", "!"}), "") + prefix = True + for index, (word, start, expansions) in enumerate(words): + assignment = ASSIGNMENT.match(word) and (prefix or command in {"export", "local", "declare", "readonly", "typeset"}) + prefix = prefix and (bool(assignment) or word in {"if", "then", "elif", "while", "until", "do", "!"}) + for offset in expansions: + array = word in {"$@", "$*"} or "[@]" in word or "[*]" in word + if not assignment and (command not in {"case", "for", "select"} or array) and not (index and words[index - 1][0] == "<<<"): + yield text.count("\n", 0, offset) + 1, "Quote this expansion to prevent word splitting and globbing" + if assignment and re.fullmatch(r'"[^"\n]*\$@[^"\n]*"', ASSIGNMENT.sub("", word, count=1)): + yield text.count("\n", 0, start) + 1, 'Use an array for "$@", or "$*" to join arguments' + if command == "read" and not any(re.fullmatch(r"-[A-Za-z]*r[A-Za-z0-9]*", w) for w, _, _ in words): + yield text.count("\n", 0, next(start for w, start, _ in words if w == "read")) + 1, "Use read -r to preserve backslashes" + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("filenames", nargs="+") + failed = False + for filename in parser.parse_args().filenames: + syntax = subprocess.run(["bash", "-n", "--", filename], check=False) + failed |= syntax.returncode != 0 + if syntax.returncode == 0: + for line, message in check_text(Path(filename).read_text()): + print(f"{filename}:{line}: {message}") + failed = True + raise SystemExit(failed) diff --git a/scripts/lint/lint.sh b/scripts/lint/lint.sh index fe3644d670..b05b2475cb 100755 --- a/scripts/lint/lint.sh +++ b/scripts/lint/lint.sh @@ -9,7 +9,7 @@ NC='\033[0m' DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" ROOT="$DIR/../../" -cd $ROOT +cd "$ROOT" FAILED=0 @@ -26,10 +26,10 @@ function run() { done shift 1; - CMD="$@" + CMD=("$@") set +e - log="$((eval "$CMD" ) 2>&1)" + log="$("${CMD[@]}" 2>&1)" if [[ $? -eq 0 ]]; then echo -e "[${GREEN}✔${NC}]" @@ -42,23 +42,21 @@ function run() { } function run_tests() { - ALL_FILES=$1 - PYTHON_FILES=$2 - run "ruff" ruff check openpilot --quiet - run "check_dependencies" python3 $DIR/check_dependencies.py - run "check_indentation" $DIR/check_indentation.py $PYTHON_FILES - run "check_added_large_files" $DIR/check_added_large_files.py --maxkb=120 $ALL_FILES - run "check_shebang_scripts_are_executable" $DIR/check_shebang_scripts_are_executable.py $ALL_FILES - run "check_shebang_format" $DIR/check_shebang_format.sh $ALL_FILES - run "check_nomerge_comments" $DIR/check_nomerge_comments.sh $ALL_FILES + run "check_shell" python3 "$DIR/check_shell.py" "${SHELL_FILES[@]}" + run "check_dependencies" python3 "$DIR/check_dependencies.py" + run "check_indentation" "$DIR/check_indentation.py" "${PYTHON_FILES[@]}" + run "check_added_large_files" "$DIR/check_added_large_files.py" --maxkb=120 "${ALL_FILES[@]}" + run "check_shebang_scripts_are_executable" "$DIR/check_shebang_scripts_are_executable.py" "${ALL_FILES[@]}" + run "check_shebang_format" "$DIR/check_shebang_format.sh" "${ALL_FILES[@]}" + run "check_nomerge_comments" "$DIR/check_nomerge_comments.sh" "${ALL_FILES[@]}" if [[ -z "$FAST" ]]; then run "ty" ty check openpilot - run "codespell" codespell $ALL_FILES + run "codespell" codespell "${ALL_FILES[@]}" fi - return $FAILED + return "$FAILED" } function help() { @@ -68,6 +66,7 @@ function help() { echo "" echo -e "${BOLD}${UNDERLINE}Tests:${NC}" echo -e " ${BOLD}ruff${NC}" + echo -e " ${BOLD}check_shell${NC}" echo -e " ${BOLD}check_dependencies${NC}" echo -e " ${BOLD}check_indentation${NC}" echo -e " ${BOLD}ty${NC}" @@ -103,16 +102,26 @@ while [[ $# -gt 0 ]]; do esac done -RUN=$([ -z "$RUN" ] && echo "" || echo "!($(echo $RUN | sed 's/ /|/g'))") -SKIP="@($(echo $SKIP | sed 's/ /|/g'))" +RUN=$([ -z "$RUN" ] && echo "" || echo "!($(echo "$RUN" | sed 's/ /|/g'))") +SKIP="@($(echo "$SKIP" | sed 's/ /|/g'))" -GIT_FILES="$(git ls-files openpilot)" -ALL_FILES="" -for f in $GIT_FILES; do +ALL_FILES=() +PYTHON_FILES=() +while IFS= read -r -d '' f; do if [[ -f $f ]]; then - ALL_FILES+="$f"$'\n' + ALL_FILES+=("$f") + if [[ $f == *.py ]]; then + PYTHON_FILES+=("$f") + fi fi -done -PYTHON_FILES=$(echo "$ALL_FILES" | grep --color=never '.py$' || true) +done < <(git ls-files -z openpilot) -run_tests "$ALL_FILES" "$PYTHON_FILES" +# Include tooling, launchers, and the extensionless Git hook. +SHELL_FILES=() +while IFS= read -r -d '' f; do + if [[ -f $f ]]; then + SHELL_FILES+=("$f") + fi +done < <(git ls-files -z '*.sh' '*.bash' scripts/post-commit) + +run_tests diff --git a/scripts/retry.sh b/scripts/retry.sh index 23501d7559..382190fe10 100755 --- a/scripts/retry.sh +++ b/scripts/retry.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash function fail { - echo $1 >&2 + echo "$1" >&2 exit 1 } @@ -14,7 +14,7 @@ function retry { "$@" && break || { if [[ $n -lt $max ]]; then ((n++)) - sleep $delay; + sleep "$delay"; else fail "The command has failed after $n attempts." fi diff --git a/tools/op.sh b/tools/op.sh index 183b4b1012..d1a7dcebbb 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -14,9 +14,9 @@ UNDERLINE='\033[4m' BOLD='\033[1m' NC='\033[0m' -SHELL_NAME="$(basename ${SHELL})" -RC_FILE="${HOME}/.$(basename ${SHELL})rc" -if [ "$(uname)" == "Darwin" ] && [ $SHELL == "/bin/bash" ]; then +SHELL_NAME="$(basename "${SHELL}")" +RC_FILE="${HOME}/.$(basename "${SHELL}")rc" +if [ "$(uname)" == "Darwin" ] && [ "$SHELL" == "/bin/bash" ]; then RC_FILE="$HOME/.bash_profile" fi @@ -250,7 +250,7 @@ function op_venv() { case $SHELL_NAME in "zsh") ZSHRC_DIR=$(mktemp -d 2>/dev/null || mktemp -d -t 'tmp_zsh') - echo "source \"$RC_FILE\"; source \"$OPENPILOT_ROOT/.venv/bin/activate\"" >> $ZSHRC_DIR/.zshrc + echo "source \"$RC_FILE\"; source \"$OPENPILOT_ROOT/.venv/bin/activate\"" >> "$ZSHRC_DIR/.zshrc" ZDOTDIR=$ZSHRC_DIR zsh ;; *) bash --rcfile <(echo "source \"$RC_FILE\"; source \"$OPENPILOT_ROOT/.venv/bin/activate\"") ;; @@ -405,14 +405,14 @@ function op_start() { if [[ -f "/AGNOS" ]]; then op_before_cmd op_check_agnos_update - op_run_command sudo systemctl restart comma $@ + op_run_command sudo systemctl restart comma "$@" fi } function op_stop() { if [[ -f "/AGNOS" ]]; then op_before_cmd - op_run_command sudo systemctl stop comma $@ + op_run_command sudo systemctl stop comma "$@" fi } diff --git a/tools/release/build_release.sh b/tools/release/build_release.sh index cced9f7a54..6dcf99957f 100755 --- a/tools/release/build_release.sh +++ b/tools/release/build_release.sh @@ -3,7 +3,7 @@ set -e set -x DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" -cd $DIR +cd "$DIR" BUILD_DIR=/data/openpilot SOURCE_DIR="$(git rev-parse --show-toplevel)" @@ -19,26 +19,26 @@ BUILD_BRANCH=release-mici-staging # set git identity -source $DIR/identity.sh +source "$DIR/identity.sh" echo "[-] Setting up repo T=$SECONDS" if ! git -C "$SOURCE_DIR" worktree remove --force "$BUILD_DIR" 2>/dev/null; then - rm -rf $BUILD_DIR + rm -rf "$BUILD_DIR" fi git -C "$SOURCE_DIR" worktree prune git -C "$SOURCE_DIR" worktree add --detach --no-checkout "$BUILD_DIR" -cd $BUILD_DIR +cd "$BUILD_DIR" git update-ref -d "refs/heads/$BUILD_BRANCH" git symbolic-ref HEAD "refs/heads/$BUILD_BRANCH" git read-tree --empty # do the files copy echo "[-] copying files T=$SECONDS" -cd $SOURCE_DIR +cd "$SOURCE_DIR" ./tools/release/release_files.py | xargs -0 cp -pR --parents -t "$BUILD_DIR" -- # in the directory -cd $BUILD_DIR +cd "$BUILD_DIR" # use the full CPU available for speeding up the build. # openpilot resets the CPU frequencies when test_onroad.py runs below. @@ -88,7 +88,7 @@ git -c core.compression=0 add -f . git -c core.compression=0 -c gc.auto=0 commit -m "openpilot v$VERSION" # Run tests -cd $BUILD_DIR +cd "$BUILD_DIR" RELEASE=1 ./openpilot/selfdrive/test/test_onroad.py "$@" #tools/test_runner.py openpilot/selfdrive/car/tests/test_car_interfaces.py diff --git a/tools/release/build_stripped.sh b/tools/release/build_stripped.sh index a216dfe11c..239add2518 100755 --- a/tools/release/build_stripped.sh +++ b/tools/release/build_stripped.sh @@ -3,23 +3,23 @@ set -ex DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" -SOURCE_DIR="$(git -C $DIR rev-parse --show-toplevel)" +SOURCE_DIR="$(git -C "$DIR" rev-parse --show-toplevel)" if [ -z "$TARGET_DIR" ]; then TARGET_DIR="$(mktemp -d)" fi # set git identity -source $DIR/identity.sh +source "$DIR/identity.sh" echo "[-] Setting up target repo T=$SECONDS" -rm -rf $TARGET_DIR -mkdir -p $TARGET_DIR -cd $TARGET_DIR -cp -r $SOURCE_DIR/.git $TARGET_DIR +rm -rf "$TARGET_DIR" +mkdir -p "$TARGET_DIR" +cd "$TARGET_DIR" +cp -r "$SOURCE_DIR/.git" "$TARGET_DIR" echo "[-] setting up stripped branch sync T=$SECONDS" -cd $TARGET_DIR +cd "$TARGET_DIR" # tmp branch git checkout --orphan tmp @@ -32,20 +32,20 @@ find . -maxdepth 1 -not -path './.git' -not -name '.' -not -name '..' -exec rm - # do the files copy echo "[-] copying files T=$SECONDS" -cd $SOURCE_DIR +cd "$SOURCE_DIR" ./tools/release/release_files.py | xargs -0 cp -pR --parents -t "$TARGET_DIR" -- # in the directory -cd $TARGET_DIR +cd "$TARGET_DIR" rm -rf .git/modules/ find openpilot/selfdrive/modeld/models -name '*.onnx' -size +95M -exec ./openpilot/common/file_chunker.py {} \; # include source commit hash and build date in commit -GIT_HASH=$(git --git-dir=$SOURCE_DIR/.git rev-parse HEAD) -GIT_COMMIT_DATE=$(git --git-dir=$SOURCE_DIR/.git show --no-patch --format='%ct %ci' HEAD) +GIT_HASH=$(git --git-dir="$SOURCE_DIR/.git" rev-parse HEAD) +GIT_COMMIT_DATE=$(git --git-dir="$SOURCE_DIR/.git" show --no-patch --format='%ct %ci' HEAD) DATETIME=$(date '+%Y-%m-%dT%H:%M:%S') -VERSION=$(cat $SOURCE_DIR/openpilot/common/version.h | awk -F\" '{print $2}') +VERSION=$(cat "$SOURCE_DIR/openpilot/common/version.h" | awk -F\" '{print $2}') echo -n "$GIT_HASH" > git_src_commit echo -n "$GIT_COMMIT_DATE" > git_src_commit_date @@ -82,7 +82,7 @@ if [ ! -z "$BRANCH" ]; then git config --local core.hooksPath .git/hooks git lfs update --force # uploading the larger pack is faster than spending CPU to optimize it - git -c pack.window=0 -c pack.depth=0 -c pack.compression=0 push -f origin tmp:$BRANCH + git -c pack.window=0 -c pack.depth=0 -c pack.compression=0 push -f origin "tmp:$BRANCH" fi echo "[-] done T=$SECONDS, ready at $TARGET_DIR" diff --git a/tools/release/check-dirty.sh b/tools/release/check-dirty.sh index ac049970cf..73d393f3c2 100755 --- a/tools/release/check-dirty.sh +++ b/tools/release/check-dirty.sh @@ -2,7 +2,7 @@ set -e DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" -cd $DIR +cd "$DIR" if [ ! -z "$(git status --porcelain)" ]; then echo "Dirty working tree after build:" diff --git a/tools/release/check-submodules.sh b/tools/release/check-submodules.sh index 93869a7403..c93bb58752 100755 --- a/tools/release/check-submodules.sh +++ b/tools/release/check-submodules.sh @@ -1,13 +1,13 @@ #!/usr/bin/env bash -while read hash submodule ref; do +while read -r hash submodule ref; do if [ "$submodule" = "tinygrad_repo" ]; then echo "Skipping $submodule" continue fi - git -C $submodule fetch --depth 100 origin master - git -C $submodule branch -r --contains $hash | grep "origin/master" + git -C "$submodule" fetch --depth 100 origin master + git -C "$submodule" branch -r --contains "$hash" | grep "origin/master" if [ "$?" -eq 0 ]; then echo "$submodule ok" else diff --git a/tools/scripts/adb_ssh.sh b/tools/scripts/adb_ssh.sh index c584a72f2b..b808243d36 100755 --- a/tools/scripts/adb_ssh.sh +++ b/tools/scripts/adb_ssh.sh @@ -36,7 +36,7 @@ SSH_PORT=2222 while ss -tln | grep -q ":${SSH_PORT} "; do SSH_PORT=$((SSH_PORT + 1)) done -adb forward tcp:${SSH_PORT} tcp:22 +adb forward tcp:"${SSH_PORT}" tcp:22 # SSH! -ssh comma@localhost -p ${SSH_PORT} "$@" +ssh comma@localhost -p "${SSH_PORT}" "$@" diff --git a/tools/setup.sh b/tools/setup.sh index ced451ab11..81eb543114 100755 --- a/tools/setup.sh +++ b/tools/setup.sh @@ -59,10 +59,10 @@ function ask_dir() { return 0 fi - read + read -r if [[ ! -z "$REPLY" ]]; then - mkdir -p $REPLY - OPENPILOT_ROOT="$(realpath $REPLY)/openpilot" + mkdir -p "$REPLY" + OPENPILOT_ROOT="$(realpath "$REPLY")/openpilot" fi } @@ -114,7 +114,7 @@ function check_git() { function git_clone() { st="$(date +%s)" echo "Cloning openpilot..." - if $(git clone --filter=blob:none https://github.com/commaai/openpilot.git "$OPENPILOT_ROOT"); then + if git clone --filter=blob:none https://github.com/commaai/openpilot.git "$OPENPILOT_ROOT"; then if [[ -f $OPENPILOT_ROOT/launch_openpilot.sh ]]; then et="$(date +%s)" echo -e " ↳ [${GREEN}✔${NC}] Successfully cloned openpilot in $((et - st)) seconds.\n" @@ -127,10 +127,10 @@ function git_clone() { } function install_with_op() { - cd $OPENPILOT_ROOT - $OPENPILOT_ROOT/tools/op.sh post-commit + cd "$OPENPILOT_ROOT" + "$OPENPILOT_ROOT/tools/op.sh" post-commit - if ! $OPENPILOT_ROOT/tools/op.sh setup; then + if ! "$OPENPILOT_ROOT/tools/op.sh" setup; then echo -e "\n[${RED}✗${NC}] failed to install openpilot!" return 1 fi @@ -147,5 +147,5 @@ check_stdin ask_dir check_dir check_git -[ -z $SKIP_GIT_CLONE ] && git_clone +[ -z "$SKIP_GIT_CLONE" ] && git_clone install_with_op diff --git a/tools/setup_dependencies.sh b/tools/setup_dependencies.sh index 7af2180686..c9e635836b 100755 --- a/tools/setup_dependencies.sh +++ b/tools/setup_dependencies.sh @@ -20,14 +20,14 @@ function retry() { } function install_linux_deps() { - SUDO="" + SUDO=() if [[ ! $(id -u) -eq 0 ]]; then if [[ -z $(which sudo) ]]; then echo "Please install sudo or run as root" exit 1 fi - SUDO="sudo" + SUDO=(sudo) fi local missing_linux_deps=0 @@ -51,28 +51,28 @@ function install_linux_deps() { # the native package managers are slow, so skip if we can echo "[ ] system packages already installed t=$SECONDS" elif command -v apt-get > /dev/null 2>&1; then - $SUDO apt-get update - $SUDO apt-get install -y --no-install-recommends ca-certificates build-essential curl libcurl4-openssl-dev locales git xclip wl-clipboard + "${SUDO[@]}" apt-get update + "${SUDO[@]}" apt-get install -y --no-install-recommends ca-certificates build-essential curl libcurl4-openssl-dev locales git xclip wl-clipboard elif command -v dnf > /dev/null 2>&1; then - $SUDO dnf install -y ca-certificates gcc gcc-c++ make curl libcurl-devel glibc-langpack-en git + "${SUDO[@]}" dnf install -y ca-certificates gcc gcc-c++ make curl libcurl-devel glibc-langpack-en git elif command -v yum > /dev/null 2>&1; then - $SUDO yum install -y ca-certificates gcc gcc-c++ make curl libcurl-devel glibc-langpack-en git + "${SUDO[@]}" yum install -y ca-certificates gcc gcc-c++ make curl libcurl-devel glibc-langpack-en git elif command -v pacman > /dev/null 2>&1; then - $SUDO pacman -Syu --noconfirm --needed base-devel ca-certificates curl git + "${SUDO[@]}" pacman -Syu --noconfirm --needed base-devel ca-certificates curl git elif command -v zypper > /dev/null 2>&1; then - $SUDO zypper --non-interactive refresh - $SUDO zypper --non-interactive install ca-certificates gcc gcc-c++ make curl libcurl-devel glibc-locale git + "${SUDO[@]}" zypper --non-interactive refresh + "${SUDO[@]}" zypper --non-interactive install ca-certificates gcc gcc-c++ make curl libcurl-devel glibc-locale git elif command -v apk > /dev/null 2>&1; then - $SUDO apk add --no-cache ca-certificates build-base curl curl-dev musl-locales git + "${SUDO[@]}" apk add --no-cache ca-certificates build-base curl curl-dev musl-locales git elif command -v xbps-install > /dev/null 2>&1; then - $SUDO xbps-install -Syu base-devel ca-certificates curl git libcurl-devel glibc-locales + "${SUDO[@]}" xbps-install -Syu base-devel ca-certificates curl git libcurl-devel glibc-locales else echo "Unsupported Linux distribution. Supported package managers: apt-get, dnf, yum, pacman, zypper, apk, xbps-install." exit 1 fi if [[ -d "/etc/udev/rules.d/" ]]; then - $SUDO tee /etc/udev/rules.d/11-openpilot.rules > /dev/null <<-EOF + "${SUDO[@]}" tee /etc/udev/rules.d/11-openpilot.rules > /dev/null <<-EOF # Panda Jungle devices SUBSYSTEM=="usb", ATTRS{idVendor}=="3801", ATTRS{idProduct}=="ddcf", MODE="0666" SUBSYSTEM=="usb", ATTRS{idVendor}=="3801", ATTRS{idProduct}=="ddef", MODE="0666" @@ -91,9 +91,9 @@ function install_linux_deps() { EOF # delete the old ones - $SUDO rm -f /etc/udev/rules.d/11-panda.rules /etc/udev/rules.d/12-panda_jungle.rules /etc/udev/rules.d/50-comma-adb.rules + "${SUDO[@]}" rm -f /etc/udev/rules.d/11-panda.rules /etc/udev/rules.d/12-panda_jungle.rules /etc/udev/rules.d/50-comma-adb.rules - $SUDO udevadm control --reload-rules && $SUDO udevadm trigger || true + "${SUDO[@]}" udevadm control --reload-rules && "${SUDO[@]}" udevadm trigger || true fi } From 19f0f69d85969eaf2cedcb884d69e83c8fd21840 Mon Sep 17 00:00:00 2001 From: commaci-public <60409688+commaci-public@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:28:19 -0700 Subject: [PATCH 078/122] [bot] Update Python packages (#38698) * Update Python packages * Fix pandad exports --------- Co-authored-by: Vehicle Researcher Co-authored-by: Adeeb Shihadeh --- docs/CARS.md | 14 +- opendbc_repo | 2 +- openpilot/selfdrive/pandad/__init__.py | 4 +- panda | 2 +- rednose_repo | 2 +- tinygrad_repo | 2 +- uv.lock | 504 ++++++++++++------------- 7 files changed, 264 insertions(+), 266 deletions(-) diff --git a/docs/CARS.md b/docs/CARS.md index 1e0bd07b77..5cf1932caa 100644 --- a/docs/CARS.md +++ b/docs/CARS.md @@ -34,7 +34,7 @@ A supported vehicle is one that just works when you install a comma device. All |Chrysler|Pacifica Hybrid 2019-25|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|

Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |comma|body|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|None||| |CUPRA[12](#footnotes)|Ateca 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|CUPRA[12](#footnotes)|Born 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|CUPRA|Born 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW MEB connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Dodge|Durango 2020-21|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Ford|Bronco Sport 2021-24|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Ford|Escape 2020-22|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -99,7 +99,7 @@ A supported vehicle is one that just works when you install a comma device. All |Honda|Fit 2018-20|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Freed 2020|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|HR-V 2019-22|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Honda|HR-V 2023-25|All|openpilot available[1,5](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|HR-V 2023-27|All|openpilot available[1,5](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Insight 2019-22|All|openpilot available[1,5](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Inspire 2018|All|openpilot available[1,5](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|N-Box 2018|All|openpilot available[1,5](#footnotes)|0 mph|11 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -258,8 +258,8 @@ A supported vehicle is one that just works when you install a comma device. All |Škoda[12](#footnotes)|Superb 2015-22[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Tesla[10](#footnotes)|Model 3 (with HW3) 2019-23[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla A connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Tesla[10](#footnotes)|Model 3 (with HW4) 2024-25[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla B connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Tesla[10](#footnotes)|Model Y (with HW3) 2020-23[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla A connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Tesla[10](#footnotes)|Model Y (with HW4) 2024-25[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla B connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Tesla[10](#footnotes)|Model Y (with HW3) 2020-24[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla A connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Tesla[10](#footnotes)|Model Y (with HW4) 2023-25[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla B connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Toyota|Alphard 2019-20|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|Alphard Hybrid 2021|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|Avalon 2016|Toyota Safety Sense P|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -325,8 +325,8 @@ A supported vehicle is one that just works when you install a comma device. All |Volkswagen[12](#footnotes)|Golf R 2015-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Volkswagen[12](#footnotes)|Golf SportsVan 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Volkswagen[12](#footnotes)|Grand California 2019-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|ID.4 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|ID.4 2024-25|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|ID.4 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW MEB connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|ID.4 2024-25|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW MEB connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Volkswagen[12](#footnotes)|Jetta 2019-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Volkswagen[12](#footnotes)|Jetta GLI 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Volkswagen|Passat 2015-22[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| @@ -353,7 +353,7 @@ A supported vehicle is one that just works when you install a comma device. All 6See more setup details for Nissan.
7In the non-US market, openpilot requires the car to come equipped with EyeSight with Lane Keep Assistance.
8Enabling longitudinal control (alpha) will disable all EyeSight functionality, including AEB, LDW, and RAB.
-9Some 2023 model years have HW4. To check which hardware type your vehicle has, look for Autopilot computer under Software -> Additional Vehicle Information on your vehicle's touchscreen. See this page for more information.
+9Model years 2023 and 2024 can have either hardware type, depending on build date and factory. To check which hardware type your vehicle has, look for Autopilot computer under Software -> Additional Vehicle Information on your vehicle's touchscreen. See this page for more information.
10See more setup details for Tesla.
11openpilot operates above 28mph for Camry 4CYL L, 4CYL LE and 4CYL SE which don't have Full-Speed Range Dynamic Radar Cruise Control.
12The J533 harness plugs in at the CAN gateway under the dashboard, just above the steering column. More information can be found at this guide.
diff --git a/opendbc_repo b/opendbc_repo index b4ef5e1cf4..a3d3b7c6ca 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit b4ef5e1cf406ff143fa67bdbfb154739d43279c9 +Subproject commit a3d3b7c6ca76ed4c97f606e9b2a0e371e8fa52af diff --git a/openpilot/selfdrive/pandad/__init__.py b/openpilot/selfdrive/pandad/__init__.py index 0c17e886a2..5f7a8fec86 100644 --- a/openpilot/selfdrive/pandad/__init__.py +++ b/openpilot/selfdrive/pandad/__init__.py @@ -1,3 +1 @@ -from openpilot.selfdrive.pandad.pandad_api_impl import can_list_to_can_capnp, can_capnp_to_list -assert can_list_to_can_capnp -assert can_capnp_to_list +from openpilot.selfdrive.pandad.pandad_api_impl import can_list_to_can_capnp as can_list_to_can_capnp, can_capnp_to_list as can_capnp_to_list diff --git a/panda b/panda index 75aa44bec9..5314c84d9e 160000 --- a/panda +++ b/panda @@ -1 +1 @@ -Subproject commit 75aa44bec9140849868239b1f1e3f22624adb8fe +Subproject commit 5314c84d9e8d18aada38bd4fff86b2d42b6eb502 diff --git a/rednose_repo b/rednose_repo index 28d4a7f69e..8671c17c3a 160000 --- a/rednose_repo +++ b/rednose_repo @@ -1 +1 @@ -Subproject commit 28d4a7f69e80e1c3e0d24ca0733d7daeaeade3d0 +Subproject commit 8671c17c3a4cdc4be5df07a068039e2da5b94eaa diff --git a/tinygrad_repo b/tinygrad_repo index f6fc4e3f2c..76059714bf 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit f6fc4e3f2c3db5fae1e19cbfbc3ad9fc579a12ae +Subproject commit 76059714bf4468363b52358434f7ce35be7f425f diff --git a/uv.lock b/uv.lock index fe85a470bc..9b1ffef34c 100644 --- a/uv.lock +++ b/uv.lock @@ -98,229 +98,229 @@ wheels = [ [[package]] name = "comma-deps-acados" -version = "0.2.2.post98" +version = "0.2.2.post103" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/13/1190aed06e91a9f9024b16fb44a4184842e56ac39dbaec8e6aea83cb1d7e/comma_deps_acados-0.2.2.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:64c002e0d6170c7bdec300159bbbe07cf3e05fa54ae5890fe736190fc3543fe7", size = 10635996, upload-time = "2026-07-23T17:01:04.136Z" }, - { url = "https://files.pythonhosted.org/packages/d6/24/a16888f692e2a2759b656847b7e6ca2dbbca666e64e1372e3e158d25e29f/comma_deps_acados-0.2.2.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:356ce6020430eb5b53d637bf870c27b10ad8599c9ade0abd2c520c3e00143410", size = 11657744, upload-time = "2026-07-23T17:01:08.618Z" }, - { url = "https://files.pythonhosted.org/packages/73/9d/24377b731093e015a44fff043dd7ea5b77b0de62acf48b5a0e7d5a662a15/comma_deps_acados-0.2.2.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:e55ac429d848415930a0b82ab100a310e1848b48cdbaa8dda574b561b43c50d0", size = 13124767, upload-time = "2026-07-23T17:01:13.091Z" }, + { url = "https://files.pythonhosted.org/packages/12/bd/b8bbb3791b42c5aef32c5ebaeaebcf4d1d712cc9e67222523daa179c7419/comma_deps_acados-0.2.2.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1067e2404c08bab1ebcdd46b00d505f956d2edacc9f50d307b0618b35eff0421", size = 10554192, upload-time = "2026-09-11T22:31:14.412Z" }, + { url = "https://files.pythonhosted.org/packages/a3/72/31488259bb173161089393e7383ae90a7f3fb76b5d41098639a78bd9699f/comma_deps_acados-0.2.2.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2ad9fcebef1f65112a9cebe8a093977310602e1325d442dc345beaf512679211", size = 11670466, upload-time = "2026-09-11T22:31:18.634Z" }, + { url = "https://files.pythonhosted.org/packages/98/eb/83c5ab1275b812ac10988e1ddbfd541088e8a5a883e45f34bd0ee35e2138/comma_deps_acados-0.2.2.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3b451852e83d62815cead999ab31073db9be60307f772650336cdd4534f12b9e", size = 13139433, upload-time = "2026-09-11T22:31:22.891Z" }, ] [[package]] name = "comma-deps-bootstrap-icons" -version = "1.10.5.0.post98" +version = "1.10.5.0.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/69/da1a72b8b7783b0caf9a54b27c7124bad11768b8bce2c656ef3b700ab831/comma_deps_bootstrap_icons-1.10.5.0.post98-py3-none-any.whl", hash = "sha256:cabaeecea398eb867b96a6c653c6078691a437c0eff2364530194a218d94cb99", size = 385998, upload-time = "2026-07-23T17:01:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/21/17/277a473fb1f6b28f88ca0b6c477b1ddf439f68371c0d4e80fdc7925f98ea/comma_deps_bootstrap_icons-1.10.5.0.post103-py3-none-any.whl", hash = "sha256:9879fa2b3b112d20f4acf6cc83bb9732f95f0864db74d25224a31fa9827f37ae", size = 386008, upload-time = "2026-09-11T22:31:27.373Z" }, ] [[package]] name = "comma-deps-capnproto" -version = "1.0.1.post98" +version = "1.0.1.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/83/d3e6346a31491be1d378e4585f37a7979eb772018616abfa74fb27750f1e/comma_deps_capnproto-1.0.1.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9f4d08682df92411b360bec855cb6475990313cd0ecd8ed5c6ee02befb9db913", size = 2407343, upload-time = "2026-07-23T17:01:21.247Z" }, - { url = "https://files.pythonhosted.org/packages/b0/8b/6f2a29d50ed4c8741dbf0a34ab109899268d09753518cd693e881bbf1a9d/comma_deps_capnproto-1.0.1.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:6cdf838a8d415ac71e1f52306624ab3ab6f27777f6ce89c0059c4129ea7b7f62", size = 2506355, upload-time = "2026-07-23T17:01:25.254Z" }, - { url = "https://files.pythonhosted.org/packages/08/24/e91f2203d62e4db9de7dae06dd0cdefb1e000d8b3ba0bde48367be7e5b63/comma_deps_capnproto-1.0.1.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3d95993c9aff0c89e39ca965e995021dd3dccdfc3d5d85152916cf4bf651b7ec", size = 2590764, upload-time = "2026-07-23T17:01:29.062Z" }, + { url = "https://files.pythonhosted.org/packages/61/ef/a108b56be5bd6a655c4acaf9c7d6a11c4397d5440bd192314dc2b4e5b997/comma_deps_capnproto-1.0.1.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cfea5b5679a530affc674717173455e1bb26bde06491283f461d8c070df33811", size = 2404560, upload-time = "2026-09-11T22:31:31.176Z" }, + { url = "https://files.pythonhosted.org/packages/c4/53/38b0e046aab3f0d371d0fa6eb3d4eb3cf787e7d270f429a485e53080416c/comma_deps_capnproto-1.0.1.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4a115a789af5835dde75371abdd0c1766df40764eaccfd912de4212522935d08", size = 2503570, upload-time = "2026-09-11T22:31:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/22/7c/fcbdd3ac0f635a3b1eb674e5bd4c3ada804c89348c1a3818ca2fc91e3d91/comma_deps_capnproto-1.0.1.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:f5717a6e6bee615bca9a68e9a5d10a11a871e0f61ee2d66759681de562d82c9a", size = 2587991, upload-time = "2026-09-11T22:31:38.822Z" }, ] [[package]] name = "comma-deps-eigen" -version = "3.4.0.post98" +version = "3.4.0.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/2f/89011c71976da6e1c3d7be315afa3d86ff25deeada1ad2319ac6be0e18ea/comma_deps_eigen-3.4.0.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bd182634cf4fa537e7815238d3135e6e89be5826421a495be92258c6c388b527", size = 2275893, upload-time = "2026-07-23T17:01:44.622Z" }, - { url = "https://files.pythonhosted.org/packages/2c/61/fcd4ad536c51437ee73ac255f3b8a23fb5f21bb1f96e834d8036c3bbcf08/comma_deps_eigen-3.4.0.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:250c15f6217c37736a2f54298d01f6e32f6e977faa87cc358de67ea3d121725e", size = 2275896, upload-time = "2026-07-23T17:01:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/d3/a2/2b7633fe5a5a2914900933393c315e9bd86e8fb7bbbe328d3a220eaf2027/comma_deps_eigen-3.4.0.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:dee9eb6c6c7e58201d7a36611857b7b3f3ba70f888ee07493fef2ea41d0d2cae", size = 2275898, upload-time = "2026-07-23T17:01:52.179Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b4/bfcd0e8b1480059e94f978a85497ebffdd33d59a8272a68556e23ed7d1b8/comma_deps_eigen-3.4.0.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:196b9019947552464cf72c7c4659ade6d8bd6733e0bb063d910b168cdc0c2c75", size = 2272667, upload-time = "2026-09-11T22:31:53.434Z" }, + { url = "https://files.pythonhosted.org/packages/6f/23/02407262cc7cb6509bd95b237535b3b32e6447b24abc32200aed6e9b4745/comma_deps_eigen-3.4.0.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:5f18b5ae2b007f5422ce67f1e1e805bb07404885d3a8718a0341daca748a0980", size = 2272674, upload-time = "2026-09-11T22:31:57.274Z" }, + { url = "https://files.pythonhosted.org/packages/40/43/a5001078c02a5f6c28e26e3d0458a7e53826d77e9945f3196921df620a41/comma_deps_eigen-3.4.0.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:e07754a4f6062285545f70c597321a5736e193aa5a600ebe189af9e43ad965ff", size = 2272674, upload-time = "2026-09-11T22:32:01.084Z" }, ] [[package]] name = "comma-deps-ffmpeg" -version = "7.1.0.post98" +version = "7.1.0.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/59/4899ac0fa54905f43e237fff122008d6c591918b41e36880eeb18cd6279c/comma_deps_ffmpeg-7.1.0.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7816c5adc9c6a7462209ccf1d023c42d7e38a4eee47aa2f43d45dfc8320063f8", size = 7326312, upload-time = "2026-07-23T17:01:55.975Z" }, - { url = "https://files.pythonhosted.org/packages/29/cb/6e047c19c39977c5ae322ad698b91d8d9fce43314cb86563de91bb161982/comma_deps_ffmpeg-7.1.0.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:45ad401b4058e3f7efb8d6841e1f187e8c265e942e56e7fb01db1ba9096e6b78", size = 4437675, upload-time = "2026-07-23T17:01:59.971Z" }, - { url = "https://files.pythonhosted.org/packages/76/3d/cda4b19fa5a7b26921a518143c94fd3632030a34b157cab6d6f10f2c86bc/comma_deps_ffmpeg-7.1.0.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:9e7034739d45a45254c4200a555d343b22ce117429bebc2de2c1b05f050bfc8c", size = 4681499, upload-time = "2026-07-23T17:02:03.963Z" }, + { url = "https://files.pythonhosted.org/packages/90/10/1407eb09f39d036c2c4d101c3e429f69059a20c78197669a2b10023ac30e/comma_deps_ffmpeg-7.1.0.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4b5074ae64ff44f32e5d2b44ea577cafeee5279e6f1029cc05ab74429f72a64f", size = 7326617, upload-time = "2026-09-11T22:32:04.765Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3b/770c32c1a232dc73bfb43f5f5834ecb2ff8fe55ff627513e1ef458214c7c/comma_deps_ffmpeg-7.1.0.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c0da5b62987de3375d1f8fa2e68d85d1a4a11bbb19a42235ae1f907f372bc364", size = 4437978, upload-time = "2026-09-11T22:32:08.792Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c8/a17c8c293b64ed75074510d9460135b2875ac1832028f950d8f69982df53/comma_deps_ffmpeg-7.1.0.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:8756f18e5b1fd760663dfed3b0762fd6b6a5f5e5acfb695e64d06605d2d9ce68", size = 4681802, upload-time = "2026-09-11T22:32:12.583Z" }, ] [[package]] name = "comma-deps-gcc-arm-none-eabi" -version = "13.2.1.post98" +version = "13.2.1.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/e5/a4cd9faa80bf419c6a7052c99dfe565c283a5c966e90ce35b1b4040b24b8/comma_deps_gcc_arm_none_eabi-13.2.1.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d0e6991b845636ab19e46199bc5cb9dd056611bc6b93c6bca4f2bb5002783533", size = 15238810, upload-time = "2026-07-23T17:02:08.588Z" }, - { url = "https://files.pythonhosted.org/packages/5b/81/690ce48945aecf58e475cb728a8d2f6c034493afd87b5b0381a85dd324b4/comma_deps_gcc_arm_none_eabi-13.2.1.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:41fef00d033e0f6c12e1748829d95e53942826e0085f57d918351b2de69530d7", size = 17367240, upload-time = "2026-07-23T17:02:13.637Z" }, - { url = "https://files.pythonhosted.org/packages/41/a9/6af914145bd5c9ce3468a95500558bdc0a438f69700daedd37535945294e/comma_deps_gcc_arm_none_eabi-13.2.1.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:ed3630aac06b3a1db78ba5a29a33b900a51dd1c0b37f86cbfe3c1591993178f8", size = 16941137, upload-time = "2026-07-23T17:02:18.976Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3c/d106a7edbb6e832974fde089c04282da9e0d035e8de1b1b5c6dbedb9deaf/comma_deps_gcc_arm_none_eabi-13.2.1.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ae7247a36c61a7c3864ac052dadd6787235ffc04d0b724972968ac57164fde00", size = 15239169, upload-time = "2026-09-11T22:32:16.377Z" }, + { url = "https://files.pythonhosted.org/packages/b5/85/3f97e71022055862e0a5f47c960eea18518a750da62ef7f6125216ec0e9c/comma_deps_gcc_arm_none_eabi-13.2.1.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:dd8eaac31e11dd291b4ea37f14c43e53e5b2b2b059207deb44b5caa3be2c6d86", size = 17367602, upload-time = "2026-09-11T22:32:21.355Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b5/4d8294c7e081b93363df0b6773be0bb2632d53c52e4fc89e64f51ab51ea5/comma_deps_gcc_arm_none_eabi-13.2.1.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:6006a1073292d56d3cc25f1a236fa4da6039028d9c251bdd66747f5d5013e020", size = 16941494, upload-time = "2026-09-11T22:32:25.886Z" }, ] [[package]] name = "comma-deps-git-lfs" -version = "3.6.1.post98" +version = "3.6.1.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/27/ecfda511eb334822d9bc464ec2d9b74d3c553784811a885baba34a27eaf6/comma_deps_git_lfs-3.6.1.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:259b1f4859bb3ab20fdcc012be0a9868a3649e1087d5cec07eaade7adea44c78", size = 4685104, upload-time = "2026-07-23T17:02:23.67Z" }, - { url = "https://files.pythonhosted.org/packages/00/a5/9631b4a676279b353f82d4e2da62eb567c70fff628133a62d7f70fbf5924/comma_deps_git_lfs-3.6.1.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:60d39254138b2c7c3f15cc512c14504c11881e79885492321e1ffc3ecb840f93", size = 4485276, upload-time = "2026-07-23T17:02:27.751Z" }, - { url = "https://files.pythonhosted.org/packages/09/5a/7ef6bc209d8ec15c40b1f988345e2e59c535a215284366916422ba0d0c30/comma_deps_git_lfs-3.6.1.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:9586057ca9c6e77e9068128f3e96f0ca03db29a757414b1b251bab4ca31ee6b8", size = 4889582, upload-time = "2026-07-23T17:02:31.454Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c3/75c2406e52cb5129d8ce6b79f80a610417d8c8729422c1e80b14b6771e69/comma_deps_git_lfs-3.6.1.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ba61d0c194725aee5644731f229ff36f56a00dfd450f8226794008ab98a93144", size = 4685121, upload-time = "2026-09-11T22:32:30.047Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4f/17fb16d2de5d858ab95fdd09fd652f25d8a698b35df3e4ce24589dfd9565/comma_deps_git_lfs-3.6.1.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2d815aab8aa153a6536f538a65872ac64aefe8c62da153f9b0e8bba23a98d5a8", size = 4485292, upload-time = "2026-09-11T22:32:33.868Z" }, + { url = "https://files.pythonhosted.org/packages/d3/30/ae0def1cf507492591fb2852066dda163fd28d0e1cf52ad76c20ce1fb358/comma_deps_git_lfs-3.6.1.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:a78ca419dec75a1b99229ac40c086c3236b86ff8585740c6b34c2ccea95b1389", size = 4889597, upload-time = "2026-09-11T22:32:38.084Z" }, ] [[package]] name = "comma-deps-imgui" -version = "1.92.7.post98" +version = "1.92.7.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/e4/b9f4b68973bfd529314c28fcd87cb2f52b5dc7d9fdeb3be2d3d15b7cea25/comma_deps_imgui-1.92.7.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6fddf76138b1e54fe33e9f5ea3cbcb650f92fef8fbcf7e5080800ea851d68b98", size = 1688011, upload-time = "2026-07-23T17:02:35.416Z" }, - { url = "https://files.pythonhosted.org/packages/7f/46/92030abf6e42e9813f144d10bcf541b39a246c5ca2d63d049478deac650b/comma_deps_imgui-1.92.7.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9f7eed0f759e59afcf289967edb3d94c48a38fe97527f2909dbbc70a850dc2cc", size = 2522785, upload-time = "2026-07-23T17:02:39.092Z" }, - { url = "https://files.pythonhosted.org/packages/7d/57/d41e76559a553565413976695fb63a768d3446d12eccc9e736a12b53e662/comma_deps_imgui-1.92.7.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:07eeb105cce73ec3b27789501c78dcd059d75e736a99f1953358ef5097e7036f", size = 2655476, upload-time = "2026-07-23T17:02:42.925Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0f/29f3dc72d8b745f8db30245502c7d5626eff5d244a4c02c4e89bbc8497ec/comma_deps_imgui-1.92.7.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bb693b2e9f3aa5900b8884527409e73d6580c8b05bb42e1ec1a2897f4f6c05fc", size = 1688025, upload-time = "2026-09-11T22:32:41.73Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4b/abf18a8cc4527c6cb1d8f8dbec10b02e28746b303850d90fe0af5ba82a9d/comma_deps_imgui-1.92.7.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:1b2737fa6f47ad00835828ff663eae7150519e0a6cc7df9e0b336a48c24032b6", size = 2522805, upload-time = "2026-09-11T22:32:45.498Z" }, + { url = "https://files.pythonhosted.org/packages/17/b8/d5fe737f156ca1dd1797f9ed664464b43fbe7712f9b61271fbe6315ed539/comma_deps_imgui-1.92.7.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:6554b6ff6ba818038ac8a6c51bd71afbb285c7b3f1c0c31947298b17db8f111b", size = 2655501, upload-time = "2026-09-11T22:32:49.008Z" }, ] [[package]] name = "comma-deps-json11" -version = "20170411.0.post98" +version = "20170411.0.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/f4/50411c9134a8347831a72f90318b7b7d91ce566e63575b8a4a821be50ca4/comma_deps_json11-20170411.0.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:20d666897062487e4cd93b8e4eb9c53ecabf706864be8d8cbb60a56f0113c452", size = 34034, upload-time = "2026-07-23T17:02:46.595Z" }, - { url = "https://files.pythonhosted.org/packages/1f/54/0c87fae682ee52e6aec371336ac980921ad34cedabb69e576cc9f83c40a7/comma_deps_json11-20170411.0.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9b4a03909609b832dc99b06503fb8c77419399a99e0db982d4c3f51f1563aa73", size = 41848, upload-time = "2026-07-23T17:02:50.039Z" }, - { url = "https://files.pythonhosted.org/packages/7b/71/dd100992e13f2c7a01f68eebcd1e3cf43f1d169e4b80b0577f330e5f5c12/comma_deps_json11-20170411.0.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:1da9030908f3a6631a0a254f493c382ba061c2e8ddb280a30d64430af29f4638", size = 42602, upload-time = "2026-07-23T17:02:53.233Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5c/8bed22f3118f53ae08763c6dfc755d08826c750ac21864d60a4710ef3183/comma_deps_json11-20170411.0.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cb255b3c010e83f8361869f7cf8b1f585db5e0dabc92b41f3132af49838e981a", size = 34039, upload-time = "2026-09-11T22:32:52.45Z" }, + { url = "https://files.pythonhosted.org/packages/6c/be/51c3dd8f80ee0a968a90b4737e0362e7b621366c24c9c55613386ba0aa14/comma_deps_json11-20170411.0.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0f7764609db411d98c540d985c289a2c37f282fbf2a75e2503017f1243546653", size = 41850, upload-time = "2026-09-11T22:32:55.711Z" }, + { url = "https://files.pythonhosted.org/packages/79/73/7f893e3a65e82c2453806c39e79eb4e2a8074f358cfa20e1b66e46e9a6b3/comma_deps_json11-20170411.0.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:a309d401dd22b758455dafd3c28b72c4bd88c7482ddd70fe8653e53efc745a83", size = 42604, upload-time = "2026-09-11T22:32:58.866Z" }, ] [[package]] name = "comma-deps-libusb" -version = "1.0.29.post98" +version = "1.0.29.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/fb/f7d342a8f785fc1c0fd5d6883e1a5a7d424a1899b888f78f9091f4b98049/comma_deps_libusb-1.0.29.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a10b82a946c33c23152cee3e330ce76d398ddce1f38fea62e33773bef8f56164", size = 102339, upload-time = "2026-07-23T17:02:56.567Z" }, - { url = "https://files.pythonhosted.org/packages/b7/fe/1b21692cc03078219a3946aae56086a109168a4b4dcfba3a22ce1cd01064/comma_deps_libusb-1.0.29.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:39567eeef6170ece389526780f90c3b75cbcfdf427f648f6b1539c203f7387a8", size = 94431, upload-time = "2026-07-23T17:03:00.01Z" }, - { url = "https://files.pythonhosted.org/packages/49/d2/d93aac76b94ae87f7a37ce88f2e7e1184e19e67d10c068c1aac209075450/comma_deps_libusb-1.0.29.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:5e5b86be94b4a355c6be933ed21586d01b1d7ba597298dafd33b871a1ae66416", size = 93462, upload-time = "2026-07-23T17:03:03.218Z" }, + { url = "https://files.pythonhosted.org/packages/ad/81/865cbc69c8f182a020f4fb85c70b4d4e1b2a636eedfc34c85490472c1e56/comma_deps_libusb-1.0.29.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7a8afc81bce03f3d6386613983a23a303369bed19593e84d0c744e8381087cb1", size = 102343, upload-time = "2026-09-11T22:33:02.116Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1a/d07a980d8132c58cc328974c0566c8263fcff9b60e053129030fd849dd2c/comma_deps_libusb-1.0.29.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:56661f2674fd41abb5337a435c032552ecd7318857d598f79c4fd6cdddd727d8", size = 94431, upload-time = "2026-09-11T22:33:05.488Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2f/19227534c7f7cf4850ef73df16ec5feabd80e23edebec648877a5b82eab6/comma_deps_libusb-1.0.29.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:450c9bfaad634931c336eeea8369d4ac8db78ba092b3c5d7b70c098d5634b050", size = 93460, upload-time = "2026-09-11T22:33:08.701Z" }, ] [[package]] name = "comma-deps-ncurses" -version = "6.5.post98" +version = "6.5.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/d4/03e62b2a0be92ad420653ff1cf4396de9840c4c59cdc6e000ea5614f7744/comma_deps_ncurses-6.5.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:22ade1596deb18538d4bf59708aa2747c28c2e0e7048f13f5bfce3e5588f7417", size = 264921, upload-time = "2026-07-23T17:03:06.576Z" }, - { url = "https://files.pythonhosted.org/packages/1a/ab/295b428ef473dbe0d7088ff02daf5ba17b924f3b915ac69a8a9c45a6eb2b/comma_deps_ncurses-6.5.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:6724d3e8c1f2e59d2475f7588d86494bcc4001e993fd7dff4c91ecb046edc97f", size = 260844, upload-time = "2026-07-23T17:03:10.329Z" }, - { url = "https://files.pythonhosted.org/packages/cd/db/75afb33eaa86425d9bee68153f6141be2645cf5699b2cab5a7cbf7a36099/comma_deps_ncurses-6.5.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:d85e70e98f4b0a969d63a4a61a1a5b85db3c648ea58422401b55f386813f7d12", size = 248352, upload-time = "2026-07-23T17:03:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/94/43/6a0c4cec1c245ec0dafa8d2c6bfa433b596153f7efa0d58f6b80c0145a74/comma_deps_ncurses-6.5.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:aad15d3cd54e6930defdad9f3c968b3f115ea634369b75b62b1457eee4789208", size = 264908, upload-time = "2026-09-11T22:33:12.25Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ca/f59ff912be4f191f6e3416548d880d662b66237e37bc8b4026af2aad2244/comma_deps_ncurses-6.5.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:a8d5822ac1a1cd5bb16e7e06ba702d6de6fb584b5727190583b6d6d983cf8530", size = 260849, upload-time = "2026-09-11T22:33:15.623Z" }, + { url = "https://files.pythonhosted.org/packages/36/f8/7406248610e7862cfca0e2c5b447baa565c504c7ba72ce19e811240e73d6/comma_deps_ncurses-6.5.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:7eeb250ed2bf34d2c69098d42ec5d63e333a2e3a802911a0e1036e855b547b5c", size = 248321, upload-time = "2026-09-11T22:33:19.032Z" }, ] [[package]] name = "comma-deps-raylib" -version = "6.0.0.1.post101" +version = "6.0.0.1.post103" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/90/e289acd1725d71c792c33399422f1052c4b0c38aeb4222a866d30c4a2cad/comma_deps_raylib-6.0.0.1.post101-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fa69d5093a92d7d2bfd2714a1afccca63b94d4c55fae88e61b80e8841de6a6cd", size = 1885392, upload-time = "2026-08-27T18:24:37.25Z" }, - { url = "https://files.pythonhosted.org/packages/a1/17/12977631f6d86d1daa4f67a310cfdf2783ba288f42d444d6a89b2297a0a4/comma_deps_raylib-6.0.0.1.post101-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c93ff9b45df414620b3011280da77151764c027e2f95d384fe255615d78872cc", size = 21707644, upload-time = "2026-08-27T18:24:41.282Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ca/ef33aff790b37dfc925f94fbb3a86da19f67929c15b3725ddb18afb91e96/comma_deps_raylib-6.0.0.1.post101-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:8f2a1ffe5f60cac06170b144d6ce84925c91657e7ada3ae35bc5df6ccbe0b461", size = 20722616, upload-time = "2026-08-27T18:24:45.803Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e3/21e3be3ad1e2382b9ecab7d836e7ca19ea0959fe714d539a71c6b87ba510/comma_deps_raylib-6.0.0.1.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d8bef63b7e49bc8704eda9fecdefbff01d7f6c9027069cb1b1f5150cac077ed1", size = 1885397, upload-time = "2026-09-11T22:33:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4d/cceaf969d771af8870094d06bb84dca805f9268584a9a3c134dfee2307c7/comma_deps_raylib-6.0.0.1.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d9b9bebb39167442bacf627958d57ae95e452550ced1b68337cbf2a73375856a", size = 21707647, upload-time = "2026-09-11T22:33:26.533Z" }, + { url = "https://files.pythonhosted.org/packages/27/ce/e8b941bcf0b213ca9cb4d8cb69b18398b4dad77923925d80bc3281837461/comma_deps_raylib-6.0.0.1.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:942ec942ec01005f05584eede1a6add483043e8e8f764e68cfebb7c21c443b89", size = 20722617, upload-time = "2026-09-11T22:33:31.158Z" }, ] [[package]] name = "comma-deps-zeromq" -version = "4.3.5.post98" +version = "4.3.5.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b7/b0070e091dae4be2cecccfb2921167b568d0e7bb9ea600b5814603e0590f/comma_deps_zeromq-4.3.5.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0ecde97133d657024bf99ac7302130905a131dabedbc8425b6666b2058ce6acb", size = 815150, upload-time = "2026-07-23T17:03:29.517Z" }, - { url = "https://files.pythonhosted.org/packages/2f/9a/d6a381b079516eca1b8a86aa3e972e550a48ff2f069ccc722b118ab53d60/comma_deps_zeromq-4.3.5.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:110a628ea440ea75707ad29fd90341d300414b0092137b1d43e8f19d100cf2fa", size = 833389, upload-time = "2026-07-23T17:03:33.25Z" }, - { url = "https://files.pythonhosted.org/packages/af/d7/504649efc8dbe8ce4c0cbec085178d1c3298f6950bd4bbca1683a90c49ed/comma_deps_zeromq-4.3.5.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:cd16a515f00f5fb679c2883970c2e7f446ad84e65d7fcf045d325952f9cc3607", size = 798894, upload-time = "2026-07-23T17:03:36.788Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a9/9013d207cca0f1d4dda65f1babf16e9e2e0b5fe86259bf994d15cc6e6024/comma_deps_zeromq-4.3.5.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:66d4cc63e45bf5864e68d6a9528ce90419f4c7210250970ff58b3919985c0929", size = 814878, upload-time = "2026-09-11T22:33:35.467Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/522bdc7b93d9c84b2600e9ed84588c94acf698a8aa3186e304c6d54b4371/comma_deps_zeromq-4.3.5.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d25d7b336707f39a6c92f0b01b40ca761d067564b39c6c56a6a7f4cca7fd512d", size = 832994, upload-time = "2026-09-11T22:33:39.03Z" }, + { url = "https://files.pythonhosted.org/packages/e4/7f/e79ec0b13146d9aea6004c06bcc37264bda973a5a71248d32dbcc7ac29b4/comma_deps_zeromq-4.3.5.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:bb05f93a91b140eb54129bf68824d00a6f93802071957062995cbbc6184f70d2", size = 798535, upload-time = "2026-09-11T22:33:42.538Z" }, ] [[package]] name = "comma-deps-zstd" -version = "1.5.6.post98" +version = "1.5.6.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/66/fd1098b4514e759d85e19d604d446ecec1f67e2f452df9e280d01a2449f7/comma_deps_zstd-1.5.6.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2b6acdd50e71ec67a1426423cda5116a7cb43900dd2e4fca2cbcbb7d588be171", size = 1065140, upload-time = "2026-07-23T17:03:40.465Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ba/1d61aae97577bbf13c9c02e7e69d4c9391947d8d0d09ca4ad78f2e3d0faa/comma_deps_zstd-1.5.6.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:04825faf902754a0945ebac374d01d676b7e5f98a68e460452319de509b369f3", size = 1006145, upload-time = "2026-07-23T17:03:44.144Z" }, - { url = "https://files.pythonhosted.org/packages/a9/22/94d164407b579090eb3aceeeb63fbd8c540f6972a11e5b72fe3ca3139333/comma_deps_zstd-1.5.6.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:316200a52c9ac1aeb6b22030480ffebaa1d91756c18a3f3cf216368a5fb35bfd", size = 1030359, upload-time = "2026-07-23T17:03:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e5/17e340fd63173767d7beae9228c2e227352ba880ced754203f9b5096f223/comma_deps_zstd-1.5.6.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:37bd1c5b8bfac9d19095f741030a73a58663bf0aab93a99768f53db67fcd09ac", size = 1004948, upload-time = "2026-09-11T22:33:46.046Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e2/96a88f630732dd83f0d01adf7707b369a055eea6dc4612a30ae6edaff8fc/comma_deps_zstd-1.5.6.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d9a6b6b6d4ecdfd28d4843f8dcd42dfd6f4886672cc90c0957798f9c0326571a", size = 956886, upload-time = "2026-09-11T22:33:49.654Z" }, + { url = "https://files.pythonhosted.org/packages/4b/64/23cf37ae4d915ad9f7b341b08cc0435aaeeef543185c86bb5b860c87cad9/comma_deps_zstd-1.5.6.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:d7bbc77bb174475f30bde69d7451baa47ddbe12724e586ac8874d799c7368101", size = 980997, upload-time = "2026-09-11T22:33:53.384Z" }, ] [[package]] name = "contourpy" -version = "1.3.3" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/5a/a55177dd22553a277388e8a1b3220e92de91bacb28356cdc73caa240121d/contourpy-1.4.0.tar.gz", hash = "sha256:20156f5a1ac4f8ce02656e39a61e82164a3d359796dc8026f75b062783d500e1", size = 13323726, upload-time = "2026-09-11T19:05:05.808Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, - { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, - { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, - { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, - { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, - { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, - { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, - { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/97/ba/01bde9753bdbba04a6da9d2bff3881f021bc708f654655e95fae26d3b3a3/contourpy-1.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:186ba929df36d61b6127da2e89cd1357e4cb79aca647381ab1a6feb0b152877b", size = 297302, upload-time = "2026-09-11T19:02:45.103Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f5/c5bf67522d49a2222f3154fe46879451d26f0e35ece41770758a64ea78cb/contourpy-1.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c76f3a5318164db7d9401132fc1b5364b784c613c93fa506e3b5e6d1bf353ec8", size = 284921, upload-time = "2026-09-11T19:02:48.049Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cc/cb989599eec12fda312e127eb8e04a8b21a7a6c94bf7ff1bc68273334a42/contourpy-1.4.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:43c3ccbb32c6294b183dcc8e8c46dacf5ecef809497e3d48a5be298eef185ad0", size = 356332, upload-time = "2026-09-11T19:02:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/47/fb/6f620d7602507817b0b4c21dd790ed68688f1f4ae9c29aacf21f08db5cf1/contourpy-1.4.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86d05cec773c9507a3950122e0e40ce77c23c75ceeb2fc189514e71893cbb34b", size = 405878, upload-time = "2026-09-11T19:02:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/32/4e/693c6d6bdece679f0953775eef3a1b66d56be55478d936e7deb1f3004422/contourpy-1.4.0-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2deb580178ca19437a84bd77e4bc2cd91a8ccad212413a83c273900868b5978c", size = 408296, upload-time = "2026-09-11T19:02:56.528Z" }, + { url = "https://files.pythonhosted.org/packages/78/f9/b6831508960d559581c448532ba4df312217072975486dbf2b24fcc5b76f/contourpy-1.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:875f42444c9cf48d56f724f2637e60d0f73b3b12c9041e1484580a233edf9591", size = 383243, upload-time = "2026-09-11T19:02:58.638Z" }, + { url = "https://files.pythonhosted.org/packages/f2/21/52903825a0ae7bb625e8bd30a09816d4c3c5d6174a469c10a616166cf780/contourpy-1.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f863c6100bf926cf47d13f3cd75f9bb8ebd98aaab230eeb4468b91f98f39a6b3", size = 1357306, upload-time = "2026-09-11T19:03:01.036Z" }, + { url = "https://files.pythonhosted.org/packages/d8/79/d68b1ce8539e4071518fed9523f558398f34dcd078b8927b109c72dad2ef/contourpy-1.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3863ef2e2b13fe93f8c0ebb08ee400cb07153b8b0e91c5acb26e4537f283634f", size = 1427243, upload-time = "2026-09-11T19:03:03.866Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f0/b75a10e9d0616b97a30de277b6dfe83f1879880854c7330337309530eb48/contourpy-1.4.0-cp312-cp312-win32.whl", hash = "sha256:5450f091ac1be0be3ad3a2a3b3f23b5e443e78c670ced4fd347d626f92a28fd2", size = 347345, upload-time = "2026-09-11T19:03:06.192Z" }, + { url = "https://files.pythonhosted.org/packages/50/a9/dab08786bb4d77ef9a046b3c1be923be4e73c5d07d91f5a54e8ea9b09e41/contourpy-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e697d94e69f499ff6bebb899cae97a58d5d14f0e1fe9568b43a0248d2f9af8c", size = 234083, upload-time = "2026-09-11T19:03:08.122Z" }, + { url = "https://files.pythonhosted.org/packages/47/b9/3ba509755a970dbde5948e7142ac21f266be72f38017cc4087a05fdceee1/contourpy-1.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:0c7a4c2716a4e98342221954416a836cca77c996c14ddbf22b5f01d5d93ca09c", size = 559521, upload-time = "2026-09-11T19:03:10.047Z" }, ] [[package]] name = "coverage" -version = "7.15.4" +version = "7.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/f5/deb1a27aa20746c0278ac998c4179e272004699b2d33959ce020c5ac1615/coverage-7.16.0.tar.gz", hash = "sha256:077f0964087883176ff6ab9b074694cae29f8c708273b13ca62c183c6ed716cd", size = 945620, upload-time = "2026-08-28T21:54:37.74Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" }, - { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" }, - { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" }, - { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" }, - { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" }, - { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" }, - { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" }, - { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" }, - { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" }, - { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" }, - { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" }, - { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" }, - { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" }, - { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" }, - { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9c/8d2688694f53dc0b0f0e4783c7eb3c4bb1e79beaf1411879f6dabedf4607/coverage-7.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d1c77c3579ac42798f8b7eed6d3dd258debacca32c8753fc8a1f6eaf1db644f5", size = 223194, upload-time = "2026-08-28T21:51:27.767Z" }, + { url = "https://files.pythonhosted.org/packages/ca/11/f002163dd688aa3fa49ac6a424b7c2705c7fcf80fba18ec9f586d77827ca/coverage-7.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1f81cb1554c3712e41649ed5dc98656b50b958e4da12f0f5adb681ce3db92831", size = 223553, upload-time = "2026-08-28T21:51:29.46Z" }, + { url = "https://files.pythonhosted.org/packages/81/65/f9d469e97c4554372a710650a109004a2434dfc56f577142e5d6057fa0cc/coverage-7.16.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e701938ec9081d3e400a0c9a9a8ae0f7ca44214741daeac4454b1c6ef6dbd19", size = 255054, upload-time = "2026-08-28T21:51:31.54Z" }, + { url = "https://files.pythonhosted.org/packages/95/29/dd89fd39af1a3b6e9a9c3eddeaf03f6376ba517d43d6cbf8b519177e2a10/coverage-7.16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:719a3feb6220dd32ed932d4c3676d17fb8739e2643b29c0e7c3af400ff80ac44", size = 257790, upload-time = "2026-08-28T21:51:33.374Z" }, + { url = "https://files.pythonhosted.org/packages/0a/64/208d26cedc525d6b5db9c492cf9130784c42d9eb08d22badaa7b806005ad/coverage-7.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87771ecf986cff55e87413238cd5e4f54d949c2074bd6fc1657d26a56314ee24", size = 258904, upload-time = "2026-08-28T21:51:35.096Z" }, + { url = "https://files.pythonhosted.org/packages/1f/98/28e2752aa9a8baee5798edade9c95602ca200f4e7eeb503eb64df42e5921/coverage-7.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47d5e1fc0b321c8308a2aacee0497c435b08acaa629b7059798fdf6fc3006352", size = 261165, upload-time = "2026-08-28T21:51:36.744Z" }, + { url = "https://files.pythonhosted.org/packages/eb/77/fa6ae699a0ea2bc12acb38a85d96b786fea0f833c12b5756056350e0e547/coverage-7.16.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01b18b8a6c9cec8d5f45550e2501426ed982cf2c35016b0acd2ba9b5d8b2fb06", size = 255416, upload-time = "2026-08-28T21:51:38.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/c8/5ee46d1de7d34cb00ba08b5c50da1971114dbc09ca9898ccc32975ec74dd/coverage-7.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:32c56b5b47c50635081445ac404dd08c2d591b9c837c22570aa9e182c3b42cd4", size = 256825, upload-time = "2026-08-28T21:51:40.27Z" }, + { url = "https://files.pythonhosted.org/packages/15/f6/d59e1c0693ad48855fe20169fbf6ee5befefe5887a7fabf5f0bcb464a2dc/coverage-7.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6ad3bbad240ab937512156bc944fdee63ac4dd34a7558a3094548fd4c1150c02", size = 254970, upload-time = "2026-08-28T21:51:43.136Z" }, + { url = "https://files.pythonhosted.org/packages/df/7b/b51bbe05b3a7565927fccfb1be42b8b3c1f4ab15e53d91b303e9923969aa/coverage-7.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4c1f16d5555a195295d0dc9c902612270e3dfed6a11f3bf7bc470b7b6a79ed3c", size = 259039, upload-time = "2026-08-28T21:51:44.983Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/d513f816456a8a43c1859abe88a37d01d7d2515b6c3e24ebb3c9b1dd44ec/coverage-7.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f6c9c21a8bf0d19788f3c5f3e020c90317a0a63ef60521b376003801e21250fb", size = 254539, upload-time = "2026-08-28T21:51:46.733Z" }, + { url = "https://files.pythonhosted.org/packages/dc/54/5542190ceb97e0d1333a4ce0c8f95b2ef2efe790f1ad018a4b61766f849e/coverage-7.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:06f20145a9eb5bf1fd1dde3c0bc2af2e7c22135ab07ca6284d6ada7cc3904c4e", size = 256410, upload-time = "2026-08-28T21:51:48.363Z" }, + { url = "https://files.pythonhosted.org/packages/ee/28/78643f361ff6bb5b2ade90f8bfc8395fe9ca367a18c101f8991215b4c65b/coverage-7.16.0-cp312-cp312-win32.whl", hash = "sha256:916cf8d25c1ce148f7eceb1d45afc9724841200110adc4e53250391852debd91", size = 225239, upload-time = "2026-08-28T21:51:50.22Z" }, + { url = "https://files.pythonhosted.org/packages/67/61/8e76b36c36b1a033dc933dd2480db96b04ce3be975793ce3fad122e7174d/coverage-7.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:78f8b56261d608be102c62edd3a60b66bcd0b581f3f86fdcabaf8b8d95adc950", size = 225775, upload-time = "2026-08-28T21:51:51.912Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f3/bb4787a4b81c1792ca69b502f5f730dbbb609f73fed552ab074c6b92cb8b/coverage-7.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:577c2ac8c0036f6f8edd3a7783a9e67302b17771d1abf0fd2ed246e3158be51b", size = 225159, upload-time = "2026-08-28T21:51:53.667Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5a/234e8fadf85c3cc48cb31c247b9e8e0c7f06ece80f5b29f9b8c241f9da4c/coverage-7.16.0-py3-none-any.whl", hash = "sha256:245f7de6d023a5bba375dbec9f2e0869bfa26ac0cc639bbb7b4c814884000b73", size = 214977, upload-time = "2026-08-28T21:54:35.189Z" }, ] [[package]] name = "cryptography" -version = "50.0.0" +version = "50.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, - { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, - { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, - { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, - { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, - { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, - { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, - { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, - { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, - { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, - { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, - { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, - { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, - { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, - { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, - { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, - { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, - { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, - { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, - { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, - { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, - { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, - { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, ] [[package]] @@ -334,50 +334,50 @@ wheels = [ [[package]] name = "cython" -version = "3.2.9" +version = "3.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/de/db48b8870e766cfea809986cc50c1e986c663a9ab7bafd0ac1a2512c4a26/cython-3.2.9.tar.gz", hash = "sha256:d249c9022ab13286b17bd66f30609e800c5f95efeecb06168990c7a66cecde6c", size = 3293493, upload-time = "2026-07-24T06:21:21.867Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/d8/4981ef716ad0e3ff0d3ef383aefc6b03c4a88dee33b272bf8e0d833001ca/cython-3.3.0.tar.gz", hash = "sha256:eed0d93fbca7087f143b42c34b05a825849bdf17f101572c2105acfa49aa88b8", size = 3727515, upload-time = "2026-08-22T05:16:39.493Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/37/c74d842306c8fe381c415b37460d5e3086a820fac72b8ff5cb48513ccfcd/cython-3.2.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:114b2dee0fa1daa48a59574d848da0ff1b6bdb725a755e9b92fad14962e1ff8d", size = 3009571, upload-time = "2026-07-24T06:21:52.534Z" }, - { url = "https://files.pythonhosted.org/packages/15/c0/f7c42b161edd585e3ae556fd62a2c72cd80a6ed527a9907f0c5c6fb060de/cython-3.2.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5cd9c5f138cb052130b40ad3b6976d2180c35348410995812678f4636bd8f94", size = 3183562, upload-time = "2026-07-24T06:21:54.62Z" }, - { url = "https://files.pythonhosted.org/packages/56/1b/c04520ac7f3157aa12a69b632c16261170dac9fab6c48608cc004b8f1b17/cython-3.2.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23e80bc885c599e72072e18d0746df82d394b73100c1e153cda7359e6e59fe09", size = 3354811, upload-time = "2026-07-24T06:21:56.72Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b5/4ce33235a25b19fcd51dc639f0f403b783a3b7f9b1934eade0d993fbe029/cython-3.2.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b1fd5a9c03f72a18618668a8e90d569442ed742f910e3ad003dcc9348e9598b", size = 2778077, upload-time = "2026-07-24T06:21:58.7Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4a/342312c5fe021c8e0c386e1915d138e0902c48ae179b0374ab04773a8831/cython-3.2.9-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:944dc8747f640b3527649c566a5fc75ee0c15e80642ea2fdae4fe6378e1a9d4a", size = 2899729, upload-time = "2026-07-24T06:22:24.877Z" }, - { url = "https://files.pythonhosted.org/packages/f0/62/ea919ee426cb4d435ec8155e1ee6bcbb46b20d8f070527191b59769d4e7f/cython-3.2.9-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4b871ad97dd7fb1cbf56f6238c54423febd310afc1d9d9bc70c69c89b7ce57fc", size = 3226650, upload-time = "2026-07-24T06:22:26.947Z" }, - { url = "https://files.pythonhosted.org/packages/18/02/057b4f63e2ced8c3cf217c4e9fb544bfe48145f493347c7ca3f51607526c/cython-3.2.9-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9b6ebc6c74b4318eaa4e51e520dc8b95ebc7b262953c3ecb24131104681f14e", size = 2881919, upload-time = "2026-07-24T06:22:29.318Z" }, - { url = "https://files.pythonhosted.org/packages/64/e4/e158793ee3de7e4417ba17e7ff1015d6e2cf557cb485ad270b2446c9d1c7/cython-3.2.9-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:92989da161a7d18a7ad4baebc49289b2b77556d5a94916f90140ba26aecf6892", size = 3004702, upload-time = "2026-07-24T06:22:31.535Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c0/d5bbbd743ab4feddb24a7e823b34c3ec4ebab91ff503d16743a4e7ce106b/cython-3.2.9-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e75ec625d8f8781ced690b7a2f5c2d138067711cf24bb8fb68c872c30c2fefe5", size = 2902695, upload-time = "2026-07-24T06:22:33.525Z" }, - { url = "https://files.pythonhosted.org/packages/d6/2f/80850817395985259f135baa510d9186d3a325df81cd1862060bba977029/cython-3.2.9-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:2b1756ddc3bc0cd4341a515fc420c3e25e13c249f5537159b3fb0bff8d19e55c", size = 3241554, upload-time = "2026-07-24T06:22:35.667Z" }, - { url = "https://files.pythonhosted.org/packages/4c/ce/6be776814f6cb81751f3da737ed537385738148e1ea99f89fb4637799198/cython-3.2.9-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7d41baea51ea00f9237f75af498577827493bca5e9b45bbd4e351543727e589a", size = 3124337, upload-time = "2026-07-24T06:22:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/e2/48/27c948cbbfe6050994e67a497ae530955dcda7e79084319321c49969e0fd/cython-3.2.9-cp39-abi3-win32.whl", hash = "sha256:61d4abbf84f77c8d19361d05d9f51d65d8d95e74f736eae55fa1aed8a1430469", size = 2435609, upload-time = "2026-07-24T06:22:40.005Z" }, - { url = "https://files.pythonhosted.org/packages/17/ef/cf0e1bd7542296f1752be63b027f90271448d8c8062eac66d8e44a79b883/cython-3.2.9-cp39-abi3-win_arm64.whl", hash = "sha256:57a6a78d14f7dd7d6062d9bca694e2a8c1c14113b6ceceea076abcd1161fdc5a", size = 2458025, upload-time = "2026-07-24T06:22:41.973Z" }, - { url = "https://files.pythonhosted.org/packages/00/ec/e61deec9bcfbb0e1b36f8b5ba75cb44644419b4bfd0fdd666bffd21d9579/cython-3.2.9-py3-none-any.whl", hash = "sha256:a2b0e87f6b80790c929308ca0831d686f7a180feab684fe8cd4a4380bd96aaca", size = 1259272, upload-time = "2026-07-24T06:21:18.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/95bad838a80ac52c9e982dad00bd9a0b2bad57fb4c688e5f53ac3ef65ff0/cython-3.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03bc5333932f5dda3ba9315298ecdd21daa1b58410bb1f8ce04c78ec8337130a", size = 3143472, upload-time = "2026-08-22T05:17:07.956Z" }, + { url = "https://files.pythonhosted.org/packages/91/8b/53d4a84de853b39940a0e35a6a2a9ed5f54cb05468daee95bc0fd1c2a178/cython-3.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e321ae700995a16dc3055ada06ffb8d61e1a7434e5d0e811547a45ac1015ebd", size = 3258974, upload-time = "2026-08-22T05:17:09.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/4e/6b1c5a4e6bbe1726104de007aa2fdf01a3e2e386b4ec93c7be5f5085d53f/cython-3.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:428fafed98ea26927000a287b4dfc9ef07339f56656a5329a34eaa593f79a4f8", size = 3412225, upload-time = "2026-08-22T05:17:12.281Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9b/cd724d91c500116769bdb853450a2197ba3d640dbbe3b02fc54ebdfdbd1b/cython-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:333449cc0350baedee5a6af27929eac8a71eac4ec59333c45ff476b33c6c660d", size = 2872123, upload-time = "2026-08-22T05:17:14.322Z" }, + { url = "https://files.pythonhosted.org/packages/14/59/bc1a84b434cb5bebb0cd6f50da8f239d35a5c141b20fdeafc2817fd87778/cython-3.3.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:e0d2713d2b292c826bc21dc8732bd9e47628103aa3764180c881e04b3fef95dc", size = 3063660, upload-time = "2026-08-22T05:17:40.923Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6d/542e32908fb421d88354f327ed6450e14240f9825d25393065bc65f4723f/cython-3.3.0-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:169e56fd411f4cd5bba51c82f8239421d547a846099db2b261e4aed48ba9f51f", size = 3358395, upload-time = "2026-08-22T05:17:43.036Z" }, + { url = "https://files.pythonhosted.org/packages/9c/7c/ddaf197bc65b581e1891657940bc4f7cb1f740e822115e828920b3a119ce/cython-3.3.0-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:29f38ebafdf23e3da2516f40c4d065da38bfe002181bf93e2b8cf1262449aba6", size = 3041760, upload-time = "2026-08-22T05:17:44.907Z" }, + { url = "https://files.pythonhosted.org/packages/19/a7/ae5ec3e34d43da846ed4c425734752d83aae0dae49feb929f09c90fc9afa/cython-3.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:75c4ae8a6d3a5ccf3cdaba8ab32e6a8d0cd38e3a476aa7ac12df8f8171a8d570", size = 3156152, upload-time = "2026-08-22T05:17:46.884Z" }, + { url = "https://files.pythonhosted.org/packages/31/44/c60b601fc43f0b08e9d6f14b94e0dd02eb0ca8d60f46e242ace7191ac1be/cython-3.3.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b94fb5613b9fe34c27d13ec9972dc0dcd2a2155db2902e93921cadc162610a38", size = 3046690, upload-time = "2026-08-22T05:17:48.731Z" }, + { url = "https://files.pythonhosted.org/packages/b0/9e/d735c26ed907563d3365534006acb263651c2d3b87fee804f7a483dd1714/cython-3.3.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c4558ba85849ab65dc57e10fd0efb13fabd9d3c09981a2566e18dec7cf47586a", size = 3373534, upload-time = "2026-08-22T05:17:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e8/aa7b4f3a28d6e8117c76e2cf78a0df7a503486cdf7243c5b53200c9533a1/cython-3.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:311a016369adfd1e0015c4f9819168fc0e518451d7efb4435c30d65a3a26d52b", size = 3265110, upload-time = "2026-08-22T05:17:52.577Z" }, + { url = "https://files.pythonhosted.org/packages/9c/66/37892a8999d6bbd3f92d691a9701cb720c8ddd6171e16f5148eee6e8cb7f/cython-3.3.0-cp39-abi3-win32.whl", hash = "sha256:90869072e50b7c8904fe1dd7810321ae901fd5637a6eec6646ed9c57f9eb1081", size = 2587733, upload-time = "2026-08-22T05:17:54.547Z" }, + { url = "https://files.pythonhosted.org/packages/19/a2/5f4d305cbd4489d21570e5491ad5c483c478cdab032853e2125c280e3bd5/cython-3.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:dce56c26d388f00a19426371b6926bf2f77c5c03b71d5273e4556c68be98c2dd", size = 2608078, upload-time = "2026-08-22T05:17:56.386Z" }, + { url = "https://files.pythonhosted.org/packages/bf/77/67b0b24e45073a699610e50f00c18474ff9b09ea29ecc95083bdf5e60acd/cython-3.3.0-py3-none-any.whl", hash = "sha256:9b24b5c8cd536946b62086fcafee6d5509d3f549f72d553d2336af87ffbe0da1", size = 1349151, upload-time = "2026-08-22T05:16:36.741Z" }, ] [[package]] name = "fonttools" -version = "4.63.0" +version = "4.65.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/51/d63c7e52163ac14393a35bd14bd7c0da95f8f74be5d7cc988092f9965129/fonttools-4.65.0.tar.gz", hash = "sha256:762ba5431358d0dbd4a01982484a1d494fb267e91f974cdcf20b80eab8560f6f", size = 3674467, upload-time = "2026-09-10T15:35:54.955Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, - { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, - { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, - { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, - { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, - { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, - { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, - { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, - { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, + { url = "https://files.pythonhosted.org/packages/58/db/242fa4fce7f632c5f7ab15585343393b25792510c0c32bd218ad24d59f1c/fonttools-4.65.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e844a45c9e5ced6536f184cf1a65b5d65e8f7e711993b413e10500a8223622e5", size = 3097120, upload-time = "2026-09-10T15:33:46Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b6/42fa4d373416675f74446421cf0b2badb82a4245c60745f05f424f75c649/fonttools-4.65.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b30e953de049bf43fc0a63c7d0c44d205c923e4bbf24716aae1518c0e65f977c", size = 2584658, upload-time = "2026-09-10T15:33:48.473Z" }, + { url = "https://files.pythonhosted.org/packages/75/6f/d589b9d62280a846c77a2c383d852c6dcb79ae8aa02bf0fa46c8577af145/fonttools-4.65.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09c34bdeed8915bfb53bee0c8ed2254dbd8ec69c0014b7f3702f347c049bf358", size = 5425737, upload-time = "2026-09-10T15:33:51.473Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/de2c0c20a42c18e565a2617932beb08c06697bbdd0d3f62b108262e11583/fonttools-4.65.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:05595385ae99f4b9626cebb973bf171b8fe38a8f40708e6e42abba0ed7537778", size = 5402463, upload-time = "2026-09-10T15:33:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/2e/bc0f5c9dce21821454bb5812d3b23410bca33c8bbd5468386d0327aa0cff/fonttools-4.65.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d95b34dd68fbfc0e4a1740c421597656117f979ed8dc85de66e08f9f9981806e", size = 5362168, upload-time = "2026-09-10T15:33:57.481Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0d/2116763ade7e71e0e5d421babe1785d745be9b3d605bf914792ce1c97f79/fonttools-4.65.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:924d06e6130429168318db71c40174a765ad016fc4b56ca811287e3d7373b3a6", size = 5524117, upload-time = "2026-09-10T15:34:00.021Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5e/f9600553b9f645e3068553831685ff1dab6259536a23b38d2e048de38f17/fonttools-4.65.0-cp312-cp312-win32.whl", hash = "sha256:04f73dd01005752a6e75cf4a8dc6b70dc724d1d4bc34cc89522153f4a2f07680", size = 2432089, upload-time = "2026-09-10T15:34:02.803Z" }, + { url = "https://files.pythonhosted.org/packages/3a/02/e436a6a1863b9862bab9f82d6da33055dd7aa3738017edd902a163525dc2/fonttools-4.65.0-cp312-cp312-win_amd64.whl", hash = "sha256:3b5d9ba89edf778b376e669b879ae33a198bf45cf5a23c3f6514f935cf9d0d9d", size = 2483475, upload-time = "2026-09-10T15:34:05.09Z" }, + { url = "https://files.pythonhosted.org/packages/e6/35/f894ceb867118c0261d0f69a9bd516b045a3754238f76c88a49513ac7a83/fonttools-4.65.0-py3-none-any.whl", hash = "sha256:3060b8c1fc2329fa20265b7c138614143ea7c1624e26c5c180c76aeb74deae6f", size = 1196441, upload-time = "2026-09-10T15:35:52.347Z" }, ] [[package]] name = "idna" -version = "3.18" +version = "3.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] [[package]] @@ -409,29 +409,28 @@ wheels = [ [[package]] name = "kiwisolver" -version = "1.5.0" +version = "1.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/07/bd78e6a8fae171ea041ef5bba3ed21a003522fa088834b069b1909981f30/kiwisolver-1.5.1.tar.gz", hash = "sha256:f1303ef2eec81262a4b708c3e858afe58d7c75ad91c1c05266eda7673369859a", size = 104395, upload-time = "2026-08-28T10:28:27.153Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, - { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, - { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, - { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, - { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, - { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, - { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, - { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, - { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, - { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, - { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, - { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, - { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, - { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, - { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, - { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, - { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, - { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9b/65b302742389c6f96f2956bef5decf26011309feb2fc5d79613af18adea4/kiwisolver-1.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:63fb7294b768f444eb4b068965f2662f28c2fd4161e23bd60fcf3ff27b74c046", size = 123876, upload-time = "2026-08-28T10:25:27.44Z" }, + { url = "https://files.pythonhosted.org/packages/71/74/c21f339956f6f691b2ed7e31d5f3ae767304df6c460192739fc830853051/kiwisolver-1.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0ebdef3eae5336568147c39a55be6a2036ffde53faa9ca2d978989ae7c2da12c", size = 66487, upload-time = "2026-08-28T10:25:28.728Z" }, + { url = "https://files.pythonhosted.org/packages/84/e5/bdb34e21523e01dceda064d63713f3bdec91388af24fba1eca7ea5e85864/kiwisolver-1.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1798e83840c3f627246104c4d8a9639c60fa068adf9ce92b61791781fa8a68c1", size = 64660, upload-time = "2026-08-28T10:25:30.071Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f4/dadfec469313c7f428efa7e84b4aba9732f813c13ea7131a24b7b008ef57/kiwisolver-1.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34633ecf50d16187ab8e5528b7a2530f2feb4e23f300db4672538b51cfc5cd38", size = 1477929, upload-time = "2026-08-28T10:25:31.495Z" }, + { url = "https://files.pythonhosted.org/packages/6f/35/09c58daac34e6f6ea5c6dee0094b422118e5a7c265586008a95fd135ac5f/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d27c2123977cb9269c30a49ba45f03a4323017ef693e19db4ec9dbe1299a3002", size = 1278499, upload-time = "2026-08-28T10:25:33.375Z" }, + { url = "https://files.pythonhosted.org/packages/19/32/739765e24fbad29d13f83e546ea4abc215a78cea9d677ca09025b027724d/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6a797a1cefc8b9c93170db580337e1fe3d011ad18b1299943231279406342048", size = 1296677, upload-time = "2026-08-28T10:25:35.059Z" }, + { url = "https://files.pythonhosted.org/packages/df/32/03304d1010e2cc45e5b3b52cef7e43fed3a2a5cd6c87a89b4a88e1d85b5d/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2551cf9917af48ee7c4b29cc82320489508cf96fd26a51f6fc124de661cd44c7", size = 1346037, upload-time = "2026-08-28T10:25:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/3e/57/4c49377bfd274450dd72ecaa13eaac32ea804a03363e4d1db0c5aa999ceb/kiwisolver-1.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:38f6e0deb4d0a4615efe0c4efc5990b06ae450ab50a0b321c0b078b6d238c083", size = 988248, upload-time = "2026-08-28T10:25:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/38df144a08b6c5d75ca4504e5cc3141bb3bfef64c04f4ef48204f42711b6/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bfd1de989b3330420e29de39352f5c049905c9e3ee67233a50d550e3d652c148", size = 2228722, upload-time = "2026-08-28T10:25:40.038Z" }, + { url = "https://files.pythonhosted.org/packages/e7/11/3221838a89cd64d9b386353e000cd8a296069a20fbe3584507fdfd5bebae/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1209042a623ddfda5497e4066c7b77651dde8e1d3a9dd97599dc7e97f3b9b78c", size = 2325216, upload-time = "2026-08-28T10:25:41.699Z" }, + { url = "https://files.pythonhosted.org/packages/83/d4/075c219230697bb5db910d37262b9bacf880f92b4811a02ab81ed073a253/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:26e8268480be5061d509e29669d59103c067a26377a56491630ece11762e3858", size = 1977689, upload-time = "2026-08-28T10:25:43.559Z" }, + { url = "https://files.pythonhosted.org/packages/bb/08/1d219c3c2dd960983d0d4da623d916e9de6385df2b0bab3d1af0e9b8fccc/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d79308fa689fac89cbcfbd4dbfc80b5f95c54c5a7fd4d194be221f9d33d026e6", size = 2491443, upload-time = "2026-08-28T10:25:45.242Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d3/024208ec1079d273f1047468d1bdffbf38bb75b7b268090fd3a0301b9d9a/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b03af77d77e50edba2030fd5f7c352ff209314b09030a3cba7c14edf9a09a444", size = 2295200, upload-time = "2026-08-28T10:25:46.984Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7c/7b210498f9f92e1cd7855f260fa69ef056881087b199ee20c208f0e4189a/kiwisolver-1.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:06a6917674de9e0fe3f66f5430787f59a9f2ddb64af9b714eaec547e29ef5c19", size = 70748, upload-time = "2026-08-28T10:25:48.444Z" }, + { url = "https://files.pythonhosted.org/packages/94/61/ef0daa157c8bb23672f7423e0d14c39db1dc6ef8ed47e6bc54c9c1bef3bf/kiwisolver-1.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:ad8b9671348d7c8716715652ae11f85ed0eb99e265a2df2ca490577d69860b2c", size = 68324, upload-time = "2026-08-28T10:25:49.81Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c4/1407df7512a5b36cc79840e01710dc575733c461b13ab866cae77eaf87f3/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:482676e5bd48d70ac99d9fc78863469845421e01184fa83f1f9366dc49f7e974", size = 134002, upload-time = "2026-08-28T10:28:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/16/45/c37a21ad5c0ab581a93c55ad544721aaa1f0ae94edb29c6a678a23d013e6/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:072bdb15a3c19a5b5dbc8f8fb1f4e1884bf4f3507eeb4cc6334401274d37a5c0", size = 194292, upload-time = "2026-08-28T10:28:11.06Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/69f00d627949580e43d57af0aa465df46868d7c29801c137a55374101294/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:a5a00665d1a0e26763a7338d7e911d4598fbc1d50dd0d6b7919b7dc6c5d6569f", size = 73362, upload-time = "2026-08-28T10:28:12.449Z" }, ] [[package]] @@ -478,7 +477,7 @@ wheels = [ [[package]] name = "matplotlib" -version = "3.11.1" +version = "3.11.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "contourpy" }, @@ -491,15 +490,15 @@ dependencies = [ { name = "pyparsing" }, { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/64/f9a391af28f518b11ad45a8a712353c94a0aefce09d3703200e5c54b610a/matplotlib-3.11.1.tar.gz", hash = "sha256:69647db5746941c793d6e445a4cd349323ffb87d9cc958c2ad84a659b4832d30", size = 32612045, upload-time = "2026-07-18T03:39:46.63Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/c8/9aa712a0afb882649424dd8de8ad9aa6235e796e84c6052e8f6dc1598d0d/matplotlib-3.11.2.tar.gz", hash = "sha256:cec596316640f2b394b8f0daa0ea61a8eae82d017b620b9f202befb972a59ea4", size = 32660610, upload-time = "2026-09-11T19:05:31.214Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/6c/7ef7ebcb2bd9739b2b66b18b076e077f44bb46fdbe28ca0506edb3c62c79/matplotlib-3.11.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e15ef41507f3d525f46154ac9e3ae785dacde9f20e593a25de8986267892ef74", size = 9453849, upload-time = "2026-07-18T03:38:19.593Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f8/6d0c312c8d9738e7d9677f09fe5c986b3239e651a7b73a2deb38b65e4a71/matplotlib-3.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:21a67b961a6d597bca54fae826cd20695ba4a6e4d05424a08da6e13e3176fd6b", size = 9283113, upload-time = "2026-07-18T03:38:21.95Z" }, - { url = "https://files.pythonhosted.org/packages/c9/cf/b4ad2cc81b6672ea29ea04e64e350a9f9b493b0908ccd884c67eeff8f7b2/matplotlib-3.11.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ba8f811b8ddfac493734d6af0b2dff96919d0c28ca0d641858dab4262777c6ea", size = 10035615, upload-time = "2026-07-18T03:38:24.315Z" }, - { url = "https://files.pythonhosted.org/packages/88/90/4e10e033d9b66589d8ed98b84c95cdbb57033d57c1f41339d7393dbd2f2e/matplotlib-3.11.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c52f7ad20ef476806ed212380b1d54d20310c8b86bdc2c9a68b51f0024a44472", size = 10842559, upload-time = "2026-07-18T03:38:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/88/eb/799612d0f8cd3e816a10fec59329fca52cd2353264df80378dfc541ae855/matplotlib-3.11.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8b14eb22961fe865efb0e4ff167e333e428908b00115a8d800ccb65ee108e481", size = 10927532, upload-time = "2026-07-18T03:38:28.532Z" }, - { url = "https://files.pythonhosted.org/packages/88/89/56649bbaa2fd12e20f3be03dbcc135b0c8676d88bac17977599e3eb442a0/matplotlib-3.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:88a2a27dd9691ae448dfae4b26f59036be90c3c28757edd3553a29559d00859f", size = 9333886, upload-time = "2026-07-18T03:38:30.477Z" }, - { url = "https://files.pythonhosted.org/packages/c1/11/4d124efbbad677b7b7552f6f85a3bd432d4232f95400cea98fcd2ae36ef3/matplotlib-3.11.1-cp312-cp312-win_arm64.whl", hash = "sha256:480194afceca4df2f137c2721227d3cba67121fbf4397b69cee7f83714b0a58a", size = 9007545, upload-time = "2026-07-18T03:38:32.833Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ce/1bfcc4873b121597791ad74032943b123218c0613af0b97e8dd05e916fb1/matplotlib-3.11.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef752769cd962f39ea0b6ffc82d1ea43a0012c5a6157c7a075212fa509cfcff2", size = 9476466, upload-time = "2026-09-11T19:03:19.45Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c4/7f5f3601ee69baf072c0c7d3ce60c03e0618621c5a56c34a62a460e29d11/matplotlib-3.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ef31985c4dedb5f1424e1aec6849a47dd37689cb7fa3c20b1b82187f26806261", size = 9305583, upload-time = "2026-09-11T19:03:22.39Z" }, + { url = "https://files.pythonhosted.org/packages/b8/90/2b3fd67ee273163faeda6d514be70b5596eaa0fd77b60ffc294ad0b34f5f/matplotlib-3.11.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df4f7784aca81a94f254c0a2767d592ee25f407e488f5fa7203e51093fb6ca27", size = 9860353, upload-time = "2026-09-11T19:03:25.049Z" }, + { url = "https://files.pythonhosted.org/packages/f4/84/32549e7a462dc311aed2ab62e5d2538028840b5c53a8be0195c789937c3d/matplotlib-3.11.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b9a7ad579856284135e401ecc918c5f8a017ee30539298862a109f51b971710", size = 10670348, upload-time = "2026-09-11T19:03:28.108Z" }, + { url = "https://files.pythonhosted.org/packages/84/39/02e21b74f7439bd643d717ea846006d46e309e6d29b632b57751265311d6/matplotlib-3.11.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3aa4b8516fd26659e4363abbf317c703d9116496c5db2e9d0609a2866dd39dd2", size = 10810580, upload-time = "2026-09-11T19:03:31.402Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/0d239bde12b308262265c2d98909b2f0ecad2b5deeae96241642e822e9c7/matplotlib-3.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:c5c1c68ee401fc98271263410f0e5ce88285abacf7627132914e8adf3d70ff43", size = 9349409, upload-time = "2026-09-11T19:03:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/49/a8/06baf901c02246c8a222b655cc4540ef9c15e1549a04e07927f9cde716c3/matplotlib-3.11.2-cp312-cp312-win_arm64.whl", hash = "sha256:643ff850d8e0f5b8319337f87ed3cb59506afb3df3cc48de777d85871233be7b", size = 9028084, upload-time = "2026-09-11T19:03:37.032Z" }, ] [[package]] @@ -532,21 +531,21 @@ provides-extras = ["dev"] [[package]] name = "numpy" -version = "2.5.2" +version = "2.5.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" } +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/72/dccb0aaf40972777283303919f613964227266d0c13adebb79ac124f1c3e/numpy-2.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:14e373cfc6387177e8409dac3c7159be8eb05cd77096cd7c950268b86f62831c", size = 16891693, upload-time = "2026-08-09T13:44:51.702Z" }, - { url = "https://files.pythonhosted.org/packages/60/2e/b5aee50a1f74ac815cf8331812cb8251e29024025de462e0c047641c614c/numpy-2.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbd96c833ecc8cc069ce518078fc8c60cb9cbfb0fea5b7a803ad65035596d03", size = 11903109, upload-time = "2026-08-09T13:44:55.501Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f4/29e78102a80601cf034d4e9767022cffeca2c3b4c926e1754572ca95593d/numpy-2.5.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:6e8172ddfcf5cf74b811d372b570b83c60bd2de87a6fbfbebdadb4a9bd9c6cbb", size = 5350202, upload-time = "2026-08-09T13:44:58.401Z" }, - { url = "https://files.pythonhosted.org/packages/11/4b/dcd3b7eadaf4035d2c7a4289d232523a6964f602598ef7674e4bd7291f93/numpy-2.5.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f188481f1669e26f62b701e8205d19e460fa4a9b52a1414ba382330e4a3414", size = 6687736, upload-time = "2026-08-09T13:45:00.813Z" }, - { url = "https://files.pythonhosted.org/packages/e5/21/4947e0e9d6c9fc2e2ff15b8949049ee44f63adb9cacc729ab8793f97e712/numpy-2.5.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee9c4eeb8454b3660a8b53493563c3e121c2fc94fbd72b848ef814ed7b676a9", size = 15612696, upload-time = "2026-08-09T13:45:04.151Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5f/62d28cf019460c7f1394105b4d49d9911a9c444cb77ab0bd95a204c5a6de/numpy-2.5.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8", size = 16722264, upload-time = "2026-08-09T13:45:07.714Z" }, - { url = "https://files.pythonhosted.org/packages/14/25/3f0be4c1b9fdf5dd5e708a6806978564d7c46a055c000496309ff2a2f8af/numpy-2.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7999d4ddb0c4025018373fd787510d46e04c769467af22869707b3c1cfd459ab", size = 16974396, upload-time = "2026-08-09T13:45:11.316Z" }, - { url = "https://files.pythonhosted.org/packages/22/72/6262cbdeeb45da9d971e40715f579d791603ba8ec0b5e2db1ac55454421d/numpy-2.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1f017dc0875c9209d219f97feceb7d54c2661bb243deb4114478e1295808af7", size = 18476044, upload-time = "2026-08-09T13:45:14.869Z" }, - { url = "https://files.pythonhosted.org/packages/36/33/29208b8b075bde62d26a81d14b358c42b0f69b6cabd98d4ff97f37f22b05/numpy-2.5.2-cp312-cp312-win32.whl", hash = "sha256:d6a48072864e3324e194a8fbb3c657bcc5b5c869dbc64c9537b1d5c862572c0a", size = 6072817, upload-time = "2026-08-09T13:45:17.867Z" }, - { url = "https://files.pythonhosted.org/packages/7f/b9/87fea2769fe1c47c1b5b01d8310772c9d1a85d485de7cf386ef7a3332b02/numpy-2.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:28ac63476ec7651484215ee7fa15a1f78b57c14621f01e392afe17b9a1390ce4", size = 12464674, upload-time = "2026-08-09T13:45:20.734Z" }, - { url = "https://files.pythonhosted.org/packages/14/52/032b97e00461ab0809bbe4c588b035620e5a14b8cdee47ecddefc7b17d33/numpy-2.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:27650bb0e7140fa3d37b9923b4803645e0b125d190f326eecfd3f4dad8e8ade1", size = 10397131, upload-time = "2026-08-09T13:45:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, ] [[package]] @@ -796,11 +795,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, ] [package.optional-dependencies] @@ -831,23 +830,24 @@ wheels = [ [[package]] name = "pyzmq" -version = "27.1.0" +version = "27.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "implementation_name == 'pypy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/8d/5b3d5631c2f4b4b8862f64cd0c9eb777b5710eeb5125b4be8dd0a200a4c0/pyzmq-27.2.0.tar.gz", hash = "sha256:54d4259d1bfae24ecdb5ca79f7acc2eac6c286a02d6a0ae617797cb45f0726d3", size = 292316, upload-time = "2026-08-20T19:08:21.19Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, - { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, - { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, - { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, - { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, - { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/57/8a/153532fa53db30e116118164f3af269a1f3966b3e2ba32c89b12fe864bd8/pyzmq-27.2.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:591c8de5851c5ea372194469fe97587b97c3b641e9a70f31bb3474acbfde0241", size = 1431074, upload-time = "2026-08-20T19:06:40.601Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ef/c08b91248bb90a9efa81fa00ba81b69c157c74d0c5efbb2c319d91babb62/pyzmq-27.2.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:00e73942ef12cecbc7951c4a9104bb8ffaed742abb13af2da6833d90dd368cef", size = 973915, upload-time = "2026-08-20T19:06:42.037Z" }, + { url = "https://files.pythonhosted.org/packages/b4/78/a3a3a86c2b00fadb92ece1ca4f8f028d62b2ce9ac3526097239ab2d6fba9/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f8079d0521fe94bbb401fe9407578b28f3701627c8be2c9f7e0c5b77dcb0109", size = 697722, upload-time = "2026-08-20T19:06:43.325Z" }, + { url = "https://files.pythonhosted.org/packages/62/2c/d5828306f795e8d34676d266823b74e2101e0ad3760d12083de3e02abbb2/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dea74fd65f1fc5f7fe167916a473ebe6ed6174e5e5d9de11ea6583661be6cf43", size = 872258, upload-time = "2026-08-20T19:06:44.627Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/51253b78fd8739293e283407eeecb14215c02c71b6519af21f6eed8e69cd/pyzmq-27.2.0-cp312-abi3-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcc99ca132b667a4ed750afd42db4ea73288f18425a9b2e3c0af095665c491f5", size = 739591, upload-time = "2026-08-20T19:06:46.214Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3e/142c85b67a4c9678629b0cf6d5125b29663d75be69bfaa57a3cac344d780/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b8d5f66e4a8246cf77f7b8f7902af64f00553368fa0373c89d99b78f0ad79394", size = 1689031, upload-time = "2026-08-20T19:06:47.612Z" }, + { url = "https://files.pythonhosted.org/packages/0e/ee/0776fb0f98ed1eb74d77240087fef0ab045b6ad15cb09555c6c5134c98ad/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:d1526b42a2e725b84ed226f37becedc250c6347594e5ed304e4e9aff68c9aec3", size = 2059547, upload-time = "2026-08-20T19:06:49.064Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/ec77f691a4aebe29ab6329f996fb0e0270c876a3016086e3ca6ef733bcae/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f707bcf2c1d007d14d70531d4dd7b41060881c73efa845580bf6faaf9ea24d42", size = 1910457, upload-time = "2026-08-20T19:06:50.783Z" }, + { url = "https://files.pythonhosted.org/packages/30/97/1f5530ff4fc271b4597048371d5af972c2baab51be132ba15874e0327a6a/pyzmq-27.2.0-cp312-abi3-win32.whl", hash = "sha256:fdaaa4ea3242f6ad298eb5177eb042aea5c73c30e76d20caee7b15af20d24ec2", size = 563450, upload-time = "2026-08-20T19:06:52.307Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/b83f7780dad22e0878e4c7bd9158ebd24ed12bc3d5e3a471cd0576f77ded/pyzmq-27.2.0-cp312-abi3-win_amd64.whl", hash = "sha256:2c218c6ab8bc447ba62054b581fd30209689d199c6ecb253f79615ca74a38e12", size = 628633, upload-time = "2026-08-20T19:06:53.809Z" }, + { url = "https://files.pythonhosted.org/packages/52/aa/3918b5ac7f9987bd9c421b065074fd7409ded88f856f2c704a24341877ec/pyzmq-27.2.0-cp312-abi3-win_arm64.whl", hash = "sha256:348d6fd3e4b81ae4580622ea8c2ea60224e84b2ac1b3be4482e6edc7de06e7a3", size = 556006, upload-time = "2026-08-20T19:06:55.242Z" }, ] [[package]] @@ -896,27 +896,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.3" +version = "0.16.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, - { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, - { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, - { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, - { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, - { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, - { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, - { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, - { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, - { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, - { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, - { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, - { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" }, + { url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" }, + { url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" }, + { url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, ] [[package]] @@ -966,18 +966,18 @@ wheels = [ [[package]] name = "sounddevice" -version = "0.5.5" +version = "0.5.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/f9/2592608737553638fca98e21e54bfec40bf577bb98a61b2770c912aab25e/sounddevice-0.5.5.tar.gz", hash = "sha256:22487b65198cb5bf2208755105b524f78ad173e5ab6b445bdab1c989f6698df3", size = 143191, upload-time = "2026-01-23T18:36:43.529Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/db/0c890e2d9aab9ba284021efc02e1d3aebfecab1b611762d7434602209bcf/sounddevice-0.5.6.tar.gz", hash = "sha256:8ec9fbfde2e32f020b167e348f3ab3bac6625a5f15af524d790108ac7147a410", size = 1120094, upload-time = "2026-08-17T07:55:05.048Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/0a/478e441fd049002cf308520c0d62dd8333e7c6cc8d997f0dda07b9fbcc46/sounddevice-0.5.5-py3-none-any.whl", hash = "sha256:30ff99f6c107f49d25ad16a45cacd8d91c25a1bcdd3e81a206b921a3a6405b1f", size = 32807, upload-time = "2026-01-23T18:36:35.649Z" }, - { url = "https://files.pythonhosted.org/packages/56/f9/c037c35f6d0b6bc3bc7bfb314f1d6f1f9a341328ef47cd63fc4f850a7b27/sounddevice-0.5.5-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:05eb9fd6c54c38d67741441c19164c0dae8ce80453af2d8c4ad2e7823d15b722", size = 108557, upload-time = "2026-01-23T18:36:37.41Z" }, - { url = "https://files.pythonhosted.org/packages/88/a1/d19dd9889cd4bce2e233c4fac007cd8daaf5b9fe6e6a5d432cf17be0b807/sounddevice-0.5.5-py3-none-win32.whl", hash = "sha256:1234cc9b4c9df97b6cbe748146ae0ec64dd7d6e44739e8e42eaa5b595313a103", size = 317765, upload-time = "2026-01-23T18:36:39.047Z" }, - { url = "https://files.pythonhosted.org/packages/c3/0e/002ed7c4c1c2ab69031f78989d3b789fee3a7fba9e586eb2b81688bf4961/sounddevice-0.5.5-py3-none-win_amd64.whl", hash = "sha256:cfc6b2c49fb7f555591c78cb8ecf48d6a637fd5b6e1db5fec6ed9365d64b3519", size = 365324, upload-time = "2026-01-23T18:36:40.496Z" }, - { url = "https://files.pythonhosted.org/packages/4e/39/a61d4b83a7746b70d23d9173be688c0c6bfc7173772344b7442c2c155497/sounddevice-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6", size = 317115, upload-time = "2026-01-23T18:36:42.235Z" }, + { url = "https://files.pythonhosted.org/packages/72/1f/62eef605172bddc1017508469a12f75bc7c4194ece35c734f822795f53b1/sounddevice-0.5.6-py3-none-any.whl", hash = "sha256:de099612311ad81e55d31ccbd83f43ea6bf4d87b48f9b6ea55a1fbcde0eee4e0", size = 32793, upload-time = "2026-08-17T07:54:57.507Z" }, + { url = "https://files.pythonhosted.org/packages/b6/84/85e719d49cf98b2f406d9ac9c338892286c4448eb42ef0b2625ccf159616/sounddevice-0.5.6-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:e3aef00ad8b1d1740eb66d9a7671eab88a4d2b8fa4ab33498d742e63b65c309c", size = 1009647, upload-time = "2026-08-17T07:54:58.814Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6f/6292145099f72a153a710245f46ae43e5fb6c77bec1b6086cb76c12dc280/sounddevice-0.5.6-py3-none-win32.whl", hash = "sha256:b36b807eb02abd257198bf84b2af05e4fea199a9d2f0019014169c7136d45e9c", size = 1009627, upload-time = "2026-08-17T07:55:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3e/cbc593c31a5f0d817b3fe97e64aa8461bd0f55cb07b67ce1b776296ae336/sounddevice-0.5.6-py3-none-win_amd64.whl", hash = "sha256:7f4162f514f007b0bf25a3ccfed3f1705bc2ec311888a90232729eec4f57a4f4", size = 1009630, upload-time = "2026-08-17T07:55:02.088Z" }, + { url = "https://files.pythonhosted.org/packages/60/a4/b0c21c9f215a6fd9606b8f8748c21212dc098e5d5a2d93068c50edcf19b4/sounddevice-0.5.6-py3-none-win_arm64.whl", hash = "sha256:c8ae19173e5f27f8c12d4b5eee2dbfe542cee125d591e663e0fb4dfb75246d45", size = 1009630, upload-time = "2026-08-17T07:55:03.689Z" }, ] [[package]] @@ -1079,39 +1079,39 @@ provides-extras = ["linting", "testing-minimal", "testing-unit", "testing", "doc [[package]] name = "tqdm" -version = "4.70.0" +version = "4.70.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/ea/b2a5bd54b28a324dae8211928b2d730b6547500342c7e6c6dea08bd0a485/tqdm-4.70.1.tar.gz", hash = "sha256:cefd0eca11b2a37a3aee776544d4f4ae913f02688135b5556b8788dfa474afc4", size = 171846, upload-time = "2026-09-11T07:25:16.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, + { url = "https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl", hash = "sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73", size = 80199, upload-time = "2026-09-11T07:25:14.599Z" }, ] [[package]] name = "ty" -version = "0.0.72" +version = "0.0.80" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d5/df/656e684bafb13c1d146e7d5b5f3e7978ca177232acc84998ff36427e9462/ty-0.0.72.tar.gz", hash = "sha256:ec2b8066b618df18cab4cb8e992f8da45d360332acb23fa34df7fa29cd1b9d3a", size = 6654939, upload-time = "2026-08-14T21:35:42.612Z" } +sdist = { url = "https://files.pythonhosted.org/packages/54/b0/6d1b10e0d422736a3c439e487c950ae785f401d71ff879d5be68bccb6d90/ty-0.0.80.tar.gz", hash = "sha256:fe86bc91327e45ff5e3593b7e306e7f57a44bc0608f38d647b8d97bd99c96013", size = 7183998, upload-time = "2026-09-09T21:18:47.547Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/3b/f51461239a4e66565d4b362f97a3b55fe7fdba2e944068341f87c62f6743/ty-0.0.72-py3-none-linux_armv6l.whl", hash = "sha256:fda86db153ffd85ee52000cf175d6a3f1c0223772cf7c5b6f726200bf92c7b44", size = 12621989, upload-time = "2026-08-14T21:35:01.676Z" }, - { url = "https://files.pythonhosted.org/packages/ca/fb/79ddf683affc679ca856f3510b5640ec3a88a842ba5f654f5d4bc78f1786/ty-0.0.72-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ceb944c612529b9023acfdc9cf4c0dcbb722549f9d17d46baecd1141baf01d7f", size = 12233910, upload-time = "2026-08-14T21:35:04.334Z" }, - { url = "https://files.pythonhosted.org/packages/5d/45/10562a0d84802158db8fa4ec46de54aa9fdcecdeeaabbfe3639ae7042b66/ty-0.0.72-py3-none-macosx_11_0_arm64.whl", hash = "sha256:108d76218333d6c092e5f1cebf8e9b06f25738613a0236a28e2dd47c936ee52c", size = 12084108, upload-time = "2026-08-14T21:35:06.686Z" }, - { url = "https://files.pythonhosted.org/packages/a1/dc/1fe1aef8d697e3509face271a5331700c7aa1d1e44a4b622707bdfa41d4b/ty-0.0.72-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f3943f186f741a2499a31053872169250c9264a9a49684920e48d8fcf4ef4f5", size = 12132640, upload-time = "2026-08-14T21:35:09.305Z" }, - { url = "https://files.pythonhosted.org/packages/14/46/41ceb265e96969487311a2014bd0e53abb4fbc1395efb2ebe411fcb4db62/ty-0.0.72-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf283c07dc3cc52ca48a3ad8ab100fb5aec3aebbd03ef6a12d5f910b8e596fc5", size = 12402489, upload-time = "2026-08-14T21:35:11.555Z" }, - { url = "https://files.pythonhosted.org/packages/2b/45/30bf43cb4fd505c5c2dd30fda27dde5f05208686cd21217adec77c954204/ty-0.0.72-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95f3b6462c38f9f115d10cee21f47fedf715fcf2040daf36eef210359300bc7c", size = 13130835, upload-time = "2026-08-14T21:35:13.746Z" }, - { url = "https://files.pythonhosted.org/packages/31/2f/03bba754d2613f640df168335c41f83f41db150bb515839c60d80e3a7880/ty-0.0.72-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30caf658feb8ffb250d9e9e47107657a78f5f3425c227df1664d8df2ebe38880", size = 13590392, upload-time = "2026-08-14T21:35:16.839Z" }, - { url = "https://files.pythonhosted.org/packages/04/c7/03c67f00e63005ec41585653dc3096064570b1e6273742baae2798cd242f/ty-0.0.72-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27bdc012ddfbeec8948e4a6036c0dc39ac7cf2c8ec7c7d48dc7d2fd56d57b399", size = 13309629, upload-time = "2026-08-14T21:35:19.169Z" }, - { url = "https://files.pythonhosted.org/packages/c1/df/102d3b264eb7f2a58dd11952f229bb5150bb5668d176a6154976a6675981/ty-0.0.72-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:802c5970a77d7739e6f499921fbb6984fb7ad8a31d95e1ff42fd46f3642e4f3b", size = 12734028, upload-time = "2026-08-14T21:35:22.099Z" }, - { url = "https://files.pythonhosted.org/packages/61/85/d0737c8c54d0ba67366ddfb9f31d88edf0b02299e65923e6945ae60ebcb5/ty-0.0.72-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:47dce65114fdc615c68ca0edb393b433df0956447e4267df0e264137a789598d", size = 13174832, upload-time = "2026-08-14T21:35:24.71Z" }, - { url = "https://files.pythonhosted.org/packages/1e/31/497f5a96c36d9b586ab6afe0574986835c6fd5b835a89773d2bec4711b49/ty-0.0.72-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:325144fa07e2675d0faa337fcc864213c272a499eb0cfe5bde2fdc62282d27bc", size = 12215005, upload-time = "2026-08-14T21:35:26.892Z" }, - { url = "https://files.pythonhosted.org/packages/df/7d/46e65b17b4966c7cd0140f134380d33d8e84fe6efccd761533ce793dc502/ty-0.0.72-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a5c9f15d0f58e43707d8848274be1821a0ef408eccb8aa7dda28a4a9eddf7640", size = 12421298, upload-time = "2026-08-14T21:35:29.301Z" }, - { url = "https://files.pythonhosted.org/packages/08/2a/12ada4ec17700b3cb1d4fd3bc3e5b1852df9e6885288429318cade87b3c1/ty-0.0.72-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8ee508d64b381871529cc22c412b41071bf5e908b7aa5d66a38f3f6b2573a806", size = 12669242, upload-time = "2026-08-14T21:35:31.444Z" }, - { url = "https://files.pythonhosted.org/packages/1c/1a/4692536880790fb550ed6d44a6096778dc71bb112f2c6d615cebb01a57e5/ty-0.0.72-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3699e2ec7921d44da79d6b089f7bf239b2cc53c4e45a5a38430adc34ee9e9a55", size = 12988199, upload-time = "2026-08-14T21:35:33.749Z" }, - { url = "https://files.pythonhosted.org/packages/9a/0d/f5e5a50322e9c45865e7b7a428ba6cd6527387cf0f2472492ac3cf746243/ty-0.0.72-py3-none-win32.whl", hash = "sha256:f25f72a67bd36cd247707c4784e52fad0b6b4f42a1b7dd14804110fa95c486ed", size = 11939708, upload-time = "2026-08-14T21:35:36.006Z" }, - { url = "https://files.pythonhosted.org/packages/3f/4e/8af3534b2e4214e6184a5a59c34101e94a68d578f081f97b995866bab1bf/ty-0.0.72-py3-none-win_amd64.whl", hash = "sha256:cdeee869341717e1736cea2e2d7856738c6957c320f584ed2f68c8f90100d2f5", size = 12643876, upload-time = "2026-08-14T21:35:38.141Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ea/a2606e654c7276bd08586391a2525b0af3f3bf60228a8c57b2d248f273f9/ty-0.0.72-py3-none-win_arm64.whl", hash = "sha256:1bd3ac3ed4424a6d6990a85dc388556aea012bd752de21349a84b685951de0d8", size = 12394857, upload-time = "2026-08-14T21:35:40.277Z" }, + { url = "https://files.pythonhosted.org/packages/29/c8/3c93195eca282936ebb574d0ba98843e4b147ee324006f88464777012a92/ty-0.0.80-py3-none-linux_armv6l.whl", hash = "sha256:738d1cfca466c577aea24547c348629c00c63548cf7da2c46b497b3840f85dee", size = 13606509, upload-time = "2026-09-09T21:18:10.476Z" }, + { url = "https://files.pythonhosted.org/packages/e7/48/bbb47f7001c97262a5109bcd92a45bcc8a2fbb21e994c214a9897630bfb7/ty-0.0.80-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:56060164bb8ee43770fa367524fbdba2611fc90f8923a02cc91f80eb37d5a1b0", size = 13203812, upload-time = "2026-09-09T21:18:12.796Z" }, + { url = "https://files.pythonhosted.org/packages/db/8d/fd141160567047b742e466be8ed09a4c4ed80fa045d37f946eb4f4532fee/ty-0.0.80-py3-none-macosx_11_0_arm64.whl", hash = "sha256:da4062e0fbf3923d9b71688157c5753394f243348f00732e9edbb27498397169", size = 13021896, upload-time = "2026-09-09T21:18:15.186Z" }, + { url = "https://files.pythonhosted.org/packages/1e/10/b4177faf9e71bc37bc4d08f512042269660a1ce0ec457c48f96e51fc91d7/ty-0.0.80-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9eb94b2659f506a3a07a9ea1415d5c18aaa1e554adc1612614d617a0ed320e1", size = 13083881, upload-time = "2026-09-09T21:18:17.616Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c2/192d48b9a9acbbe030fc9a4bd73af75671a5566e731949f77e912a68ce68/ty-0.0.80-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2f5e39a87da48c1af1a3b3135f02be7a83a93da30b0d6a5f5d27806e7c747ce", size = 13354874, upload-time = "2026-09-09T21:18:19.744Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5b/736dfd31efd98bed9dbe018ff91676e5ea83485b1e24afb635fd0247728f/ty-0.0.80-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e00149e7779c6b98f3ba12e10a631a861bd7ca1a42bbf064e2677ada7d2c0c2", size = 14204805, upload-time = "2026-09-09T21:18:21.999Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6f/557c726cb53401998e3589bb632c963a6887a70240e0a4386024f3731c81/ty-0.0.80-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a377268f359fb6e7a2cc9026a09223c764f58d020dec8e6baeaba9caba6ff829", size = 14630014, upload-time = "2026-09-09T21:18:24Z" }, + { url = "https://files.pythonhosted.org/packages/e9/19/7a4b18fe27f6b6bd4ffa56b67c8b09ecb76bb4312d1c64b73f65417b4dbd/ty-0.0.80-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4511dbf1b266b9ce5b73e9b28d94468096c2b6da00e2fe205168c32d3b352ca7", size = 14326946, upload-time = "2026-09-09T21:18:26.679Z" }, + { url = "https://files.pythonhosted.org/packages/f8/95/16dd90805fc7e53ec54ae9fdb1a8b74753fb159a55a170f60f49b9417824/ty-0.0.80-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe95feffa7156800c6f804195acb9fb5846a39671c7192c30fff23351aaf7c31", size = 13705988, upload-time = "2026-09-09T21:18:28.681Z" }, + { url = "https://files.pythonhosted.org/packages/b1/09/4e87992c23ab8a742c7d4914ac5fe66fb9301c8464a69fd2ecbc0be3fbcd/ty-0.0.80-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ba26b39f06bc8c3c2c5acd3a147239482b36620cdbca1b9d02e915294854f2cc", size = 14233729, upload-time = "2026-09-09T21:18:30.961Z" }, + { url = "https://files.pythonhosted.org/packages/36/e9/b8c11fda8e66a1d1cc1a7160ed719a33f4dc0107b1cef6a14e2673965763/ty-0.0.80-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2e6167320888c115a6fbe69893b63fd1c848f9121a454e75e5f612674528e3f3", size = 13173523, upload-time = "2026-09-09T21:18:33.266Z" }, + { url = "https://files.pythonhosted.org/packages/73/a7/512083fa540c5be1ac8642658f03bfb5bfa6fd168ea4f55e3c0aabe04166/ty-0.0.80-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7aecb62b4de70b479eab07d1779ea66da26dd8b068f50a9330867157312f103c", size = 13373014, upload-time = "2026-09-09T21:18:35.339Z" }, + { url = "https://files.pythonhosted.org/packages/5e/63/cd2f0ca81fd9b8cafef93bbcf022bf636bc4de8ee7465c6aafeec1d4d52b/ty-0.0.80-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4fed06adacd16b7e2d722f37449021b1419598d0440769701c0f4827faddde63", size = 13667827, upload-time = "2026-09-09T21:18:37.241Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5d/ebdfdcb2dc099ae74dc4b75511eef0dd398b2fa27006c543543974b4567f/ty-0.0.80-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:09329af6303ce611ec2cfffc995dc1ddf76c9f47194fc1a4d64a984759c25f5b", size = 13963866, upload-time = "2026-09-09T21:18:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/c286d5e1b560cfd16d6e91fdab683718aacf09fc88938ca06dcc9d1c8502/ty-0.0.80-py3-none-win32.whl", hash = "sha256:a81b3b512f7b4c68e42fbd1835633de658809666e9aafd5defa52bbc5197698d", size = 12881806, upload-time = "2026-09-09T21:18:41.507Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c8/3b3a8ac16d47a23ff34bf57c0d99a491860748aea96e0e886b4bfed54f9b/ty-0.0.80-py3-none-win_amd64.whl", hash = "sha256:8043f99878a2ae434781cb881c8a3840dff70c4a5b60bdc5ae84251f35ee063f", size = 13528883, upload-time = "2026-09-09T21:18:43.687Z" }, + { url = "https://files.pythonhosted.org/packages/2e/71/a6c697930fca76596d7d17f8853120f41a298fd9ea632c901cfca8ca869b/ty-0.0.80-py3-none-win_arm64.whl", hash = "sha256:e277ef034331da5319efc839c064968d24f35715b71c70369cf97d36fe4dcbdb", size = 13370135, upload-time = "2026-09-09T21:18:45.758Z" }, ] [[package]] @@ -1125,11 +1125,11 @@ wheels = [ [[package]] name = "websocket-client" -version = "1.9.0" +version = "1.9.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/cb/a5abcc2891249f393827c650c6296660ce40374ac22d99ab9aea41f9d2a2/websocket_client-1.9.2.tar.gz", hash = "sha256:0fcb57545848be86992e128218fd96dd87a6769ffdb1a968dff79632b85604d0", size = 84110, upload-time = "2026-08-31T14:08:40.964Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d2/cc4dc1271e464942db7ee278baae2daa99ee77cb2af744025c04da585a3e/websocket_client-1.9.2-py3-none-any.whl", hash = "sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce", size = 95786, upload-time = "2026-08-31T14:08:39.899Z" }, ] [[package]] From 39146cb240975b6cf51ece8ee0a3faee787d773f Mon Sep 17 00:00:00 2001 From: Harsh Singh <143034341+singhharsh1708@users.noreply.github.com> Date: Sun, 13 Sep 2026 03:00:51 +0530 Subject: [PATCH 079/122] test_runner: collect parameterized_class tests instead of skipping them with their base (#38820) --- tools/test_runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/test_runner.py b/tools/test_runner.py index 1979ce01e4..2121907e33 100755 --- a/tools/test_runner.py +++ b/tools/test_runner.py @@ -170,7 +170,7 @@ def collect(targets, keyword): cls = type(test) if cls.__name__ == "_FailedTest": continue - if getattr(cls, "__unittest_skip_why__", "") == "parameterized base class": + if cls.__dict__.get("__unittest_skip_why__", "") == "parameterized base class": continue if not keyword or keyword.lower() in test.id().lower(): tests.append(test) From f2357840427987b4bbe7abb9a019331f4aa8e382 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sat, 12 Sep 2026 17:26:45 -0700 Subject: [PATCH 080/122] Revert tinygrad update (#38879) --- tinygrad_repo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinygrad_repo b/tinygrad_repo index 76059714bf..f6fc4e3f2c 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit 76059714bf4468363b52358434f7ce35be7f425f +Subproject commit f6fc4e3f2c3db5fae1e19cbfbc3ad9fc579a12ae From f147094cb7c81e2b6d685d776b6a4df2cbffaeb4 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:44:43 -0700 Subject: [PATCH 081/122] cabana: restore Qt palettes and contrast (#38875) * cabana: restore Qt dark palette and contrast * cabana: restore Qt standard light palette and selection text * cabana: drive component colors from shared style values * cabana: remove historical palette comments --- .../tools/cabana/ui/chart/signalselector.cc | 8 +-- openpilot/tools/cabana/ui/chart/sparkline.cc | 7 +-- .../tools/cabana/ui/dialogs/filedialog.cc | 2 +- .../tools/cabana/ui/dialogs/routesdialog.cc | 2 +- openpilot/tools/cabana/ui/theme.cc | 61 ++++++++++--------- openpilot/tools/cabana/ui/theme.h | 13 ++-- openpilot/tools/cabana/ui/util.cc | 16 ++++- openpilot/tools/cabana/ui/util.h | 4 +- .../tools/cabana/ui/widgets/binaryview.cc | 16 ++--- .../tools/cabana/ui/widgets/detailwidget.cc | 4 +- .../tools/cabana/ui/widgets/messagebytes.cc | 5 +- .../tools/cabana/ui/widgets/signalview.cc | 10 +-- 12 files changed, 81 insertions(+), 67 deletions(-) diff --git a/openpilot/tools/cabana/ui/chart/signalselector.cc b/openpilot/tools/cabana/ui/chart/signalselector.cc index dd84c49cba..245603d035 100644 --- a/openpilot/tools/cabana/ui/chart/signalselector.cc +++ b/openpilot/tools/cabana/ui/chart/signalselector.cc @@ -54,7 +54,7 @@ bool SignalSelector::draw() { inputText("##msgs_filter", &msgs_combo_filter_, "Select a message..."); for (int i = 0; i < (int)msgs_combo_.size(); ++i) { if (!msgs_combo_filter_.empty() && !utils::containsCI(msgs_combo_[i].text, msgs_combo_filter_)) continue; - if (ImGui::Selectable(msgs_combo_[i].text.c_str(), i == msgs_combo_index_)) { + if (selectable(msgs_combo_[i].text.c_str(), i == msgs_combo_index_)) { msgs_combo_index_ = i; updateAvailableList(i); ImGui::CloseCurrentPopup(); @@ -107,7 +107,7 @@ void SignalSelector::drawList(const char *id, std::vector &list, int * const auto &item = list[i]; ImGui::PushID(i); const ImVec2 pos = ImGui::GetCursorScreenPos(); - if (ImGui::Selectable("##item", i == *current_row)) *current_row = i; + if (selectable("##item", i == *current_row)) *current_row = i; if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { *current_row = i; *double_clicked = true; @@ -117,10 +117,10 @@ void SignalSelector::drawList(const char *id, std::vector &list, int * float x = pos.x + 5; drawColorMarker(dl, ImVec2(x, pos.y), toImU32(item.sig->color)); x += markerSize() + 4; - dl->AddText(ImVec2(x, pos.y), ImGui::GetColorU32(ImGuiCol_Text), item.sig->name.c_str()); + dl->AddText(ImVec2(x, pos.y), ImGui::GetColorU32(i == *current_row ? palette().text_selected : palette().text), item.sig->name.c_str()); if (show_msg_name) { x += ImGui::CalcTextSize(item.sig->name.c_str()).x; - dl->AddText(ImVec2(x, pos.y), ImGui::GetColorU32(ImGuiCol_TextDisabled), msgLabel(item.msg_id).c_str()); + dl->AddText(ImVec2(x, pos.y), ImGui::GetColorU32(i == *current_row ? palette().text_selected : palette().text_disabled), msgLabel(item.msg_id).c_str()); } ImGui::PopID(); } diff --git a/openpilot/tools/cabana/ui/chart/sparkline.cc b/openpilot/tools/cabana/ui/chart/sparkline.cc index cf0482c197..1e804f2765 100644 --- a/openpilot/tools/cabana/ui/chart/sparkline.cc +++ b/openpilot/tools/cabana/ui/chart/sparkline.cc @@ -100,12 +100,7 @@ void Sparkline::render(const CabanaColor &color, int range, ImVec2 sz, double wi } size = sz; - CabanaColor line_color = color; - if (!isDarkTheme()) { - auto [h, s, v] = color.hsv(); - line_color = CabanaColor::fromHsv(h, std::min(1.0f, s * 2.0f), v * 0.7f, color.a / 255.0f); - } - color_ = toImU32(line_color); + color_ = toImU32(sparklineColor(color)); draw_individual_points_ = draw_individual_points; window_end_ = window_end; xscale_ = xscale; diff --git a/openpilot/tools/cabana/ui/dialogs/filedialog.cc b/openpilot/tools/cabana/ui/dialogs/filedialog.cc index 5721d8edbc..7ac420e499 100644 --- a/openpilot/tools/cabana/ui/dialogs/filedialog.cc +++ b/openpilot/tools/cabana/ui/dialogs/filedialog.cc @@ -165,7 +165,7 @@ void draw() { const std::string label = (is_dir ? std::string(icon::FOLDER) : std::string(icon::FILE_EARMARK)) + " " + name; ImGui::PushID(static_cast(i)); const bool selected = !is_dir && name == s.filename; - if (ImGui::Selectable(label.c_str(), selected, ImGuiSelectableFlags_AllowDoubleClick)) { + if (selectable(label.c_str(), selected, ImGuiSelectableFlags_AllowDoubleClick)) { const bool double_clicked = ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left); if (is_dir) { if (double_clicked) { diff --git a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc index 8e192dce17..9e21464985 100644 --- a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc +++ b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc @@ -108,7 +108,7 @@ void RoutesDialog::draw() { } for (int i = 0; i < static_cast(s_.routes.size()); ++i) { ImGui::PushID(i); - if (ImGui::Selectable(s_.routes[i].label.c_str(), s_.route_index == i, ImGuiSelectableFlags_AllowDoubleClick)) { + if (selectable(s_.routes[i].label.c_str(), s_.route_index == i, ImGuiSelectableFlags_AllowDoubleClick)) { s_.route_index = i; if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) accepted = true; } diff --git a/openpilot/tools/cabana/ui/theme.cc b/openpilot/tools/cabana/ui/theme.cc index ff02ca24c7..9a2a6741bc 100644 --- a/openpilot/tools/cabana/ui/theme.cc +++ b/openpilot/tools/cabana/ui/theme.cc @@ -12,30 +12,35 @@ namespace fs = std::filesystem; namespace { constexpr Palette DARK_PALETTE = { - .text = rgb(0xf8f9f9), .text_disabled = rgb(0xb8c0c4), - .window = rgb(0x1d2225), .surface = rgb(0x30373b), - .frame = rgb(0x1e2224), .frame_hovered = rgb(0x394044), .frame_active = rgb(0x424a4f), - .button = rgb(0x424a4f), .button_hovered = rgb(0x535f64), .button_active = rgb(0x175886), - .header = rgb(0x175886), .header_hovered = rgb(0x24455e), .header_active = rgb(0x1c6ea8), - .accent = rgb(0x57a9e3), - .border = rgb(0x65737a), .separator = rgb(0x4b5559), - .tab = rgb(0x272c2f), .tab_hovered = rgb(0x424a4f), .table_header = rgb(0x424a4f), - .grid = rgb(0x65737a, 0.45f), .badge = rgb(0x808080), + .text = rgb(0xbbbbbb), .text_disabled = rgb(0x777777), .text_selected = rgb(0xbbbbbb), + .window = rgb(0x353535), .surface = rgb(0x3c3f41), + .frame = rgb(0x3c3f41), .frame_hovered = rgb(0x484b4d), .frame_active = rgb(0x505355), + .button = rgb(0x484b4d), .button_hovered = rgb(0x535658), .button_active = rgb(0x3c3f41), + .header = rgb(0x2f65ca), .header_hovered = rgb(0x414e65), .header_active = rgb(0x2f65ca), + .accent = rgb(0x2f65ca), + .border = rgb(0x282828), .separator = rgb(0x353535), .scrollbar_grab = rgb(0x484b4d), + .tab = rgb(0x353535), .tab_hovered = rgb(0x484b4d), .table_header = rgb(0x484b4d), + .grid = rgb(0xbbbbbb, 50.0f / 255.0f), .badge = rgb(0x808080), + .bit_background = rgb(0xffffff, 20.0f / 255.0f), + .heatmap_signal_alpha = 70.0, .heatmap_bit_alpha = 28.0, .heatmap_gamma = 0.6, + .sparkline_saturation = 1.0f, .sparkline_value = 1.0f, }; constexpr Palette LIGHT_PALETTE = { - .text = rgb(0x1e2224), .text_disabled = rgb(0x535f64), - .window = rgb(0xeeeff0), .surface = rgb(0xffffff), - .frame = rgb(0xf8f9f9), .frame_hovered = rgb(0xeeeff0), .frame_active = rgb(0xddeef9), - .button = rgb(0xe3e6e8), .button_hovered = rgb(0xd8dcdf), .button_active = rgb(0xbcddf4), - .header = rgb(0xbcddf4), .header_hovered = rgb(0xddeef9), .header_active = rgb(0x9fcbec), - .accent = rgb(0x1c6ea8), - .border = rgb(0x98a3a9), .separator = rgb(0xcdd3d6), - .tab = rgb(0xe3e6e8), .tab_hovered = rgb(0xddeef9), .table_header = rgb(0xd8dcdf), - .grid = rgb(0x98a3a9, 0.4f), .badge = rgb(0xa0a0a4), + .text = rgb(0x000000), .text_disabled = rgb(0xbebebe), .text_selected = rgb(0xffffff), + .window = rgb(0xefefef), .surface = rgb(0xffffff), + .frame = rgb(0xffffff), .frame_hovered = rgb(0xf5f9fc), .frame_active = rgb(0xe7f3fb), + .button = rgb(0xefefef), .button_hovered = rgb(0xe7f3fb), .button_active = rgb(0xd4e7f4), + .header = rgb(0x308cc6), .header_hovered = rgb(0xe7f3fb), .header_active = rgb(0x308cc6), + .accent = rgb(0x308cc6), + .border = rgb(0xb6b6b6), .separator = rgb(0xd0d0d0), .scrollbar_grab = rgb(0xb6b6b6), + .tab = rgb(0xe5e5e5), .tab_hovered = rgb(0xefefef), .table_header = rgb(0xefefef), + .grid = rgb(0x000000, 50.0f / 255.0f), .badge = rgb(0xa0a0a4), + .bit_background = rgb(0xffffff, 0.0f), + .heatmap_signal_alpha = 25.0, .heatmap_bit_alpha = 10.0, .heatmap_gamma = 1.0, + .sparkline_saturation = 2.0f, .sparkline_value = 0.7f, }; -bool g_dark = false; const Palette *g_palette = &LIGHT_PALETTE; ImFont *g_ui_font = nullptr; ImFont *g_bold_font = nullptr; @@ -82,8 +87,7 @@ void loadFonts() { } void applyTheme(int theme) { - g_dark = theme == DARK_THEME; - g_palette = g_dark ? &DARK_PALETTE : &LIGHT_PALETTE; + g_palette = theme == DARK_THEME ? &DARK_PALETTE : &LIGHT_PALETTE; const Palette &p = *g_palette; const ImVec4 none(0, 0, 0, 0); @@ -110,7 +114,7 @@ void applyTheme(int theme) { c[ImGuiCol_Text] = p.text; c[ImGuiCol_TextDisabled] = p.text_disabled; c[ImGuiCol_WindowBg] = c[ImGuiCol_ScrollbarBg] = c[ImGuiCol_DockingEmptyBg] = p.window; - c[ImGuiCol_MenuBarBg] = p.surface; + c[ImGuiCol_MenuBarBg] = p.window; c[ImGuiCol_TitleBg] = c[ImGuiCol_TitleBgActive] = c[ImGuiCol_TitleBgCollapsed] = p.window; c[ImGuiCol_ChildBg] = c[ImGuiCol_PopupBg] = p.surface; c[ImGuiCol_Border] = c[ImGuiCol_TableBorderStrong] = p.border; @@ -121,7 +125,7 @@ void applyTheme(int theme) { c[ImGuiCol_FrameBgActive] = p.frame_active; c[ImGuiCol_Button] = p.button; c[ImGuiCol_ButtonHovered] = c[ImGuiCol_SliderGrab] = p.button_hovered; - c[ImGuiCol_ScrollbarGrab] = p.border; + c[ImGuiCol_ScrollbarGrab] = p.scrollbar_grab; c[ImGuiCol_ScrollbarGrabHovered] = p.text_disabled; c[ImGuiCol_ButtonActive] = p.button_active; c[ImGuiCol_Header] = p.header; @@ -138,7 +142,7 @@ void applyTheme(int theme) { c[ImGuiCol_TabSelected] = c[ImGuiCol_TabDimmedSelected] = p.surface; c[ImGuiCol_TabDimmedSelectedOverline] = none; c[ImGuiCol_TableHeaderBg] = p.table_header; - c[ImGuiCol_TableRowBgAlt] = g_dark ? ImVec4(1, 1, 1, 0.065f) : ImVec4(0, 0, 0, 0.045f); + c[ImGuiCol_TableRowBgAlt] = none; c[ImGuiCol_PlotLines] = p.text; // ImGuiStyle() seeds every slot from the dark theme: set the rest so the light theme does not keep a white caret. c[ImGuiCol_InputTextCursor] = c[ImGuiCol_UnsavedMarker] = p.text; @@ -152,13 +156,12 @@ void applyTheme(int theme) { ImPlot::GetStyle().Colors[ImPlotCol_AxisGrid] = p.grid; } -bool isDarkTheme() { return g_dark; } const Palette &palette() { return *g_palette; } -CabanaColor signalFillColor(const CabanaColor &c) { - if (!g_dark) return c; - auto [h, s, v] = c.hsv(); - return CabanaColor::fromHsv(h, std::min(1.0f, s * 1.4f), v * 0.8f, c.a / 255.0f); +CabanaColor sparklineColor(const CabanaColor &color) { + const Palette &p = palette(); + auto [h, s, v] = color.hsv(); + return CabanaColor::fromHsv(h, std::min(1.0f, s * p.sparkline_saturation), v * p.sparkline_value, color.a / 255.0f); } ImFont *boldFont() { return g_bold_font; } diff --git a/openpilot/tools/cabana/ui/theme.h b/openpilot/tools/cabana/ui/theme.h index 91f2a0740f..dbd0d97814 100644 --- a/openpilot/tools/cabana/ui/theme.h +++ b/openpilot/tools/cabana/ui/theme.h @@ -5,20 +5,21 @@ #include "tools/cabana/core/color.h" -// Palette source: commaai/connect src/{colors,theme}.js at 7091050. -// Dark colors follow connect; light colors use its lightGrey and lightBlue families. struct Palette { - ImVec4 text, text_disabled; + ImVec4 text, text_disabled, text_selected; ImVec4 window; // the background behind panels and docked windows ImVec4 surface; // panels, popups, table bodies: what content is drawn on ImVec4 frame, frame_hovered, frame_active; ImVec4 button, button_hovered, button_active; ImVec4 header, header_hovered, header_active; // selections ImVec4 accent; - ImVec4 border, separator; + ImVec4 border, separator, scrollbar_grab; ImVec4 tab, tab_hovered, table_header; ImVec4 grid; ImVec4 badge; // the fill behind the time labels drawn over a chart + ImVec4 bit_background; // overlay beneath heatmap bits without a signal + double heatmap_signal_alpha, heatmap_bit_alpha, heatmap_gamma; + float sparkline_saturation, sparkline_value; // HSV multipliers for signal colors }; constexpr ImVec4 rgb(unsigned hex, float alpha = 1.0f) { @@ -35,10 +36,8 @@ constexpr float UI_FONT_SIZE = 16.0f; void loadFonts(); void applyTheme(int theme); // Safe to call at runtime. -bool isDarkTheme(); const Palette &palette(); - -CabanaColor signalFillColor(const CabanaColor &c); +CabanaColor sparklineColor(const CabanaColor &color); ImFont *boldFont(); void pushMonoFont(float size = 0.0f); diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index 4566064a27..50553c2d47 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -89,13 +89,23 @@ bool clearableInput(const char *label, std::string *s, const char *hint, ImGuiIn return changed; } +bool selectable(const char *label, bool selected, ImGuiSelectableFlags flags, const ImVec2 &size) { + if (selected) { + ImGui::PushStyleColor(ImGuiCol_Text, palette().text_selected); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, palette().header); + } + const bool clicked = ImGui::Selectable(label, selected, flags, size); + if (selected) ImGui::PopStyleColor(2); + return clicked; +} + bool comboBox(const char *label, int *index, const std::vector &items) { bool changed = false; const int count = (int)items.size(); if (ImGui::BeginCombo(label, *index >= 0 && *index < count ? items[*index].c_str() : "")) { for (int i = 0; i < count; ++i) { ImGui::PushID(i); - if (ImGui::Selectable(items[i].c_str(), i == *index) && *index != i) { + if (selectable(items[i].c_str(), i == *index) && *index != i) { *index = i; changed = true; } @@ -241,7 +251,7 @@ void disabledItemTooltip(const char *text) { bool radioMenuItem(const char *label, bool checked, float width) { const float indent = ImGui::GetFontSize(); const ImVec2 pos = ImGui::GetCursorScreenPos(); - const bool clicked = ImGui::Selectable((std::string("##") + label).c_str(), false, ImGuiSelectableFlags_None, + const bool clicked = selectable((std::string("##") + label).c_str(), false, ImGuiSelectableFlags_None, ImVec2(ImMax(width, ImGui::GetContentRegionAvail().x), 0.0f)); const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text); ImDrawList *painter = ImGui::GetWindowDrawList(); @@ -328,7 +338,7 @@ int tableHeadersRow() { bool viewSelectable(const char *label, bool selected, ImGuiSelectableFlags flags, const ImVec2 &size) { ImGui::PushStyleColor(ImGuiCol_HeaderHovered, selected ? ImGui::GetColorU32(ImGuiCol_Header) : IM_COL32(0, 0, 0, 0)); ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImGui::GetColorU32(ImGuiCol_Header)); - const bool clicked = ImGui::Selectable(label, selected, flags, size); + const bool clicked = selectable(label, selected, flags, size); ImGui::PopStyleColor(2); return clicked; } diff --git a/openpilot/tools/cabana/ui/util.h b/openpilot/tools/cabana/ui/util.h index d7a27fba7b..e1f368e1ac 100644 --- a/openpilot/tools/cabana/ui/util.h +++ b/openpilot/tools/cabana/ui/util.h @@ -38,6 +38,8 @@ bool beginControlChild(const char *id, const ImVec2 &size, ImGuiWindowFlags flag // SetNextItemWidth includes the field and clear button. Returns true when text changes. bool clearableInput(const char *label, std::string *s, const char *hint = "", ImGuiInputTextCallback validator = nullptr); +bool selectable(const char *label, bool selected, ImGuiSelectableFlags flags = 0, const ImVec2 &size = ImVec2(0, 0)); + bool comboBox(const char *label, int *index, const std::vector &items); // numeric items (bus ids, bus speeds) are formatted as they are drawn @@ -48,7 +50,7 @@ inline bool comboBox(const char *label, int *index, const T *values, int count) if (ImGui::BeginCombo(label, preview.c_str())) { for (int i = 0; i < count; ++i) { ImGui::PushID(i); - if (ImGui::Selectable(std::to_string(values[i]).c_str(), i == *index) && *index != i) { + if (selectable(std::to_string(values[i]).c_str(), i == *index) && *index != i) { *index = i; changed = true; } diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.cc b/openpilot/tools/cabana/ui/widgets/binaryview.cc index 4469273607..84f5a29fd8 100644 --- a/openpilot/tools/cabana/ui/widgets/binaryview.cc +++ b/openpilot/tools/cabana/ui/widgets/binaryview.cc @@ -367,11 +367,11 @@ void BinaryView::updateState() { } } - const bool dark = isDarkTheme(); + const Palette &p = palette(); const double max_alpha = 255.0; - const double min_alpha_with_signal = dark ? 70.0 : 25.0; // Base alpha for small flip counts - const double min_alpha_no_signal = dark ? 28.0 : 10.0; // Base alpha for small flip counts for no signal bits - const double alpha_gamma = dark ? 0.6 : 1.0; + const double min_alpha_with_signal = p.heatmap_signal_alpha; // Base alpha for small flip counts + const double min_alpha_no_signal = p.heatmap_bit_alpha; // Base alpha for small flip counts for no signal bits + const double alpha_gamma = p.heatmap_gamma; const double log_factor = 1.0 + 0.2; const double log_scaler = max_alpha / log2(log_factor * max_bit_flip_count); @@ -456,13 +456,13 @@ void BinaryView::paintCell(ImDrawList *painter, const ImRect &rect, const Binary if (item->sigs.size() > 0) { for (auto &s : item->sigs) { if (s == hovered_sig_) { - painter->AddRectFilled(rect.Min, rect.Max, toImU32(signalFillColor(s->color).darker(125))); // 4/5x brightness + painter->AddRectFilled(rect.Min, rect.Max, toImU32(s->color.darker(125))); // 4/5x brightness } else { drawSignalCell(painter, rect, index, s); } } } else if (item->valid) { - if (isDarkTheme()) painter->AddRectFilled(rect.Min, rect.Max, IM_COL32(255, 255, 255, 20)); + painter->AddRectFilled(rect.Min, rect.Max, ImGui::GetColorU32(palette().bit_background)); if (item->bg_color.alpha() > 0) painter->AddRectFilled(rect.Min, rect.Max, toImU32(item->bg_color)); } bool bright = std::find(item->sigs.begin(), item->sigs.end(), hovered_sig_) != item->sigs.end(); @@ -526,9 +526,9 @@ void BinaryView::drawSignalCell(ImDrawList *painter, const ImRect &rect, const B if (bottom_notch) band(bottom_notch, rc.Max.y - spacing, rc.Max.y); auto item = &cellAt(index); - CabanaColor color = signalFillColor(sig->color); + CabanaColor color = sig->color; color.a = item->bg_color.alpha(); - const ImU32 edge = toImU32(signalFillColor(sig->color).darker(125)); + const ImU32 edge = toImU32(sig->color.darker(125)); for (const ImRect &clip : region) { painter->PushClipRect(clip.Min, clip.Max, true); diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc index 0e5cda1bd2..cd3d3378a8 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.cc +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -262,13 +262,15 @@ void DetailWidget::drawTabWidget() { const bool selected = tab_widget_index_ == i; ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetColorU32(selected ? ImGuiCol_Header : ImGuiCol_Button, selected ? 1.0f : 0.0f)); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::GetColorU32(selected ? ImGuiCol_HeaderActive : ImGuiCol_ButtonHovered)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, selected ? palette().header_active : palette().button_active); + ImGui::PushStyleColor(ImGuiCol_Text, selected ? palette().text_selected : palette().text); if (i) ImGui::SameLine(); if (ImGui::Button(labels[i].c_str()) && !selected) { tab_widget_index_ = i; if (i == 1) history_log_->onShown(); updateState(); } - ImGui::PopStyleColor(2); + ImGui::PopStyleColor(4); } ImGui::PopStyleVar(2); ImGui::EndChild(); diff --git a/openpilot/tools/cabana/ui/widgets/messagebytes.cc b/openpilot/tools/cabana/ui/widgets/messagebytes.cc index 97261039ee..63e31d6f83 100644 --- a/openpilot/tools/cabana/ui/widgets/messagebytes.cc +++ b/openpilot/tools/cabana/ui/widgets/messagebytes.cc @@ -24,7 +24,10 @@ ImVec2 bytesCellSize(int n, bool multiple_lines) { } ImU32 cellTextColor(bool selected, bool inactive) { - if (selected && inactive) return withAlpha(ImGui::GetColorU32(ImGuiCol_Text), 100); + if (selected) { + const ImU32 text = ImGui::GetColorU32(palette().text_selected); + return inactive ? withAlpha(text, 100) : text; + } return ImGui::GetColorU32(inactive ? ImGuiCol_TextDisabled : ImGuiCol_Text); } diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index d98058354a..ef18357184 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -260,13 +260,13 @@ void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const ImRect rect(option_rect.Min.x + h_margin, option_rect.Min.y + v_margin, option_rect.Max.x - h_margin, option_rect.Max.y - v_margin); // selection background is painted by the row's Selectable - const ImU32 text_color = ImGui::GetColorU32(ImGuiCol_Text); + const ImU32 text_color = ImGui::GetColorU32(selected ? palette().text_selected : palette().text); if (column == 0) { if (item->type == SignalModel::Item::Sig) { // color label ImRect icon_rect(rect.Min.x, rect.Min.y, rect.Min.x + COLOR_LABEL_WIDTH, rect.Max.y); - painter->AddRectFilled(icon_rect.Min, icon_rect.Max, toImU32(signalFillColor(item->sig->color).darker(item->highlight ? 125 : 0)), ImGui::GetStyle().FrameRounding); + painter->AddRectFilled(icon_rect.Min, icon_rect.Max, toImU32(item->sig->color.darker(item->highlight ? 125 : 0)), ImGui::GetStyle().FrameRounding); drawText(painter, icon_rect, std::to_string(item->row() + 1).c_str(), item->highlight ? IM_COL32_WHITE : IM_COL32_BLACK, nullptr, LABEL_FONT); @@ -688,7 +688,7 @@ float SignalView::minimumWidth() { } void SignalView::draw() { - ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::GetStyleColorVec4(ImGuiCol_WindowBg)); + ImGui::PushStyleColor(ImGuiCol_ChildBg, palette().surface); if (!ImGui::BeginChild("SignalView", ImVec2(0, 0), ImGuiChildFlags_Borders)) { ImGui::EndChild(); ImGui::PopStyleColor(); @@ -823,7 +823,7 @@ bool SignalView::drawItem(SignalModel::Item *item, int depth, DrawContext &ctx) if (!item->children.empty()) { const float arrow_size = ImGui::GetFontSize() * 0.7f; ImGui::RenderArrow(ctx.draw_list, ImVec2(row_min.x + depth * INDENTATION + 4.0f, row_min.y + (row_height - arrow_size) * 0.5f), - ImGui::GetColorU32(ImGuiCol_Text), item->expanded ? ImGuiDir_Down : ImGuiDir_Right, 0.7f); + ImGui::GetColorU32(selected ? palette().text_selected : palette().text), item->expanded ? ImGuiDir_Down : ImGuiDir_Right, 0.7f); } // every row is measured, the header sizes column 0 to the contents of the whole tree @@ -936,7 +936,7 @@ bool ValueDescriptionDlg::draw() { if (row == current_row_) ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, ImGui::GetColorU32(ImGuiCol_Header)); ImGui::TableSetColumnIndex(0); ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(std::to_string(row + 1).c_str()); + ImGui::TextColored(row == current_row_ ? palette().text_selected : palette().text, "%d", row + 1); ImGui::TableSetColumnIndex(1); ImGui::SetNextItemWidth(-FLT_MIN); if (valueDescriptionEditor(0, &table_[row].first)) current_row_ = row; From 7e84a6166832f1297217dd494247d30b30525862 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:45:14 -0700 Subject: [PATCH 082/122] cabana: use ImGui decorations for floating panes (#38871) * cabana: redraw floating panes during native resizing * cabana: keep rendering while native resize is held * cabana: use ImGui decorations to avoid native resize loops * cabana: draw docking previews only on the target viewport --- openpilot/tools/cabana/ui/app.cc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpilot/tools/cabana/ui/app.cc b/openpilot/tools/cabana/ui/app.cc index 3f4dac1016..e45eaac6fb 100644 --- a/openpilot/tools/cabana/ui/app.cc +++ b/openpilot/tools/cabana/ui/app.cc @@ -165,7 +165,8 @@ public: ImGuiIO &io = ImGui::GetIO(); io.ConfigFlags |= ImGuiConfigFlags_DockingEnable; io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable; - io.ConfigViewportsNoDecoration = false; + io.ConfigViewportsNoDecoration = true; + io.ConfigDockingTransparentPayload = true; io.IniFilename = nullptr; io.LogFilename = nullptr; if (!ImGui_ImplGlfw_InitForOpenGL(window, true)) { From 298e51010ce7902d0f84711c915fc249bd555412 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Sat, 12 Sep 2026 19:48:31 -0700 Subject: [PATCH 083/122] print jenkins crash log (#38880) --- Jenkinsfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Jenkinsfile b/Jenkinsfile index a5dfa86fb8..67039d0927 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -22,6 +22,7 @@ trap 'kill 0' HUP # stop this process group on SSH disconnect export CI=1 export PYTHONWARNINGS=error +export PYTHONFAULTHANDLER=1 export COMMA_CACHE=/data/tmp/comma_download_cache #export LOGPRINT=debug # this has gotten too spammy... export TEST_DIR=${env.TEST_DIR} From dc338c2831b8066412e5dd0ed452b2ff12b81846 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:37:01 -0700 Subject: [PATCH 084/122] cabana: standardize button spacing (#38873) * cabana: standardize button spacing and control alignment * cabana: center shared step controls and inset chart content * cabana: use uniform per-chart content insets --- openpilot/tools/cabana/ui/chart/chart.cc | 48 +++++----- openpilot/tools/cabana/ui/chart/chart.h | 1 + .../tools/cabana/ui/chart/chartswidget.cc | 25 +++--- .../tools/cabana/ui/chart/signalselector.cc | 2 + .../tools/cabana/ui/dialogs/filedialog.cc | 6 +- .../tools/cabana/ui/dialogs/settingsdialog.cc | 20 +---- .../tools/cabana/ui/dialogs/streamselector.cc | 11 +-- openpilot/tools/cabana/ui/mainwin.cc | 4 +- openpilot/tools/cabana/ui/theme.cc | 7 +- openpilot/tools/cabana/ui/theme.h | 8 ++ openpilot/tools/cabana/ui/tools/findsignal.cc | 33 ++++--- .../tools/cabana/ui/tools/findsimilarbits.cc | 20 +++-- openpilot/tools/cabana/ui/util.cc | 87 ++++++++++++++----- openpilot/tools/cabana/ui/util.h | 12 ++- .../tools/cabana/ui/widgets/detailwidget.cc | 9 +- .../cabana/ui/widgets/scrollabletabbar.cc | 4 +- .../tools/cabana/ui/widgets/signalview.cc | 21 +++-- .../tools/cabana/ui/widgets/signalview.h | 1 + .../tools/cabana/ui/widgets/videowidget.cc | 32 +++---- 19 files changed, 205 insertions(+), 146 deletions(-) diff --git a/openpilot/tools/cabana/ui/chart/chart.cc b/openpilot/tools/cabana/ui/chart/chart.cc index 8dc5d627d0..41256845c1 100644 --- a/openpilot/tools/cabana/ui/chart/chart.cc +++ b/openpilot/tools/cabana/ui/chart/chart.cc @@ -15,12 +15,9 @@ #include "tools/cabana/ui/util.h" #include "tools/cabana/utils/strings.h" -const int AXIS_X_TOP_MARGIN = 4; const int X_TICK_COUNT = 5; const double MIN_ZOOM_SECONDS = 0.01; // 10ms const double EPSILON = 1e-6; -constexpr ImVec4 LAYOUT_MARGINS{0, 6, 0, 6}; // left, top, right, bottom -constexpr int LEGEND_SPACING = 5; static inline bool xLessThan(const ImPlotPoint &p, double x) { return p.x < (x - EPSILON); } static inline bool isNull(const ImPlotPoint &p) { return p.x == 0 && p.y == 0; } @@ -135,27 +132,32 @@ void ChartView::manageSignals() { void ChartView::updateLayout() { const ImVec2 grip = ImGui::CalcTextSize(icon::GRIP_HORIZONTAL); - const ImVec2 top_left = layout_.rect.Min + ImVec2(LAYOUT_MARGINS.x, LAYOUT_MARGINS.y); + const ImGuiStyle &style = ImGui::GetStyle(); + // WindowPadding can be zero in a borderless pane or drag preview. Chart + // content always uses the shared control gap, independently of its parent. + layout_.content_rect = layout_.rect; + layout_.content_rect.Expand(-style.ItemSpacing.x); + const ImVec2 top_left = layout_.content_rect.Min; layout_.move_icon_rect = ImRect(top_left, top_left + grip); const ImVec2 btn_size(iconButtonWidth(), iconButtonWidth()); - const ImVec2 close_min(layout_.rect.Max.x - ImGui::GetStyle().WindowPadding.x - btn_size.x, top_left.y); + const ImVec2 close_min(layout_.content_rect.Max.x - btn_size.x, top_left.y); layout_.close_btn_rect = ImRect(close_min, close_min + btn_size); - const ImVec2 manage_min(close_min.x - btn_size.x - ImGui::GetStyle().ItemInnerSpacing.x, top_left.y); + const ImVec2 manage_min(close_min.x - btn_size.x - ImGui::GetStyle().ItemSpacing.x, top_left.y); layout_.manage_btn_rect = ImRect(manage_min, manage_min + btn_size); ImFont *bold = boldFont(); const float font_size = ImGui::GetFontSize(); const float fm_height = ImGui::GetTextLineHeight(); const int marker_size = markerSize(); - const int row_height = std::max(marker_size, fm_height) + fm_height + 3; // + the signal value line - const int legend_left = layout_.move_icon_rect.Max.x + LEGEND_SPACING; - const int legend_right = std::max(layout_.manage_btn_rect.Min.x - ImGui::GetStyle().ItemInnerSpacing.x, legend_left + 10); + const int row_height = std::max(marker_size, fm_height) + fm_height + style.ItemInnerSpacing.y; // + the signal value line + const int legend_left = layout_.move_icon_rect.Max.x + style.ItemSpacing.x; + const int legend_right = std::max(layout_.manage_btn_rect.Min.x - ImGui::GetStyle().ItemSpacing.x, legend_left + 10); // layout legend entries left-to-right, wrapping between the move icon and the buttons layout_.legend_rects.clear(); int x = legend_left, y = top_left.y; for (auto &s : sigs_) { - int w = marker_size + LEGEND_SPACING + bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, s.sig->name.c_str()).x + + int w = marker_size + style.ItemInnerSpacing.x + bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, s.sig->name.c_str()).x + ImGui::CalcTextSize(msgLabel(s.msg_id).c_str()).x; pushMonoFont(font_size); w = std::max(w, (int)std::ceil(ImGui::CalcTextSize("-0.00000e+000").x)); @@ -166,13 +168,11 @@ void ChartView::updateLayout() { y += row_height; } layout_.legend_rects.emplace_back(ImVec2(x, y), ImVec2(x + w, y + std::max(marker_size, fm_height))); - x += w + 12; + x += w + style.ItemSpacing.x; } // add top space for the legend and signal values - int adjust_top = (y + row_height) - top_left.y; - adjust_top = std::max(adjust_top, layout_.manage_btn_rect.Max.y - layout_.rect.Min.y + LAYOUT_MARGINS.y); - layout_.header_bottom = layout_.rect.Min.y + adjust_top + LAYOUT_MARGINS.y; + layout_.header_bottom = std::max(y + row_height, layout_.manage_btn_rect.Max.y) + ImGui::GetStyle().ItemSpacing.y; } void ChartView::updatePlot(double cur, double min, double max) { @@ -554,9 +554,9 @@ void ChartView::drawStaticLayer() { } void ChartView::drawAxes() { - ImGui::SetCursorScreenPos(ImVec2(layout_.rect.Min.x, layout_.header_bottom)); - const float plot_h = std::max(layout_.rect.Max.y - layout_.header_bottom - LAYOUT_MARGINS.w, 10.0f); - ImPlot::PushStyleVar(ImPlotStyleVar_PlotPadding, ImVec2(LAYOUT_MARGINS.x, AXIS_X_TOP_MARGIN)); + ImGui::SetCursorScreenPos(ImVec2(layout_.content_rect.Min.x, layout_.header_bottom)); + const float plot_h = std::max(layout_.content_rect.Max.y - layout_.header_bottom, 10.0f); + ImPlot::PushStyleVar(ImPlotStyleVar_PlotPadding, ImVec2(0.0f, ImGui::GetStyle().ItemInnerSpacing.y)); ImPlot::PushStyleColor(ImPlotCol_PlotBg, ImVec4(0, 0, 0, 0)); ImPlot::PushStyleColor(ImPlotCol_FrameBg, ImVec4(0, 0, 0, 0)); ImPlot::PushStyleColor(ImPlotCol_PlotBorder, palette().grid); @@ -568,8 +568,8 @@ void ChartView::drawAxes() { ImPlotFlags_NoBoxSelect | ImPlotFlags_NoInputs | ImPlotFlags_NoFrame; const ImPlotAxisFlags axis_flags = ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight | ImPlotAxisFlags_NoSideSwitch | ImPlotAxisFlags_Lock; // reserve room for the right half of the last x tick label - const float x_label_width = ImGui::CalcTextSize(formatNumber(x_max_, xAxisPrecision()).c_str()).x + 5; - if (ImPlot::BeginPlot("##plot", ImVec2(layout_.rect.GetWidth() - x_label_width / 2, plot_h), flags)) { + const float x_label_width = ImGui::CalcTextSize(formatNumber(x_max_, xAxisPrecision()).c_str()).x + ImGui::GetStyle().ItemInnerSpacing.x; + if (ImPlot::BeginPlot("##plot", ImVec2(layout_.content_rect.GetWidth() - x_label_width / 2, plot_h), flags)) { ImPlot::SetupAxis(ImAxis_X1, nullptr, axis_flags); ImPlot::SetupAxis(ImAxis_Y1, y_unit_.empty() ? nullptr : y_unit_.c_str(), axis_flags); ImPlot::SetupAxisLimits(ImAxis_X1, x_min_, x_max_, ImPlotCond_Always); @@ -624,7 +624,7 @@ void ChartView::drawLegend() { drawColorMarker(painter, r.Min, toImU32(s.color)); } - float x = r.Min.x + marker_size + LEGEND_SPACING; + float x = r.Min.x + marker_size + ImGui::GetStyle().ItemInnerSpacing.x; const float text_y = r.GetCenter().y - font_size / 2.0f; addTextEllipsis(painter, bold, title_color, ImVec2(x, text_y), r.Max.x, s.sig->name); float name_w = std::min(bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, s.sig->name.c_str()).x, r.Max.x - x); @@ -633,7 +633,7 @@ void ChartView::drawLegend() { addTextEllipsis(painter, normal, msg_color, ImVec2(x, text_y), r.Max.x, msg); if (!s.visible) { // strike out const float y = r.GetCenter().y; - painter->AddLine(ImVec2(r.Min.x + marker_size + LEGEND_SPACING, y), ImVec2(std::min(x + ImGui::CalcTextSize(msg.c_str()).x, r.Max.x), y), title_color); + painter->AddLine(ImVec2(r.Min.x + marker_size + ImGui::GetStyle().ItemInnerSpacing.x, y), ImVec2(std::min(x + ImGui::CalcTextSize(msg.c_str()).x, r.Max.x), y), title_color); } } } @@ -730,10 +730,10 @@ void ChartView::drawRubberBandTimeRange() { painter->PushClipRect(layout_.rect.Min, layout_.rect.Max); for (const auto &pt : {rubber_rect_.GetBL(), rubber_rect_.GetBR()}) { std::string sec = formatNumber(secondsAtPoint(pt), 2); - ImVec2 size = ImGui::CalcTextSize(sec.c_str()) + ImVec2(12, AXIS_X_TOP_MARGIN * 2); + ImVec2 size = ImGui::CalcTextSize(sec.c_str()) + ImVec2(12, ImGui::GetStyle().ItemInnerSpacing.y * 2); ImVec2 top_left = pt.x == rubber_rect_.Min.x ? ImVec2(pt.x - size.x, pt.y + 2) : ImVec2(pt.x, pt.y + 2); painter->AddRectFilled(top_left, top_left + size, badge, ImGui::GetStyle().FrameRounding); - painter->AddText(top_left + ImVec2(6, AXIS_X_TOP_MARGIN), white, sec.c_str()); + painter->AddText(top_left + ImVec2(6, ImGui::GetStyle().ItemInnerSpacing.y), white, sec.c_str()); } painter->PopClipRect(); } @@ -745,7 +745,7 @@ void ChartView::drawTimeline() { std::string time_str = formatNumber(cur_sec_, 2); ImVec2 time_str_size = ImGui::CalcTextSize(time_str.c_str()) + ImVec2(8, 2); - ImVec2 time_str_pos(x - time_str_size.x / 2.0f, layout_.plot_area.Max.y + AXIS_X_TOP_MARGIN); + ImVec2 time_str_pos(x - time_str_size.x / 2.0f, layout_.plot_area.Max.y + ImGui::GetStyle().ItemInnerSpacing.y); painter->AddRectFilled(time_str_pos, time_str_pos + time_str_size, ImGui::GetColorU32(palette().badge), ImGui::GetStyle().FrameRounding); painter->AddText(time_str_pos + ImVec2(4, 1), IM_COL32_WHITE, time_str.c_str()); } diff --git a/openpilot/tools/cabana/ui/chart/chart.h b/openpilot/tools/cabana/ui/chart/chart.h index d32906390a..7e7152f6df 100644 --- a/openpilot/tools/cabana/ui/chart/chart.h +++ b/openpilot/tools/cabana/ui/chart/chart.h @@ -106,6 +106,7 @@ private: // layout struct Layout { ImRect rect; // the whole chart widget, screen coordinates + ImRect content_rect; // the same inset on all four sides, including during a drag ImRect plot_area; ImRect move_icon_rect; ImRect close_btn_rect; diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.cc b/openpilot/tools/cabana/ui/chart/chartswidget.cc index 24ce32aa25..94a5fa81b2 100644 --- a/openpilot/tools/cabana/ui/chart/chartswidget.cc +++ b/openpilot/tools/cabana/ui/chart/chartswidget.cc @@ -15,7 +15,6 @@ #include "tools/cabana/utils/strings.h" const int MAX_COLUMN_COUNT = 4; -const int CHART_SPACING = 4; const int START_DRAG_DISTANCE = 10; const float MIN_RANGE_SLIDER_WIDTH = 40.0f; @@ -166,12 +165,11 @@ void ChartsWidget::drawToolBar() { // the labels are captured by reference, they outlive the draw calls below std::vector items; items.push_back({iconButtonWidth(), [this]() { - if (iconButton("new_plot_btn", icon::PLUS_LG, "New Chart")) newChart(); + if (stepButton("new_plot_btn", true, "New Chart")) newChart(); }}); items.push_back({iconButtonWidth(), [this]() { if (iconButton("new_tab_btn", icon::WINDOW_PLUS, "New Tab")) newTab(); }}); - items.back().tight = true; const std::string title_label = "Charts: " + std::to_string(charts_.size()); items.push_back({ImGui::CalcTextSize(title_label.c_str()).x, [&title_label]() { ImGui::AlignTextToFramePadding(); @@ -231,11 +229,11 @@ void ChartsWidget::drawToolBar() { pushMonoFont(ImGui::GetFontSize()); const float reset_zoom_width = iconTextButtonWidth(icon::ZOOM_OUT, widest + "-" + widest); popMonoFont(); - items.push_back({iconButtonWidth() * 2 + ImGui::GetStyle().ItemInnerSpacing.x, [this]() { + items.push_back({iconButtonWidth() * 2 + ImGui::GetStyle().ItemSpacing.x, [this]() { ImGui::BeginDisabled(!zoom_undo_stack_.canUndo()); if (iconButton("undo_zoom", icon::ARROW_COUNTERCLOCKWISE, "Undo Zoom")) zoom_undo_stack_.undo(); ImGui::EndDisabled(); - ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); + ImGui::SameLine(); ImGui::BeginDisabled(!zoom_undo_stack_.canRedo()); if (iconButton("redo_zoom", icon::ARROW_CLOCKWISE, "Redo Zoom")) zoom_undo_stack_.redo(); ImGui::EndDisabled(); @@ -247,12 +245,11 @@ void ChartsWidget::drawToolBar() { if (clicked) zoomReset(); ImGui::SetItemTooltip("Reset Zoom"); }}); - items.back().tight = true; } items.push_back(toolbarAction("remove_all_btn", icon::TRASH, "Remove all charts", [this]() { removeAll(); }, !charts_.empty())); const char *dock_btn_icon = is_docked_ ? icon::BOX_ARROW_UP_RIGHT : icon::BOX_ARROW_IN_DOWN_LEFT; const char *dock_label = is_docked_ ? "Float the charts window" : "Dock the charts window"; - items.push_back(toolbarAction("dock_btn", dock_btn_icon, dock_label, [this]() { toggleChartsDocking(); }, true, true)); + items.push_back(toolbarAction("dock_btn", dock_btn_icon, dock_label, [this]() { toggleChartsDocking(); })); // the slider shrinks first, the buttons stay pinned to the right edge if (slider_index != (size_t)-1) { @@ -356,7 +353,7 @@ void ChartsWidget::updateLayout() { int n = MAX_COLUMN_COUNT; for (; n > 1; --n) { - if ((n * CHART_MIN_WIDTH + (n - 1) * CHART_SPACING) < container_width) break; + if ((n * CHART_MIN_WIDTH + (n - 1) * ImGui::GetStyle().ItemSpacing.x) < container_width) break; } columns_action_visible_ = n > 1; @@ -625,14 +622,14 @@ void ChartsContainer::draw() { charts_widget_->updateLayout(); const int n = std::max(charts_widget_->current_column_count_, 1); - const float spacing = CHART_SPACING; + const float spacing = ImGui::GetStyle().ItemSpacing.x; const float width = (geometry_.GetWidth() - (n - 1) * spacing) / n; - const ImVec2 origin = ImGui::GetCursorScreenPos() + ImVec2(0, CHART_SPACING); + const ImVec2 origin = ImGui::GetCursorScreenPos(); auto current_charts = charts_widget_->currentCharts(); // copy: drawing may remove charts float bottom = origin.y; const bool aligned = ImPlot::BeginAlignedPlots("charts_align", true); for (int i = 0; i < current_charts.size(); ++i) { - ImVec2 pos = origin + ImVec2((i % n) * (width + spacing), (i / n) * (settings.chart_height + spacing)); + ImVec2 pos = origin + ImVec2((i % n) * (width + spacing), (i / n) * (settings.chart_height + ImGui::GetStyle().ItemSpacing.y)); ImGui::SetCursorScreenPos(pos); current_charts[i]->draw(width); bottom = std::max(bottom, pos.y + settings.chart_height); @@ -640,15 +637,15 @@ void ChartsContainer::draw() { } if (aligned) ImPlot::EndAlignedPlots(); ImGui::SetCursorScreenPos(ImVec2(origin.x, bottom)); - ImGui::Dummy(ImVec2(geometry_.GetWidth(), CHART_SPACING)); - geometry_.Max.y = bottom + CHART_SPACING; + ImGui::Dummy(ImVec2(geometry_.GetWidth(), ImGui::GetStyle().ItemSpacing.y)); + geometry_.Max.y = bottom + ImGui::GetStyle().ItemSpacing.y; drawDropIndicator(); } void ChartsContainer::drawDropIndicator() { if (!(drop_indicator_pos_.x == 0 && drop_indicator_pos_.y == 0) && !childAt(drop_indicator_pos_)) { ImRect r = geometry_; - r.Max.y = r.Min.y + CHART_SPACING; + r.Max.y = r.Min.y + ImGui::GetStyle().ItemSpacing.y; if (auto insert_after = getDropAfter(drop_indicator_pos_)) { float h = r.GetHeight(); r.Min.y = insert_after->rect().Max.y; diff --git a/openpilot/tools/cabana/ui/chart/signalselector.cc b/openpilot/tools/cabana/ui/chart/signalselector.cc index 245603d035..1f29a99ac7 100644 --- a/openpilot/tools/cabana/ui/chart/signalselector.cc +++ b/openpilot/tools/cabana/ui/chart/signalselector.cc @@ -41,6 +41,7 @@ bool SignalSelector::draw() { const float lists_h = ImGui::GetContentRegionAvail().y - ImGui::GetFrameHeightWithSpacing() * 3; ImGui::BeginGroup(); + ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Available Signals"); // a combo popup with a filter box const char *preview = msgs_combo_index_ >= 0 ? msgs_combo_[msgs_combo_index_].text.c_str() : "Select a message..."; @@ -79,6 +80,7 @@ bool SignalSelector::draw() { ImGui::SameLine(); ImGui::BeginGroup(); + ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Selected Signals"); bool remove_dbl = false; drawList("##selected_list", selected_list_, &selected_row_, true, &remove_dbl, ImVec2(column_w, lists_h + ImGui::GetFrameHeightWithSpacing())); diff --git a/openpilot/tools/cabana/ui/dialogs/filedialog.cc b/openpilot/tools/cabana/ui/dialogs/filedialog.cc index 7ac420e499..c03dcad205 100644 --- a/openpilot/tools/cabana/ui/dialogs/filedialog.cc +++ b/openpilot/tools/cabana/ui/dialogs/filedialog.cc @@ -188,10 +188,12 @@ void draw() { if (!pending_dir.empty()) setDir(pending_dir); if (s.mode != Mode::Directory) { - ImGui::SetNextItemWidth(-90.0f); + const std::string filter = s.extension.empty() ? "*" : "*" + s.extension; + ImGui::SetNextItemWidth(-(ImGui::CalcTextSize(filter.c_str()).x + ImGui::GetStyle().ItemSpacing.x)); if (inputText("##name", &s.filename, "File name", ImGuiInputTextFlags_EnterReturnsTrue)) ok = true; ImGui::SameLine(); - ImGui::TextDisabled("%s", s.extension.empty() ? "*" : ("*" + s.extension).c_str()); + ImGui::AlignTextToFramePadding(); + ImGui::TextDisabled("%s", filter.c_str()); } const char *accept_label = s.mode == Mode::SaveFile ? "Save" : (s.mode == Mode::Directory ? "Choose" : "Open"); dialogButtons(accept_label, &ok, &cancel); diff --git a/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc b/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc index f67e2f2ad8..96f10cff1a 100644 --- a/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc +++ b/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc @@ -23,7 +23,7 @@ const char *FORM_LABELS[FORM_LABEL_COUNT] = {"Color Theme", "Max Cached Minutes" float formLabelWidth() { float w = 0.0f; for (const char *label : FORM_LABELS) w = std::max(w, ImGui::CalcTextSize(label).x); - return w + ImGui::GetStyle().ItemSpacing.x * 2; // horizontal spacing between label and field + return ImGui::GetCursorPosX() + w + ImGui::GetStyle().ItemSpacing.x; } void formRow(FormLabel label, float label_width) { @@ -34,22 +34,8 @@ void formRow(FormLabel label, float label_width) { } void settingInputInt(const char *id, int *value, int step, int step_fast, int minimum, int maximum) { - const float spacing = ImGui::GetStyle().ItemInnerSpacing.x; - const float width = ImGui::CalcItemWidth(); - ImGui::PushID(id); - ImGui::BeginGroup(); - ImGui::SetNextItemWidth(width - 2 * (iconButtonWidth() + spacing)); - ImGui::InputInt("##value", value, 0); + inputInt((std::string("##") + id).c_str(), value, step, step_fast); *value = std::clamp(*value, minimum, maximum); - const int increment = ImGui::GetIO().KeyCtrl ? step_fast : step; - ImGui::PushItemFlag(ImGuiItemFlags_ButtonRepeat, true); - ImGui::SameLine(0.0f, spacing); - if (iconButton("decrement", icon::DASH_LG)) *value = std::max(minimum, *value - increment); - ImGui::SameLine(0.0f, spacing); - if (iconButton("increment", icon::PLUS_LG)) *value = std::min(maximum, *value + increment); - ImGui::PopItemFlag(); - ImGui::EndGroup(); - ImGui::PopID(); } } // namespace @@ -89,7 +75,7 @@ void SettingsDialog::draw() { checkBox("Enable live stream logging", &log_livestream_); ImGui::BeginDisabled(!log_livestream_); - ImGui::SetNextItemWidth(-90.0f); + ImGui::SetNextItemWidth(-(toolbarButtonWidth("Browse...") + ImGui::GetStyle().ItemSpacing.x)); inputText("##log_path", &log_path_, "", ImGuiInputTextFlags_ReadOnly); ImGui::SameLine(); if (ImGui::Button("Browse...")) { diff --git a/openpilot/tools/cabana/ui/dialogs/streamselector.cc b/openpilot/tools/cabana/ui/dialogs/streamselector.cc index f1d2b1da13..17cae9635c 100644 --- a/openpilot/tools/cabana/ui/dialogs/streamselector.cc +++ b/openpilot/tools/cabana/ui/dialogs/streamselector.cc @@ -18,7 +18,8 @@ void OpenReplayWidget::draw() { ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Route"); ImGui::SameLine(); - ImGui::SetNextItemWidth(-250.0f); + ImGui::SetNextItemWidth(-(toolbarButtonWidth("Remote Route...") + toolbarButtonWidth("Local Route...") + + ImGui::GetStyle().ItemSpacing.x * 2)); inputText("##route", &route_, "Enter a route name or browse for a local or remote route"); ImGui::SameLine(); if (ImGui::Button("Remote Route...")) { @@ -134,7 +135,7 @@ void OpenPandaWidget::draw() { ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Serial"); ImGui::SameLine(); - ImGui::SetNextItemWidth(-100.0f); + ImGui::SetNextItemWidth(-(toolbarButtonWidth("Refresh") + ImGui::GetStyle().ItemSpacing.x)); if (comboBox("##serial", &serial_index_, serials_)) buildConfigForm(); ImGui::SameLine(); if (ImGui::Button("Refresh")) { @@ -228,10 +229,10 @@ void OpenSocketCanWidget::draw() { ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Device"); ImGui::SameLine(); - ImGui::SetNextItemWidth(300.0f); + ImGui::SetNextItemWidth(-(toolbarButtonWidth("Refresh") + ImGui::GetStyle().ItemSpacing.x)); if (comboBox("##device", &device_index_, devices_)) config.device = devices_[device_index_]; ImGui::SameLine(); - if (ImGui::Button("Refresh", ImVec2(100.0f, 0.0f))) refreshDevices(); + if (ImGui::Button("Refresh")) refreshDevices(); } std::unique_ptr OpenSocketCanWidget::open() { @@ -289,7 +290,7 @@ void StreamSelector::draw() { ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("DBC File"); ImGui::SameLine(); - ImGui::SetNextItemWidth(-90.0f); + ImGui::SetNextItemWidth(-(toolbarButtonWidth("Browse...") + ImGui::GetStyle().ItemSpacing.x)); inputText("##dbc", &dbc_file_, "Choose a DBC file to open", ImGuiInputTextFlags_ReadOnly); ImGui::SameLine(); if (ImGui::Button("Browse...")) { diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index 3f8c815e77..343494a03c 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -904,16 +904,18 @@ void MainWindow::drawVideoPanel() { } } // Replay uses a splitter for the gap; live streams use normal item spacing. - if (!charts_floating_ && !live) ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); if (video_h > 0.0f) { ImGui::BeginChild("video", ImVec2(0, video_h), ImGuiChildFlags_Borders); help_overlay_.add(video_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); video_widget_->draw(); ImGui::EndChild(); + // The splitter supplies the pane gap; keep normal spacing inside the video child. + if (!charts_floating_ && !live) ImGui::SetCursorPosY(ImGui::GetCursorPosY() - ImGui::GetStyle().ItemSpacing.y); } else { video_widget_->setVisible(false); // the splitter collapsed the video: stop the vipc thread } if (!charts_floating_ && !live) { + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); ImGui::InvisibleButton("##splitter", ImVec2(-1.0f, splitter_h)); const bool splitter_hovered = ImGui::IsItemHovered() && !live, splitter_active = ImGui::IsItemActive() && !live; if (splitter_active) { diff --git a/openpilot/tools/cabana/ui/theme.cc b/openpilot/tools/cabana/ui/theme.cc index 9a2a6741bc..696a933b82 100644 --- a/openpilot/tools/cabana/ui/theme.cc +++ b/openpilot/tools/cabana/ui/theme.cc @@ -103,9 +103,10 @@ void applyTheme(int theme) { style.WindowBorderSize = 1.0f; style.FrameBorderSize = 1.0f; style.TabBorderSize = 1.0f; - style.WindowPadding = ImVec2(12.0f, 10.0f); - style.FramePadding = ImVec2(9.0f, 5.0f); - style.ItemSpacing = ImVec2(10.0f, 8.0f); + style.WindowPadding = ImVec2(spacing::CONTROL, spacing::CONTROL); + style.FramePadding = ImVec2(spacing::CONTROL, spacing::INNER); + style.ItemSpacing = ImVec2(spacing::CONTROL, spacing::CONTROL); + style.ItemInnerSpacing = ImVec2(spacing::INNER, spacing::INNER); style.CellPadding = ImVec2(6.0f, 4.0f); style.ScrollbarSize = 14.0f; style.GrabMinSize = 13.0f; diff --git a/openpilot/tools/cabana/ui/theme.h b/openpilot/tools/cabana/ui/theme.h index dbd0d97814..020b137c62 100644 --- a/openpilot/tools/cabana/ui/theme.h +++ b/openpilot/tools/cabana/ui/theme.h @@ -32,6 +32,14 @@ inline ImU32 toImU32(const CabanaColor &c) { return IM_COL32(c.r, c.g, c.b, c.a) inline ImVec4 toImVec4(const CabanaColor &c) { return ImVec4(c.r / 255.0f, c.g / 255.0f, c.b / 255.0f, c.a / 255.0f); } inline ImU32 withAlpha(ImU32 c, int alpha) { return (c & ~IM_COL32_A_MASK) | ((ImU32)alpha << IM_COL32_A_SHIFT); } +// Logical pixels. External control gaps are equal on both axes; inner spacing +// is reserved for parts of one control (icon/label, checkbox/label, dropdown arrow). +namespace spacing { +constexpr float CONTROL = 8.0f; +constexpr float INNER = 4.0f; +constexpr float DIALOG_BUTTON_MIN_WIDTH = 80.0f; +} // namespace spacing + constexpr float UI_FONT_SIZE = 16.0f; void loadFonts(); diff --git a/openpilot/tools/cabana/ui/tools/findsignal.cc b/openpilot/tools/cabana/ui/tools/findsignal.cc index c3ce025f4c..734107aa9f 100644 --- a/openpilot/tools/cabana/ui/tools/findsignal.cc +++ b/openpilot/tools/cabana/ui/tools/findsignal.cc @@ -76,12 +76,15 @@ bool FindSignalDlg::draw() { } searching_ = search_future_.valid(); if (begin(ImVec2(900, 650))) { - float group_w = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) / 2; - ImGui::BeginChild("Messages", ImVec2(group_w, 0), ImGuiChildFlags_Borders | ImGuiChildFlags_AutoResizeY); + const ImGuiStyle &style = ImGui::GetStyle(); + const float group_w = (ImGui::GetContentRegionAvail().x - style.ItemSpacing.x) / 2; + const float group_h = ImGui::GetTextLineHeightWithSpacing() + ImGui::GetFrameHeightWithSpacing() * 4 + + style.WindowPadding.y * 2 - style.ItemSpacing.y; + ImGui::BeginChild("Messages", ImVec2(group_w, group_h), ImGuiChildFlags_Borders); drawMessageGroup(); ImGui::EndChild(); ImGui::SameLine(); - ImGui::BeginChild("Signal", ImVec2(group_w, 0), ImGuiChildFlags_Borders | ImGuiChildFlags_AutoResizeY); + ImGui::BeginChild("Signal", ImVec2(group_w, group_h), ImGuiChildFlags_Borders); drawPropertiesGroup(); ImGui::EndChild(); float footer = searched_ ? ImGui::GetTextLineHeightWithSpacing() : 0; @@ -98,20 +101,21 @@ bool FindSignalDlg::draw() { void FindSignalDlg::drawMessageGroup() { ImGui::BeginDisabled(searching_ || !search_.histories.empty()); + const float field_x = ImGui::GetCursorPosX() + ImGui::CalcTextSize("Address").x + ImGui::GetStyle().ItemSpacing.x; ImGui::TextUnformatted("Messages"); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Bus"); - ImGui::SameLine(80); + ImGui::SameLine(field_x); ImGui::SetNextItemWidth(-1); inputText("##bus", &bus_, "Comma-separated values. Leave blank for all."); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Address"); - ImGui::SameLine(80); + ImGui::SameLine(field_x); ImGui::SetNextItemWidth(-1); inputText("##address", &address_, "Comma-separated hex values. Leave blank for all."); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Time"); - ImGui::SameLine(80); + ImGui::SameLine(field_x); ImGui::SetNextItemWidth(70); validatedText("##first_time", &first_time_, validateDouble); ImGui::SameLine(); @@ -126,29 +130,30 @@ void FindSignalDlg::drawMessageGroup() { void FindSignalDlg::drawPropertiesGroup() { ImGui::BeginDisabled(searching_ || !search_.histories.empty()); + const float field_x = ImGui::GetCursorPosX() + ImGui::CalcTextSize("Factor").x + ImGui::GetStyle().ItemSpacing.x; ImGui::TextUnformatted("Signal"); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Size"); - ImGui::SameLine(80); - ImGui::SetNextItemWidth(70); - if (ImGui::InputInt("##min_size", &min_size_, 1, 10)) min_size_ = std::clamp(min_size_, 1, 64); + ImGui::SameLine(field_x); + ImGui::SetNextItemWidth(inputIntWidth(2)); + if (inputInt("##min_size", &min_size_, 1, 10)) min_size_ = std::clamp(min_size_, 1, 64); ImGui::SameLine(); ImGui::TextUnformatted("-"); ImGui::SameLine(); - ImGui::SetNextItemWidth(70); - if (ImGui::InputInt("##max_size", &max_size_, 1, 10)) max_size_ = std::clamp(max_size_, 1, 64); - ImGui::SameLine(); + ImGui::SetNextItemWidth(inputIntWidth(2)); + if (inputInt("##max_size", &max_size_, 1, 10)) max_size_ = std::clamp(max_size_, 1, 64); + ImGui::SetCursorPosX(field_x); checkBox("Little Endian", &little_endian_); ImGui::SameLine(); checkBox("Signed", &is_signed_); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Factor"); - ImGui::SameLine(80); + ImGui::SameLine(field_x); ImGui::SetNextItemWidth(100); validatedText("##factor", &factor_, validateDouble); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Offset"); - ImGui::SameLine(80); + ImGui::SameLine(field_x); ImGui::SetNextItemWidth(100); validatedText("##offset", &offset_, validateDouble); ImGui::EndDisabled(); diff --git a/openpilot/tools/cabana/ui/tools/findsimilarbits.cc b/openpilot/tools/cabana/ui/tools/findsimilarbits.cc index daffc5408c..1761fd0f51 100644 --- a/openpilot/tools/cabana/ui/tools/findsimilarbits.cc +++ b/openpilot/tools/cabana/ui/tools/findsimilarbits.cc @@ -30,9 +30,10 @@ void FindSimilarBitsDlg::updateMessages() { bool FindSimilarBitsDlg::draw() { if (begin(ImVec2(700, 500))) { + const float field_x = ImGui::GetCursorPosX() + ImGui::CalcTextSize("Find From:").x + ImGui::GetStyle().ItemSpacing.x; ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Find From:"); - ImGui::SameLine(90); + ImGui::SameLine(field_x); ImGui::TextUnformatted("Bus"); ImGui::SameLine(); ImGui::SetNextItemWidth(60); @@ -40,20 +41,21 @@ bool FindSimilarBitsDlg::draw() { ImGui::SameLine(); ImGui::SetNextItemWidth(200); comboBox("##msg", &msg_index_, msg_names_); - ImGui::SameLine(); + ImGui::SetCursorPosX(field_x); + ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Byte Index"); ImGui::SameLine(); - ImGui::SetNextItemWidth(80); - if (ImGui::InputInt("##byte_idx", &byte_idx_, 1, 10)) byte_idx_ = std::clamp(byte_idx_, 0, 63); + ImGui::SetNextItemWidth(inputIntWidth(2)); + if (inputInt("##byte_idx", &byte_idx_, 1, 10)) byte_idx_ = std::clamp(byte_idx_, 0, 63); ImGui::SameLine(); ImGui::TextUnformatted("Bit Index"); ImGui::SameLine(); - ImGui::SetNextItemWidth(80); - if (ImGui::InputInt("##bit_idx", &bit_idx_, 1, 10)) bit_idx_ = std::clamp(bit_idx_, 0, 7); + ImGui::SetNextItemWidth(inputIntWidth(1)); + if (inputInt("##bit_idx", &bit_idx_, 1, 10)) bit_idx_ = std::clamp(bit_idx_, 0, 7); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Find In:"); - ImGui::SameLine(90); + ImGui::SameLine(field_x); ImGui::TextUnformatted("Bus"); ImGui::SameLine(); ImGui::SetNextItemWidth(60); @@ -66,8 +68,8 @@ bool FindSimilarBitsDlg::draw() { ImGui::SameLine(); ImGui::TextUnformatted("Minimum Message Count"); ImGui::SameLine(); - ImGui::SetNextItemWidth(80); - if (ImGui::InputInt("##min_msgs", &min_msgs_, 1, 10)) min_msgs_ = std::max(min_msgs_, 0); + ImGui::SetNextItemWidth(inputIntWidth(4)); + if (inputInt("##min_msgs", &min_msgs_, 1, 10)) min_msgs_ = std::max(min_msgs_, 0); ImGui::SameLine(); if (ImGui::Button("Find")) find(); diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index 50553c2d47..69eb94980b 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -71,13 +72,13 @@ bool beginControlChild(const char *id, const ImVec2 &size, ImGuiWindowFlags flag bool clearableInput(const char *label, std::string *s, const char *hint, ImGuiInputTextCallback validator) { const float width = ImGui::CalcItemWidth(); - const float clear_width = iconButtonWidth() + ImGui::GetStyle().ItemInnerSpacing.x; + const float clear_width = iconButtonWidth() + ImGui::GetStyle().ItemSpacing.x; const bool show_clear = !s->empty() && width >= clear_width + ImGui::GetFrameHeight(); ImGui::SetNextItemWidth(show_clear ? width - clear_width : width); ImGui::BeginGroup(); bool changed = validatedInput(label, s, validator, hint); if (show_clear) { - ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); + ImGui::SameLine(); ImGui::PushID(label); if (iconButton("clear", icon::X_LG)) { s->clear(); @@ -205,11 +206,10 @@ bool squareIconButton(const char *id, const char *icon) { ImFontBaked *baked = ImGui::GetFont()->GetFontBaked(size); if (const ImFontGlyph *g = baked->FindGlyph((ImWchar)codepoint)) { const GlyphInk ink = cachedGlyphInk(g, size, codepoint); - // Preserve half-logical-pixel positions on HiDPI displays. - const float snap = std::max(1.0f, ImGui::GetIO().DisplayFramebufferScale.x); - auto snapped = [snap](float v) { return std::round(v * snap) / snap; }; - const ImVec2 pos(snapped(r.GetCenter().x - (ink.x0 + ink.x1) * 0.5f), snapped(r.GetCenter().y - (ink.y0 + ink.y1) * 0.5f)); - // AddText truncates to whole logical pixels, undoing the framebuffer snapping above. + // Keep the exact ink center, including half pixels when glyph and button sizes + // have different parity. Rounding the origin shifts small icons off center. + const ImVec2 pos(r.GetCenter().x - (ink.x0 + ink.x1) * 0.5f, r.GetCenter().y - (ink.y0 + ink.y1) * 0.5f); + // AddText truncates the origin, so draw the atlas glyph directly. ImGui::GetWindowDrawList()->AddImage(ImGui::GetIO().Fonts->TexRef, ImVec2(pos.x + g->X0, pos.y + g->Y0), ImVec2(pos.x + g->X1, pos.y + g->Y1), ImVec2(g->U0, g->V0), ImVec2(g->U1, g->V1), ImGui::GetColorU32(ImGuiCol_Text)); @@ -290,7 +290,8 @@ bool dialogEscapePressed() { bool dialogButtons(const char *accept_label, bool *accepted, bool *rejected, bool accept_enabled, const char *reject_label) { - const float button_width = 80.0f; + const float button_width = std::max({spacing::DIALOG_BUTTON_MIN_WIDTH, toolbarButtonWidth(accept_label), + reject_label ? toolbarButtonWidth(reject_label) : 0.0f}); const int count = reject_label ? 2 : 1; const float total = button_width * count + ImGui::GetStyle().ItemSpacing.x * (count - 1); const float avail = ImGui::GetContentRegionAvail().x; @@ -316,6 +317,52 @@ bool dialogButtons(const char *accept_label, bool *accepted, bool *rejected, boo return pressed; } +float inputIntWidth(int digits) { + const ImGuiStyle &style = ImGui::GetStyle(); + return ImGui::CalcTextSize(std::string(digits, '0').c_str()).x + style.FramePadding.x * 2 + + (ImGui::GetFrameHeight() + style.ItemSpacing.x) * 2; +} + +bool stepButton(const char *id, bool increment, const char *tooltip) { + return iconButton(id, increment ? icon::PLUS_LG : icon::DASH_LG, tooltip); +} + +bool inputInt(const char *label, int *value, int step, int step_fast, ImGuiInputTextFlags flags) { + if (step <= 0) return ImGui::InputInt(label, value, 0, 0, flags); + + const float spacing = ImGui::GetStyle().ItemSpacing.x; + const float width = ImGui::CalcItemWidth(); + ImGui::BeginGroup(); + ImGui::PushID(label); + ImGui::SetNextItemWidth(std::max(1.0f, width - 2 * (iconButtonWidth() + spacing))); + bool changed = ImGui::InputInt("##value", value, 0, 0, flags); + ImGui::BeginDisabled(flags & ImGuiInputTextFlags_ReadOnly); + ImGui::PushItemFlag(ImGuiItemFlags_ButtonRepeat, true); + for (bool increment : {false, true}) { + ImGui::SameLine(0.0f, spacing); + if (stepButton(increment ? "increment" : "decrement", increment)) { + const int amount = ImGui::GetIO().KeyCtrl && step_fast > 0 ? step_fast : step; + const int next = std::clamp(int64_t(*value) + (increment ? int64_t(amount) : -int64_t(amount)), + std::numeric_limits::min(), std::numeric_limits::max()); + if (next != *value) { + *value = next; + changed = true; + } + } + } + ImGui::PopItemFlag(); + ImGui::EndDisabled(); + const char *label_end = ImGui::FindRenderedTextEnd(label); + if (label != label_end) { + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); + ImGui::TextUnformatted(label, label_end); + } + ImGui::PopID(); + ImGui::EndGroup(); + if (changed) ImGui::MarkItemEdited(ImGui::GetItemID()); + return changed; +} + int tableHeadersRow() { int clicked = -1; ImGui::TableNextRow(ImGuiTableRowFlags_Headers); @@ -449,16 +496,16 @@ bool beginDialog(const char *id, PopupOwner *owner, const ImVec2 &size, ImGuiWin // tool bar -ToolbarItem toolbarAction(const char *id, const char *icon, const char *label, std::function trigger, bool enabled, bool tight) { +ToolbarItem toolbarAction(const char *id, const char *icon, const char *label, std::function trigger, bool enabled) { return {iconButtonWidth(), [=]() { ImGui::BeginDisabled(!enabled); if (iconButton(id, icon)) trigger(); ImGui::EndDisabled(); disabledItemTooltip(label); - }, label, trigger, enabled, true, tight}; + }, label, trigger, enabled, true}; } -ToolbarItem toolbarMenu(const char *id, const std::string &text, const char *label, std::function items, bool bold, bool tight, float width) { +ToolbarItem toolbarMenu(const char *id, const std::string &text, const char *label, std::function items, bool bold, float width) { if (width <= 0.0f) width = menuButtonWidth(text, bold); ToolbarItem item{width, [id, text, items, bold, width]() { const std::string popup_id = std::string(id) + "_menu"; @@ -468,7 +515,6 @@ ToolbarItem toolbarMenu(const char *id, const std::string &text, const char *lab ImGui::EndPopup(); } }, label}; - item.tight = tight; item.submenu = std::move(items); return item; } @@ -477,13 +523,9 @@ float toolbarButtonWidth(const std::string &label) { return ImGui::CalcTextSize(label.c_str(), nullptr, true).x + ImGui::GetStyle().FramePadding.x * 2; } -static float toolbarSpacing(const ToolbarItem &item) { - return item.tight ? ImGui::GetStyle().ItemInnerSpacing.x : ImGui::GetStyle().ItemSpacing.x; -} - static float toolbarGroupWidth(const std::vector &items, size_t begin, size_t end) { float w = 0; - for (size_t i = begin; i < end; ++i) w += items[i].width + (i > begin ? toolbarSpacing(items[i]) : 0); + for (size_t i = begin; i < end; ++i) w += items[i].width + (i > begin ? ImGui::GetStyle().ItemSpacing.x : 0); return w; } @@ -512,7 +554,7 @@ void drawToolbar(const std::vector &items, size_t spacer_index, flo const float usable = avail - (extension_width + style.ItemSpacing.x); float used = 0; for (visible = 0; visible < items.size(); ++visible) { - const float w = items[visible].width + (visible ? toolbarSpacing(items[visible]) : 0); + const float w = items[visible].width + (visible ? style.ItemSpacing.x : 0); if (used + w > usable) break; used += w; } @@ -521,7 +563,7 @@ void drawToolbar(const std::vector &items, size_t spacer_index, flo for (size_t i = 0; i < visible; ++i) { if (i == 0) ImGui::SetCursorPosX(start_x); else if (fits && i == spacer_index) ImGui::SameLine(right_edge - right_width); - else ImGui::SameLine(0.0f, toolbarSpacing(items[i])); + else ImGui::SameLine(0.0f, ImGui::GetStyle().ItemSpacing.x); items[i].draw(); } @@ -557,11 +599,10 @@ void drawToolbar(const std::vector &items, size_t spacer_index, flo } const float MENU_ARROW_SIZE = 6.0f; // dropdown arrow on a menu button -const float MENU_ARROW_SPACING = 5.0f; // gap between the label and the dropdown arrow float menuButtonWidth(const std::string &text, bool bold) { if (bold) pushBoldFont(); - const float w = ImGui::CalcTextSize(text.c_str(), nullptr, true).x + MENU_ARROW_SPACING + MENU_ARROW_SIZE + + const float w = ImGui::CalcTextSize(text.c_str(), nullptr, true).x + ImGui::GetStyle().ItemInnerSpacing.x + MENU_ARROW_SIZE + ImGui::GetStyle().FramePadding.x * 2; if (bold) popBoldFont(); return w; @@ -576,7 +617,7 @@ bool menuButton(const char *id, const std::string &text, const char *popup_id, b const float text_width = ImGui::CalcTextSize(text.c_str(), nullptr, true).x; const float ascent = ImGui::GetFontBaked()->Ascent; // the text and the arrow are centered as a group in the button - const float padding_x = std::max(style.FramePadding.x, (width - (text_width + MENU_ARROW_SPACING + MENU_ARROW_SIZE)) * 0.5f); + const float padding_x = std::max(style.FramePadding.x, (width - (text_width + ImGui::GetStyle().ItemInnerSpacing.x + MENU_ARROW_SIZE)) * 0.5f); ImGui::PushStyleColor(ImGuiCol_Button, popup_open ? style.Colors[ImGuiCol_ButtonActive] : style.Colors[ImGuiCol_Button]); ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(padding_x, style.FramePadding.y)); ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.0f, 0.5f)); @@ -588,7 +629,7 @@ bool menuButton(const char *id, const std::string &text, const char *popup_id, b if (bold) popBoldFont(); // a 6 px arrow right after the text, sitting on the text baseline const ImVec2 min = ImGui::GetItemRectMin(); - const float x = min.x + padding_x + text_width + MENU_ARROW_SPACING; + const float x = min.x + padding_x + text_width + ImGui::GetStyle().ItemInnerSpacing.x; const float baseline = min.y + style.FramePadding.y + ascent; ImGui::GetWindowDrawList()->AddTriangleFilled(ImVec2(x, baseline - MENU_ARROW_SIZE * 0.5f), ImVec2(x + MENU_ARROW_SIZE, baseline - MENU_ARROW_SIZE * 0.5f), diff --git a/openpilot/tools/cabana/ui/util.h b/openpilot/tools/cabana/ui/util.h index e1f368e1ac..f431c9a338 100644 --- a/openpilot/tools/cabana/ui/util.h +++ b/openpilot/tools/cabana/ui/util.h @@ -83,6 +83,7 @@ inline std::string shortcut(const char *keys) { return std::string(MOD_KEY) + "+ // Use ItemInnerSpacing between related buttons and ItemSpacing between groups. bool iconButton(const char *id, const char *icon, const char *tooltip = nullptr); float iconButtonWidth(); +bool stepButton(const char *id, bool increment, const char *tooltip = nullptr); bool iconTextButton(const char *id, const char *icon, const std::string &text, float width = 0.0f); float iconTextButtonWidth(const char *icon, const std::string &text); @@ -115,6 +116,12 @@ ImGuiWindow *topPopupWindow(); bool dialogButtons(const char *accept_label, bool *accepted, bool *rejected, bool accept_enabled = true, const char *reject_label = "Cancel"); +// Numeric inputs keep the same external gaps as other button rows. Width includes +// a readable value plus both step buttons; use for compact fixed-width fields. +float inputIntWidth(int digits); +bool inputInt(const char *label, int *value, int step = 1, int step_fast = 100, + ImGuiInputTextFlags flags = ImGuiInputTextFlags_None); + // horizontal header labels are centered. Returns the column a right click was released on, or -1. int tableHeadersRow(); @@ -168,15 +175,14 @@ struct ToolbarItem { std::function trigger; bool enabled = true; bool in_menu = true; // false: left out of the ">>" menu (a separator) - bool tight = false; // true: ItemInnerSpacing before it, it belongs to the previous item's group std::function submenu; // set: the ">>" entry is a submenu with these items instead of an action }; ToolbarItem toolbarAction(const char *id, const char *icon, const char *label, std::function trigger, - bool enabled = true, bool tight = false); + bool enabled = true); // A drop-down button that opens `items` in a popup; in the overflow menu they become a submenu. // width 0: sized to the text. ToolbarItem toolbarMenu(const char *id, const std::string &text, const char *label, std::function items, - bool bold = false, bool tight = false, float width = 0.0f); + bool bold = false, float width = 0.0f); float toolbarButtonWidth(const std::string &label); // the width of every item plus the spacing between neighbors and the two groups float toolbarWidth(const std::vector &items, size_t spacer_index); diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc index cd3d3378a8..39fc68683e 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.cc +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -102,7 +102,7 @@ void DetailWidget::drawToolBar() { const float panel_width = ImGui::GetWindowWidth(); items.push_back(toolbarAction("edit_msg", icon::PENCIL, "Edit Message", [this, panel_width]() { editMsg(panel_width); })); items.push_back(toolbarAction("remove_msg", icon::TRASH, "Remove Message", - [this]() { UndoStack::instance()->push(new RemoveMsgCommand(msg_id_)); }, action_remove_msg_enabled_, true)); + [this]() { UndoStack::instance()->push(new RemoveMsgCommand(msg_id_)); }, action_remove_msg_enabled_)); const float right_width = toolbarWidth(items, spacer_index) - style.ItemSpacing.x; name_width = std::max(ImGui::CalcTextSize("MMMMMM").x, ImGui::GetContentRegionAvail().x - right_width - style.ItemSpacing.x); @@ -221,13 +221,12 @@ void DetailWidget::drawTabWidget() { // binary_view_ keeps its size hint, signal_view_ takes the rest const float min_height = binary_view_->minimumSizeHint().y; const float avail = ImGui::GetContentRegionAvail().y; - const float max_height = std::max(avail - 6.0f - ImGui::GetStyle().ItemSpacing.y * 2 - 1.0f, 1.0f); + const float max_height = std::max(avail - style.ItemSpacing.y - 1.0f, 1.0f); const float height = std::clamp(min_height, 1.0f, max_height); ImGui::BeginChild("binary_view", ImVec2(0, height), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar); binary_view_rect_ = ImGui::GetCurrentWindow()->Rect(); binary_view_->draw(); ImGui::EndChild(); - ImGui::Dummy(ImVec2(0.0f, 6.0f)); ImGui::BeginChild("signal_view", ImVec2(0, 0)); signal_view_rect_ = ImGui::GetCurrentWindow()->Rect(); signal_view_->draw(); @@ -338,7 +337,7 @@ bool EditMessageDialog::draw() { setNextDialogWindow(ImVec2(std::clamp(width_, min_width, max_width), 0.0f)); bool open = true; if (ImGui::BeginPopupModal(window_title_.c_str(), &open)) { - const float label_width = ImGui::CalcTextSize("Comment").x + ImGui::GetStyle().ItemSpacing.x * 2; + const float label_width = ImGui::GetCursorPosX() + ImGui::CalcTextSize("Comment").x + ImGui::GetStyle().ItemSpacing.x; auto row = [&](const char *label) { ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(label); @@ -356,7 +355,7 @@ bool EditMessageDialog::draw() { } row("Size"); - if (ImGui::InputInt("##size", &size_spin_)) size_spin_ = std::clamp(size_spin_, 1, CAN_MAX_DATA_BYTES); + if (inputInt("##size", &size_spin_)) size_spin_ = std::clamp(size_spin_, 1, CAN_MAX_DATA_BYTES); row("Node"); validatedInput("##node", &node_, nameValidator); diff --git a/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc b/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc index 75d7c42788..c35e649148 100644 --- a/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc +++ b/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc @@ -9,7 +9,7 @@ namespace { float scrollButtonsWidth() { const ImGuiStyle &style = ImGui::GetStyle(); - return style.ItemSpacing.x + ImGui::GetFrameHeight() * 2.0f + style.ItemInnerSpacing.x; + return (ImGui::GetFrameHeight() + style.ItemSpacing.x) * 2.0f; } void drawScrollButtons(ImGuiTabBar *tab_bar) { @@ -22,7 +22,7 @@ void drawScrollButtons(ImGuiTabBar *tab_bar) { ImGui::PushItemFlag(ImGuiItemFlags_ButtonRepeat, true); for (int i = 0; i < 2; ++i) { const bool left = i == 0; - ImGui::SetCursorScreenPos(ImVec2(start_x + i * (size + style.ItemInnerSpacing.x), tab_bar->BarRect.Min.y)); + ImGui::SetCursorScreenPos(ImVec2(start_x + i * (size + style.ItemSpacing.x), tab_bar->BarRect.Min.y)); ImGui::BeginDisabled(left ? tab_bar->ScrollingTarget <= 0.0f : tab_bar->ScrollingTarget >= max_scroll); if (ImGui::Button(left ? "###scroll_left" : "###scroll_right", ImVec2(size, size))) { const float step = (left ? -4.0f : 4.0f) * ImGui::GetFontSize(); diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index ef18357184..344bf7a4b3 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -334,15 +334,18 @@ void SignalView::drawEditor(SignalModel::Item *item) { drawLineEditor(item, validator, take_focus); } else if (item->type == SignalModel::Item::Size) { - int v = item->sig->size; - if (take_focus) ImGui::SetKeyboardFocusHere(); - bool changed = ImGui::InputInt("##editor", &v, 1, 100, ImGuiInputTextFlags_AutoSelectAll); + if (take_focus) { + edit_int_ = item->sig->size; + ImGui::SetKeyboardFocusHere(); + } + bool changed = inputInt("##editor", &edit_int_, 1, 100, ImGuiInputTextFlags_AutoSelectAll); if (ImGui::IsItemDeactivated() && ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { open_item_ = nullptr; // InputInt already reverted the value; only the commit has to be skipped return; } if (ImGui::IsItemDeactivatedAfterEdit() || (changed && !ImGui::IsItemActive())) { - queueCommit(item, std::clamp(v, 1, CAN_MAX_DATA_BYTES)); + edit_int_ = std::clamp(edit_int_, 1, CAN_MAX_DATA_BYTES); + queueCommit(item, edit_int_); } // Enter, Escape and a click outside close the editor; the step buttons keep it open if (ImGui::IsItemDeactivated() && (!ImGui::IsItemHovered() || ImGui::IsKeyPressed(ImGuiKey_Enter, false) || @@ -492,7 +495,7 @@ void SignalView::drawValueDescriptionDlg() { } static ImVec2 indexButtonsSize(float button) { - return ImVec2(button * 2 + ImGui::GetStyle().ItemInnerSpacing.x * 2, button); + return ImVec2(button * 2 + ImGui::GetStyle().ItemSpacing.x, button); } SignalView::SignalView(ChartsWidget *charts) : charts_(charts) { @@ -871,7 +874,7 @@ bool SignalView::drawItem(SignalModel::Item *item, int depth, DrawContext &ctx) } void SignalView::drawIndexWidget(SignalModel::Item *item, const ImRect &rect) { - const float spacing = ImGui::GetStyle().ItemInnerSpacing.x; + const float spacing = ImGui::GetStyle().ItemSpacing.x; const ImVec2 size = indexButtonsSize(iconButtonWidth()); ImGui::SetCursorScreenPos(ImVec2(rect.Max.x - size.x, rect.Min.y + (rect.GetHeight() - size.y) * 0.5f)); @@ -910,12 +913,12 @@ bool ValueDescriptionDlg::draw() { if (!ImGui::BeginPopupModal(popup_id.c_str(), &open, ImGuiWindowFlags_NoSavedSettings)) return ImGui::IsPopupOpen(popup_id.c_str()); bool closing = false; - if (iconButton("add", icon::PLUS_LG, "Add")) { + if (stepButton("add", true, "Add")) { table_.emplace_back("", ""); } - ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); + ImGui::SameLine(); ImGui::BeginDisabled(current_row_ == -1); - if (iconButton("remove", icon::DASH_LG, "Remove") && current_row_ < table_.size()) { + if (stepButton("remove", false, "Remove") && current_row_ < table_.size()) { table_.erase(table_.begin() + current_row_); current_row_ = -1; } diff --git a/openpilot/tools/cabana/ui/widgets/signalview.h b/openpilot/tools/cabana/ui/widgets/signalview.h index eaeb6d2aed..dc77ad7b37 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.h +++ b/openpilot/tools/cabana/ui/widgets/signalview.h @@ -190,6 +190,7 @@ private: std::function pending_commit_; SignalModel::Item *editing_item_ = nullptr; // the open text editor std::string edit_text_; + int edit_int_ = 0; bool editor_active_ = false; // editor had the keyboard focus last frame bool refocus_editor_ = false; // reopen the editor rejected by the validator bool enter_pressed_ = false; diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index 5122ca96fa..0896951bab 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -25,8 +25,7 @@ const int MIN_VIDEO_HEIGHT = 100; const int THUMBNAIL_MARGIN = 3; const float POINT_10_FONT_SIZE = 13.0f; // 10 pt at 96 dpi const float POINT_16_FONT_SIZE = 21.0f; // 16 pt at 96 dpi -const float TOOLBAR_MARGIN_Y = 6.0f; // between the slider and the buttons, which are as tall as the ones in the charts toolbar -const float TOOLBAR_SEPARATOR_EXTENT = 6.0f; +constexpr float TOOLBAR_SEPARATOR_EXTENT = 1.0f; const float SLIDER_HEIGHT = 15.0f; // the handle plus a 1 px margin // Indexed by TimelineType: None, Engaged, AlertInfo, AlertWarning, AlertCritical, UserBookmark @@ -138,11 +137,11 @@ std::string VideoWidget::whatsThis() const { "Pause/Resume:  space "; } -static float toolbarHeight() { return TOOLBAR_MARGIN_Y + ImGui::GetFrameHeight(); } +static float toolbarHeight() { return ImGui::GetStyle().ItemSpacing.y + ImGui::GetFrameHeight(); } void VideoWidget::drawPlaybackController() { if (!can->liveStreaming()) - ImGui::SetCursorPosY(ImGui::GetCursorPosY() + TOOLBAR_MARGIN_Y); + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + ImGui::GetStyle().ItemSpacing.y); const float speed_width = menuButtonWidth("0.05x", true); const char *play_icon = can->isPaused() ? icon::PLAY : icon::PAUSE; @@ -156,11 +155,11 @@ void VideoWidget::drawPlaybackController() { if (!can->liveStreaming()) { items.push_back(toolbarAction("rewind", icon::REWIND, "Seek backward", []() { can->seekTo(can->currentSec() - 1); })); } - items.push_back(toolbarAction("play", play_icon, play_tooltip, []() { can->pause(!can->isPaused()); }, true, true)); + items.push_back(toolbarAction("play", play_icon, play_tooltip, []() { can->pause(!can->isPaused()); })); if (can->liveStreaming()) { - items.push_back(toolbarAction("skip-end", icon::SKIP_END, "Go live", [this]() { skipToEnd(); }, skip_to_end_enabled_, true)); + items.push_back(toolbarAction("skip-end", icon::SKIP_END, "Go live", [this]() { skipToEnd(); }, skip_to_end_enabled_)); } else { - items.push_back(toolbarAction("fast-forward", icon::FAST_FORWARD, "Seek forward", []() { can->seekTo(can->currentSec() + 1); }, true, true)); + items.push_back(toolbarAction("fast-forward", icon::FAST_FORWARD, "Seek forward", []() { can->seekTo(can->currentSec() + 1); })); } if (slider_ || msgs_received_) { // a mono font: with proportional digits the time changed width as it ticked and the items after it moved @@ -186,20 +185,21 @@ void VideoWidget::drawPlaybackController() { const ImVec2 min = ImGui::GetCursorScreenPos(); ImGui::Dummy(ImVec2(TOOLBAR_SEPARATOR_EXTENT, ImGui::GetFrameHeight())); const float x = std::floor(min.x + TOOLBAR_SEPARATOR_EXTENT * 0.5f); - ImGui::GetWindowDrawList()->AddLine(ImVec2(x, min.y + 4.0f), ImVec2(x, min.y + ImGui::GetFrameHeight() - 4.0f), ImGui::GetColorU32(ImGuiCol_Separator)); + const float inset = ImGui::GetStyle().FramePadding.y; + ImGui::GetWindowDrawList()->AddLine(ImVec2(x, min.y + inset), ImVec2(x, min.y + ImGui::GetFrameHeight() - inset), + ImGui::GetColorU32(ImGuiCol_Separator)); }}; item.in_menu = false; - item.tight = true; return item; }; const char *aspect_ratio_icon = settings.crop_video ? icon::ASPECT_RATIO_FILL : icon::ASPECT_RATIO; if (!can->liveStreaming()) { items.push_back(toolbarAction("crop_video", aspect_ratio_icon, "Crop to fill", [this]() { cropVideoClicked(); })); items.push_back(separator()); - items.push_back(toolbarAction("loop", loop_icon, "Loop playback", [this]() { loopPlaybackClicked(); }, true, true)); - items.push_back(toolbarMenu("speed_btn", speed_text_, "Speed", [this]() { drawSpeedMenuItems(); }, true, true, speed_width)); + items.push_back(toolbarAction("loop", loop_icon, "Loop playback", [this]() { loopPlaybackClicked(); })); + items.push_back(toolbarMenu("speed_btn", speed_text_, "Speed", [this]() { drawSpeedMenuItems(); }, true, speed_width)); items.push_back(separator()); - items.push_back(toolbarAction("route_info", icon::INFO_CIRCLE, "View route details", [this]() { showRouteInfo(); }, true, true)); + items.push_back(toolbarAction("route_info", icon::INFO_CIRCLE, "View route details", [this]() { showRouteInfo(); })); } drawToolbar(items, spacer_index); @@ -275,16 +275,20 @@ void VideoWidget::createCameraWidget() { } void VideoWidget::drawCameraWidget() { + const float toolbar_height = toolbarHeight(); + // Camera tabs, video and timeline touch; restore the normal gap for the controls. + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); camera_tab_->draw(); // cam_widget_: minimum height MIN_VIDEO_HEIGHT, takes the space left by the slider and the toolbar const ImVec2 avail = ImGui::GetContentRegionAvail(); - const float cam_height = std::max((float)MIN_VIDEO_HEIGHT, avail.y - SLIDER_HEIGHT - toolbarHeight()); + const float cam_height = std::max((float)MIN_VIDEO_HEIGHT, avail.y - SLIDER_HEIGHT - toolbar_height); cam_widget_->draw(ImVec2(avail.x, cam_height), thumbnail_display_time_); if (!slider_->isSliderDown()) slider_->setCurrentSecond(can->currentSec()); slider_->draw(thumbnail_display_time_); updateSliderThumbnail(); + ImGui::PopStyleVar(); } void VideoWidget::vipcAvailableStreamsUpdated(std::set streams) { @@ -364,12 +368,10 @@ float VideoWidget::defaultHeight(float width) const { } void VideoWidget::draw() { - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); if (!can->liveStreaming()) drawCameraWidget(); drawPlaybackController(); - ImGui::PopStyleVar(); for (auto it = route_info_dlgs_.begin(); it != route_info_dlgs_.end();) { it = (*it)->draw() ? it + 1 : route_info_dlgs_.erase(it); From 20064d9681c6828d482e91de59add4e8e00ecdcf Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:46:51 -0700 Subject: [PATCH 085/122] cabana: show placeholder for unresolved timestamps (#38870) --- openpilot/tools/cabana/ui/widgets/videowidget.cc | 15 ++++++++++----- openpilot/tools/cabana/ui/widgets/videowidget.h | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index 0896951bab..7860a95138 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -147,8 +147,13 @@ void VideoWidget::drawPlaybackController() { const char *play_icon = can->isPaused() ? icon::PLAY : icon::PAUSE; const char *play_tooltip = can->isPaused() ? "Play" : "Pause"; const char *loop_icon = getReplay() && getReplay()->loop() ? icon::REPEAT : icon::REPEAT_1; - const std::string time_text = slider_ ? formatTime(can->currentSec(), true) + " / " + formatTime(slider_->maximum() / slider_->factor) - : formatTime(can->currentSec(), true); + const bool timestamps_resolved = can->liveStreaming() + ? msgs_received_ + : can->beginDateTime() != std::chrono::system_clock::time_point{}; + const std::string time_text = timestamps_resolved + ? (slider_ ? formatTime(can->currentSec(), true) + " / " + formatTime(slider_->maximum() / slider_->factor) + : formatTime(can->currentSec(), true)) + : "--:--.-- / --:--.--"; const char *time_tooltip = settings.absolute_time ? "Elapsed time" : "Absolute time"; std::vector items; @@ -161,14 +166,14 @@ void VideoWidget::drawPlaybackController() { } else { items.push_back(toolbarAction("fast-forward", icon::FAST_FORWARD, "Seek forward", []() { can->seekTo(can->currentSec() + 1); })); } - if (slider_ || msgs_received_) { + if (slider_ || timestamps_resolved) { // a mono font: with proportional digits the time changed width as it ticked and the items after it moved - pushMonoFont(ImGui::GetFontSize()); + pushMonoFont(ImGui::GetStyle().FontSizeBase); const float time_width = ImGui::CalcTextSize(time_text.c_str()).x; popMonoFont(); items.push_back({time_width, [&]() { - pushMonoFont(ImGui::GetFontSize()); + pushMonoFont(ImGui::GetStyle().FontSizeBase); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(time_text.c_str()); popMonoFont(); diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.h b/openpilot/tools/cabana/ui/widgets/videowidget.h index 1abdbdbe71..94b50dbf3f 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.h +++ b/openpilot/tools/cabana/ui/widgets/videowidget.h @@ -112,7 +112,7 @@ private: std::string speed_text_; int speed_index_ = -1; // checked entry of the speed menu bool skip_to_end_enabled_ = true; - bool msgs_received_ = false; // the time is blank until the live stream delivers its first messages + bool msgs_received_ = false; // live-stream timestamps resolve when the first messages arrive double thumbnail_display_time_ = -1; std::unique_ptr slider_; std::unique_ptr camera_tab_; From 1aa97338c470bef73c30fdb520f655bdff1f4c21 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:56:08 -0700 Subject: [PATCH 086/122] Cabana: standardize floating dropdown menus (#38874) * Cabana: standardize floating dropdown menus * Cabana: use uniform items in menus and combo lists * Cabana: remove dropdown test and explanatory comments --- openpilot/tools/cabana/ui/chart/chart.cc | 30 ++--- .../tools/cabana/ui/chart/chartswidget.cc | 10 +- .../tools/cabana/ui/chart/signalselector.cc | 6 +- .../tools/cabana/ui/dialogs/routesdialog.cc | 2 +- .../tools/cabana/ui/dialogs/settingsdialog.cc | 4 +- openpilot/tools/cabana/ui/dropdown.h | 90 +++++++++++++ openpilot/tools/cabana/ui/mainwin.cc | 123 +++++++----------- openpilot/tools/cabana/ui/tools/findsignal.cc | 8 +- .../tools/cabana/ui/tools/findsimilarbits.cc | 2 +- openpilot/tools/cabana/ui/util.cc | 32 ++--- openpilot/tools/cabana/ui/util.h | 12 +- .../tools/cabana/ui/widgets/detailwidget.cc | 10 +- .../tools/cabana/ui/widgets/historylog.cc | 6 +- .../tools/cabana/ui/widgets/messageswidget.cc | 10 +- .../tools/cabana/ui/widgets/signalview.cc | 2 +- .../tools/cabana/ui/widgets/videowidget.cc | 9 +- 16 files changed, 195 insertions(+), 161 deletions(-) create mode 100644 openpilot/tools/cabana/ui/dropdown.h diff --git a/openpilot/tools/cabana/ui/chart/chart.cc b/openpilot/tools/cabana/ui/chart/chart.cc index 41256845c1..060bc91541 100644 --- a/openpilot/tools/cabana/ui/chart/chart.cc +++ b/openpilot/tools/cabana/ui/chart/chart.cc @@ -49,20 +49,14 @@ ChartView::ChartView(const std::pair &x_range, ChartsWidget *par } void ChartView::drawMenuActions() { - // the current series type is marked with a radio bullet on the left - const float indent = ImGui::GetFontSize(); - float label_width = ImGui::CalcTextSize("Manage Signals").x; - for (const char *type : SERIES_TYPE_NAMES) label_width = std::max(label_width, ImGui::CalcTextSize(type).x); for (int i = 0; i < (int)std::size(SERIES_TYPE_NAMES); ++i) { - if (radioMenuItem(SERIES_TYPE_NAMES[i], i == (int)series_type_, indent + label_width + indent)) { + if (dropdown::Item(SERIES_TYPE_NAMES[i], nullptr, i == (int)series_type_)) { setSeriesType((SeriesType)i); } } ImGui::Separator(); - ImGui::Indent(indent); - if (ImGui::MenuItem("Manage Signals")) manageSignals(); - if (ImGui::MenuItem("Split Chart", nullptr, false, sigs_.size() > 1)) charts_widget_->splitChart(this); - ImGui::Unindent(indent); + if (dropdown::Item("Manage Signals")) manageSignals(); + if (dropdown::Item("Split Chart", nullptr, false, sigs_.size() > 1)) charts_widget_->splitChart(this); } // the buttons and their menus are drawn every frame, at the rects updateLayout() placed them at @@ -72,9 +66,9 @@ void ChartView::createToolButtons() { ImGui::SetCursorScreenPos(layout_.manage_btn_rect.Min); if (iconButton("manage_btn", icon::THREE_DOTS_VERTICAL, "")) ImGui::OpenPopup("manage_menu"); - if (ImGui::BeginPopup("manage_menu")) { + if (dropdown::BeginPopup("manage_menu")) { drawMenuActions(); - ImGui::EndPopup(); + dropdown::EndPopup(); } if (close_clicked) charts_widget_->removeChart(this); @@ -331,23 +325,19 @@ void ChartView::drawContextMenu() { ImGui::OpenPopup("context_menu"); } context_menu_id_ = ImGui::GetID("context_menu"); - if (ImGui::BeginPopup("context_menu")) { + if (dropdown::BeginPopup("context_menu")) { drawMenuActions(); - // the menu holds checkable entries, so every entry keeps the same left margin - const float indent = ImGui::GetFontSize(); - ImGui::Indent(indent); ImGui::Separator(); // the zoom entries come from the toolbar, where they are only visible while zoomed if (can->timeRange().has_value()) { const std::string undo_text = std::string(icon::ARROW_COUNTERCLOCKWISE) + " Undo Zoom"; const std::string redo_text = std::string(icon::ARROW_CLOCKWISE) + " Redo Zoom"; - if (ImGui::MenuItem(undo_text.c_str(), nullptr, false, charts_widget_->zoom_undo_stack_.canUndo())) charts_widget_->zoom_undo_stack_.undo(); - if (ImGui::MenuItem(redo_text.c_str(), nullptr, false, charts_widget_->zoom_undo_stack_.canRedo())) charts_widget_->zoom_undo_stack_.redo(); + if (dropdown::Item(undo_text.c_str(), nullptr, false, charts_widget_->zoom_undo_stack_.canUndo())) charts_widget_->zoom_undo_stack_.undo(); + if (dropdown::Item(redo_text.c_str(), nullptr, false, charts_widget_->zoom_undo_stack_.canRedo())) charts_widget_->zoom_undo_stack_.redo(); ImGui::Separator(); } - if (ImGui::MenuItem("Close")) charts_widget_->removeChart(this); - ImGui::Unindent(indent); - ImGui::EndPopup(); + if (dropdown::Item("Close")) charts_widget_->removeChart(this); + dropdown::EndPopup(); } } diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.cc b/openpilot/tools/cabana/ui/chart/chartswidget.cc index 94a5fa81b2..8052931885 100644 --- a/openpilot/tools/cabana/ui/chart/chartswidget.cc +++ b/openpilot/tools/cabana/ui/chart/chartswidget.cc @@ -43,13 +43,13 @@ ChartsWidget::ChartsWidget() { connections_.push_back(seriesChanged.connect([this]() { updateTabBar(); })); connections_.push_back(tabbar_.tabCloseRequested.connect([this](int index) { removeTab(index); })); connections_.push_back(tabbar_.tabContextMenu.connect([this](int index) { - if (ImGui::BeginPopupContextItem()) { - if (ImGui::MenuItem("Close Other Tabs")) { + if (dropdown::BeginPopupContextItem()) { + if (dropdown::Item("Close Other Tabs")) { tabbar_.moveTab(index, 0); tabbar_.setCurrentIndex(0); while (tabbar_.count() > 1) removeTab(1); } - ImGui::EndPopup(); + dropdown::EndPopup(); } })); connections_.push_back(tabbar_.currentChanged.connect([this](int index) { @@ -180,7 +180,7 @@ void ChartsWidget::drawToolBar() { const std::string chart_type_text = std::string("Type: ") + SERIES_TYPE_NAMES[std::clamp(settings.chart_series_type, 0, type_count - 1)]; auto chart_type_items = [this]() { for (int i = 0; i < type_count; ++i) { - if (ImGui::MenuItem(SERIES_TYPE_NAMES[i], nullptr, settings.chart_series_type == i)) { + if (dropdown::Item(SERIES_TYPE_NAMES[i], nullptr, settings.chart_series_type == i)) { settings.chart_series_type = i; settingChanged(); } @@ -192,7 +192,7 @@ void ChartsWidget::drawToolBar() { if (columns_action_visible_) { auto column_items = [this]() { for (int i = 0; i < MAX_COLUMN_COUNT; ++i) { - if (ImGui::MenuItem(std::to_string(i + 1).c_str(), nullptr, column_count_ == i + 1)) setColumnCount(i + 1); + if (dropdown::Item(std::to_string(i + 1).c_str(), nullptr, column_count_ == i + 1)) setColumnCount(i + 1); } }; items.push_back(toolbarMenu("columns", columns_action_text, "Columns", column_items)); diff --git a/openpilot/tools/cabana/ui/chart/signalselector.cc b/openpilot/tools/cabana/ui/chart/signalselector.cc index 1f29a99ac7..2ea6d70121 100644 --- a/openpilot/tools/cabana/ui/chart/signalselector.cc +++ b/openpilot/tools/cabana/ui/chart/signalselector.cc @@ -46,7 +46,7 @@ bool SignalSelector::draw() { // a combo popup with a filter box const char *preview = msgs_combo_index_ >= 0 ? msgs_combo_[msgs_combo_index_].text.c_str() : "Select a message..."; ImGui::SetNextItemWidth(column_w); - if (ImGui::BeginCombo("##msgs_combo", preview)) { + if (dropdown::BeginCombo("##msgs_combo", preview)) { if (ImGui::IsWindowAppearing()) { msgs_combo_filter_.clear(); // reopen showing the full list ImGui::SetKeyboardFocusHere(); @@ -55,13 +55,13 @@ bool SignalSelector::draw() { inputText("##msgs_filter", &msgs_combo_filter_, "Select a message..."); for (int i = 0; i < (int)msgs_combo_.size(); ++i) { if (!msgs_combo_filter_.empty() && !utils::containsCI(msgs_combo_[i].text, msgs_combo_filter_)) continue; - if (selectable(msgs_combo_[i].text.c_str(), i == msgs_combo_index_)) { + if (dropdown::Item(msgs_combo_[i].text.c_str(), nullptr, i == msgs_combo_index_)) { msgs_combo_index_ = i; updateAvailableList(i); ImGui::CloseCurrentPopup(); } } - ImGui::EndCombo(); + dropdown::EndCombo(); } bool add_dbl = false; drawList("##available_list", available_list_, &available_row_, false, &add_dbl, ImVec2(column_w, lists_h)); diff --git a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc index 9e21464985..f0a5eb0b9e 100644 --- a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc +++ b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc @@ -95,7 +95,7 @@ void RoutesDialog::draw() { ImGui::EndDisabled(); } ImGui::SetNextItemWidth(-1.0f); - if (ImGui::Combo("##period", &s_.period_index, PERIOD_NAMES, IM_ARRAYSIZE(PERIOD_NAMES))) fetchRoutes(); + if (dropdown::Combo("##period", &s_.period_index, PERIOD_NAMES, IM_ARRAYSIZE(PERIOD_NAMES))) fetchRoutes(); bool accepted = false, rejected = false; const float footer = ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y; diff --git a/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc b/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc index 96f10cff1a..c8dd4fb665 100644 --- a/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc +++ b/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc @@ -60,14 +60,14 @@ void SettingsDialog::draw() { static const char *themes[] = {"Light", "Dark"}; formRow(THEME, label_width); int theme_index = theme_ - LIGHT_THEME; - if (ImGui::Combo("##theme", &theme_index, themes, IM_ARRAYSIZE(themes))) theme_ = theme_index + LIGHT_THEME; + if (dropdown::Combo("##theme", &theme_index, themes, IM_ARRAYSIZE(themes))) theme_ = theme_index + LIGHT_THEME; formRow(CACHED_MINUTES, label_width); settingInputInt("cached_minutes", &cached_minutes_, 1, 10, MIN_CACHE_MINUTES, MAX_CACHE_MINUTES); ImGui::SeparatorText("New Signal Settings"); static const char *directions[] = {"MSB First", "LSB First", "Always Little Endian", "Always Big Endian"}; formRow(DRAG_DIRECTION, label_width); - ImGui::Combo("##drag_direction", &drag_direction_, directions, IM_ARRAYSIZE(directions)); + dropdown::Combo("##drag_direction", &drag_direction_, directions, IM_ARRAYSIZE(directions)); ImGui::SeparatorText("Chart"); formRow(CHART_HEIGHT, label_width); diff --git a/openpilot/tools/cabana/ui/dropdown.h b/openpilot/tools/cabana/ui/dropdown.h new file mode 100644 index 0000000000..dc46d55252 --- /dev/null +++ b/openpilot/tools/cabana/ui/dropdown.h @@ -0,0 +1,90 @@ +#pragma once + +#include +#include + +#include "tools/cabana/ui/theme.h" + +namespace dropdown { +constexpr float PADDING_X = 9.0f; +constexpr float PADDING_Y = 6.0f; +constexpr float SPACING_X = 10.0f; +constexpr float SPACING_Y = 8.0f; +constexpr float ROUNDING = 6.0f; +constexpr float BORDER = 1.0f; +constexpr int WINDOW_STYLE_VARS = 3; + +inline void pushStyle() { + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(PADDING_X, PADDING_Y)); + ImGui::PushStyleVar(ImGuiStyleVar_PopupRounding, ROUNDING); + ImGui::PushStyleVar(ImGuiStyleVar_PopupBorderSize, BORDER); +} + +inline bool finishBegin(bool open) { + if (open) ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(SPACING_X, SPACING_Y)); + else ImGui::PopStyleVar(WINDOW_STYLE_VARS); + return open; +} +inline bool BeginPopup(const char *id, ImGuiWindowFlags flags = 0) { + pushStyle(); + return finishBegin(ImGui::BeginPopup(id, flags)); +} +inline bool BeginPopupContextItem(const char *id = nullptr, ImGuiPopupFlags flags = ImGuiPopupFlags_MouseButtonRight) { + pushStyle(); + return finishBegin(ImGui::BeginPopupContextItem(id, flags)); +} +inline void EndPopup() { + ImGui::PopStyleVar(); + ImGui::EndPopup(); + ImGui::PopStyleVar(WINDOW_STYLE_VARS); +} +inline bool BeginMenu(const char *label, bool enabled = true) { + pushStyle(); + return finishBegin(ImGui::BeginMenu(label, enabled)); +} +inline void EndMenu() { + ImGui::PopStyleVar(); + ImGui::EndMenu(); + ImGui::PopStyleVar(WINDOW_STYLE_VARS); +} +inline bool BeginCombo(const char *label, const char *preview, ImGuiComboFlags flags = 0) { + pushStyle(); + return finishBegin(ImGui::BeginCombo(label, preview, flags)); +} +inline void EndCombo() { + ImGui::PopStyleVar(); + ImGui::EndCombo(); + ImGui::PopStyleVar(WINDOW_STYLE_VARS); +} + +inline bool Item(const char *label, const char *shortcut = nullptr, bool selected = false, bool enabled = true) { + return ImGui::MenuItem(label, shortcut, selected, enabled); +} +inline bool Item(const char *label, const char *shortcut, bool *selected, bool enabled = true) { + if (!Item(label, shortcut, selected && *selected, enabled)) return false; + if (selected) *selected = !*selected; + return true; +} + +inline bool Combo(const char *label, int *index, const char *const items[], int count) { + bool changed = false; + if (BeginCombo(label, *index >= 0 && *index < count ? items[*index] : "")) { + for (int i = 0; i < count; ++i) { + ImGui::PushID(i); + if (Item(items[i], nullptr, i == *index) && i != *index) { + *index = i; + changed = true; + } + if (i == *index) ImGui::SetItemDefaultFocus(); + ImGui::PopID(); + } + EndCombo(); + } + return changed; +} +inline bool Combo(const char *label, int *index, const char *items) { + std::vector labels; + for (const char *item = items; *item; item += std::strlen(item) + 1) labels.push_back(item); + return Combo(label, index, labels.data(), labels.size()); +} +} // namespace dropdown diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index 343494a03c..0683116424 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -91,70 +91,47 @@ void MainWindow::loadFingerprints() { void MainWindow::drawFileMenu() { const bool has_stream = hasStream(); - if (ImGui::MenuItem("Open Stream...")) selectAndOpenStream(); - if (ImGui::MenuItem("Close Stream", nullptr, false, has_stream)) closeStream(); - if (ImGui::MenuItem("Export to CSV...", nullptr, false, has_stream)) exportToCSV(); + if (dropdown::Item("Open Stream...")) selectAndOpenStream(); + if (dropdown::Item("Close Stream", nullptr, false, has_stream)) closeStream(); + if (dropdown::Item("Export to CSV...", nullptr, false, has_stream)) exportToCSV(); ImGui::Separator(); - if (ImGui::MenuItem("New DBC File", shortcut("N").c_str())) newFile(); - if (ImGui::MenuItem("Open DBC File...", shortcut("O").c_str())) openFile(); + if (dropdown::Item("New DBC File", shortcut("N").c_str())) newFile(); + if (dropdown::Item("Open DBC File...", shortcut("O").c_str())) openFile(); - if (ImGui::BeginMenu("Manage DBC Files", has_stream)) { + if (dropdown::BeginMenu("Manage DBC Files", has_stream)) { drawManageDBCsMenu(); - ImGui::EndMenu(); + dropdown::EndMenu(); } - if (ImGui::BeginMenu("Open Recent")) { + if (dropdown::BeginMenu("Open Recent")) { drawRecentFilesMenu(); - ImGui::EndMenu(); + dropdown::EndMenu(); } ImGui::Separator(); - if (ImGui::BeginMenu("Load DBC from commaai/opendbc")) { + if (dropdown::BeginMenu("Load DBC from commaai/opendbc")) { for (const auto &name : opendbc_names_) { - if (ImGui::MenuItem(name.c_str())) loadDBCFromOpendbc(name); + if (dropdown::Item(name.c_str())) loadDBCFromOpendbc(name); } - ImGui::EndMenu(); + dropdown::EndMenu(); } - if (ImGui::MenuItem("Load DBC from Clipboard")) loadFromClipboard(); + if (dropdown::Item("Load DBC from Clipboard")) loadFromClipboard(); ImGui::Separator(); const int cnt = dbc()->nonEmptyDBCCount(); const std::string save_text = cnt > 1 ? "Save " + std::to_string(cnt) + " DBCs..." : "Save DBC..."; - if (ImGui::MenuItem(save_text.c_str(), shortcut("S").c_str(), false, cnt > 0)) save(); - if (ImGui::MenuItem("Save DBC As...", shortcut("Shift+S").c_str(), false, cnt == 1)) saveAs(); + if (dropdown::Item(save_text.c_str(), shortcut("S").c_str(), false, cnt > 0)) save(); + if (dropdown::Item("Save DBC As...", shortcut("Shift+S").c_str(), false, cnt == 1)) saveAs(); // TODO: Support clipboard for multiple files - if (ImGui::MenuItem("Copy DBC to Clipboard", nullptr, false, cnt == 1)) saveToClipboard(); + if (dropdown::Item("Copy DBC to Clipboard", nullptr, false, cnt == 1)) saveToClipboard(); ImGui::Separator(); - if (ImGui::MenuItem("Settings...")) openSettings(); + if (dropdown::Item("Settings...")) openSettings(); ImGui::Separator(); - if (ImGui::MenuItem("Exit", shortcut("Q").c_str())) close(); + if (dropdown::Item("Exit", shortcut("Q").c_str())) close(); } -namespace { -bool beginTopMenu(const char *label, bool enabled = true) { - ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImGui::GetColorU32(ImGuiCol_Header)); - const bool open = ImGui::BeginMenu(label, enabled); - ImGui::PopStyleColor(); - if (open) { - // Erase the popup's rounded top border so it joins the menu bar's separator. - const ImGuiStyle &style = ImGui::GetStyle(); - const ImGuiWindow *w = ImGui::GetCurrentWindow(); - const float r = style.PopupRounding, b = style.PopupBorderSize; - const ImVec2 min = w->Pos, max(w->Pos.x + w->Size.x, w->Pos.y + w->Size.y); - const ImU32 bg = ImGui::GetColorU32(ImGuiCol_PopupBg), border = ImGui::GetColorU32(ImGuiCol_Border); - ImDrawList *dl = w->DrawList; - dl->PushClipRect(min, max, false); // the window's own clip rect excludes its border - dl->AddRectFilled(min, ImVec2(max.x, min.y + r), bg); - dl->AddRectFilled(ImVec2(min.x, min.y), ImVec2(min.x + b, min.y + r), border); - dl->AddRectFilled(ImVec2(max.x - b, min.y), ImVec2(max.x, min.y + r), border); - dl->PopClipRect(); - } - return open; -} -} // namespace - void MainWindow::drawMenuBar() { // Avoid a double border with the separator drawn below. ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); @@ -166,43 +143,43 @@ void MainWindow::drawMenuBar() { const ImVec2 max(min.x + ImGui::GetWindowWidth(), min.y + ImGui::GetWindowHeight()); ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(min.x, max.y - 1.0f), max, ImGui::GetColorU32(ImGuiCol_Border)); } - if (beginTopMenu("File")) { + if (dropdown::BeginMenu("File")) { drawFileMenu(); - ImGui::EndMenu(); + dropdown::EndMenu(); } - if (beginTopMenu("Edit")) { + if (dropdown::BeginMenu("Edit")) { auto stack = UndoStack::instance(); const std::string undo_text = stack->canUndo() ? "Undo " + stack->undoText() : "Undo"; const std::string redo_text = stack->canRedo() ? "Redo " + stack->redoText() : "Redo"; - if (ImGui::MenuItem(undo_text.c_str(), shortcut("Z").c_str(), false, stack->canUndo())) stack->undo(); - if (ImGui::MenuItem(redo_text.c_str(), shortcut("Shift+Z").c_str(), false, stack->canRedo())) stack->redo(); - ImGui::EndMenu(); + if (dropdown::Item(undo_text.c_str(), shortcut("Z").c_str(), false, stack->canUndo())) stack->undo(); + if (dropdown::Item(redo_text.c_str(), shortcut("Shift+Z").c_str(), false, stack->canRedo())) stack->redo(); + dropdown::EndMenu(); } - if (beginTopMenu("View")) { - if (ImGui::MenuItem("Full Screen", shortcut("F11").c_str())) toggleFullScreen(); + if (dropdown::BeginMenu("View")) { + if (dropdown::Item("Full Screen", shortcut("F11").c_str())) toggleFullScreen(); ImGui::Separator(); - ImGui::MenuItem(messages_widget_ ? messages_widget_->title().c_str() : "MESSAGES", nullptr, &messages_visible_); - ImGui::MenuItem(video_dock_title_.empty() ? "Video" : video_dock_title_.c_str(), nullptr, &video_visible_); + dropdown::Item(messages_widget_ ? messages_widget_->title().c_str() : "MESSAGES", nullptr, &messages_visible_); + dropdown::Item(video_dock_title_.empty() ? "Video" : video_dock_title_.c_str(), nullptr, &video_visible_); ImGui::Separator(); - if (ImGui::MenuItem("Reset Window Layout")) { + if (dropdown::Item("Reset Window Layout")) { messages_visible_ = video_visible_ = true; video_splitter_ratio_ = -1.0f; reset_layout_ = true; } - ImGui::EndMenu(); + dropdown::EndMenu(); } - if (beginTopMenu("Tools", hasStream())) { - if (ImGui::MenuItem("Find Similar Bits")) findSimilarBits(); - if (ImGui::MenuItem("Find Signal")) findSignal(); - ImGui::EndMenu(); + if (dropdown::BeginMenu("Tools", hasStream())) { + if (dropdown::Item("Find Similar Bits")) findSimilarBits(); + if (dropdown::Item("Find Signal")) findSignal(); + dropdown::EndMenu(); } - if (beginTopMenu("Help")) { - if (ImGui::MenuItem("Help", "F1")) toggleHelp(); - ImGui::EndMenu(); + if (dropdown::BeginMenu("Help")) { + if (dropdown::Item("Help", "F1")) toggleHelp(); + dropdown::EndMenu(); } ImGui::EndMainMenuBar(); } @@ -527,22 +504,22 @@ void MainWindow::drawManageDBCsMenu() { auto dbc_file = dbc()->findDBCFile(source); const std::string title = "Bus " + std::to_string(source) + " (" + (dbc_file ? dbc_file->name() : "No DBCs loaded") + ")"; ImGui::PushID(source); - if (ImGui::BeginMenu(title.c_str())) { - if (ImGui::MenuItem("New DBC File")) newFile(ss); - if (ImGui::MenuItem("Open DBC File...")) openFile(ss); - if (ImGui::MenuItem("Load DBC from Clipboard")) loadFromClipboard(ss, false); + if (dropdown::BeginMenu(title.c_str())) { + if (dropdown::Item("New DBC File")) newFile(ss); + if (dropdown::Item("Open DBC File...")) openFile(ss); + if (dropdown::Item("Load DBC from Clipboard")) loadFromClipboard(ss, false); // Show sub-menu for each dbc for this source. if (dbc_file) { ImGui::Separator(); - ImGui::MenuItem((dbc_file->name() + " (" + toString(dbc()->sources(dbc_file)) + ")").c_str(), nullptr, false, false); - if (ImGui::MenuItem("Save...")) saveFile(dbc_file); - if (ImGui::MenuItem("Save As...")) saveFileAs(dbc_file); - if (ImGui::MenuItem("Copy to Clipboard")) saveFileToClipboard(dbc_file); - if (ImGui::MenuItem("Remove from This Bus...")) closeFile(ss, {}); - if (ImGui::MenuItem("Remove from All Buses...")) closeFile(dbc_file); + dropdown::Item((dbc_file->name() + " (" + toString(dbc()->sources(dbc_file)) + ")").c_str(), nullptr, false, false); + if (dropdown::Item("Save...")) saveFile(dbc_file); + if (dropdown::Item("Save As...")) saveFileAs(dbc_file); + if (dropdown::Item("Copy to Clipboard")) saveFileToClipboard(dbc_file); + if (dropdown::Item("Remove from This Bus...")) closeFile(ss, {}); + if (dropdown::Item("Remove from All Buses...")) closeFile(dbc_file); } - ImGui::EndMenu(); + dropdown::EndMenu(); } ImGui::PopID(); } @@ -560,14 +537,14 @@ void MainWindow::updateRecentFiles(const std::string &fn) { void MainWindow::drawRecentFilesMenu() { int num_recent_files = std::min(settings.recent_files.size(), MAX_RECENT_FILES); if (!num_recent_files) { - ImGui::MenuItem("No Recent Files", nullptr, false, false); + dropdown::Item("No Recent Files", nullptr, false, false); return; } for (int i = 0; i < num_recent_files; ++i) { std::string text = std::to_string(i + 1) + " " + std::filesystem::path(settings.recent_files[i]).filename().string(); ImGui::PushID(i); - if (ImGui::MenuItem(text.c_str())) loadFile(settings.recent_files[i]); + if (dropdown::Item(text.c_str())) loadFile(settings.recent_files[i]); ImGui::PopID(); } } diff --git a/openpilot/tools/cabana/ui/tools/findsignal.cc b/openpilot/tools/cabana/ui/tools/findsignal.cc index 734107aa9f..f977aa804a 100644 --- a/openpilot/tools/cabana/ui/tools/findsignal.cc +++ b/openpilot/tools/cabana/ui/tools/findsignal.cc @@ -167,7 +167,7 @@ void FindSignalDlg::drawFindGroup() { ImGui::TextUnformatted("Value"); ImGui::SameLine(); ImGui::SetNextItemWidth(90); - ImGui::Combo("##compare", &compare_, compare_items, compare_count); + dropdown::Combo("##compare", &compare_, compare_items, compare_count); ImGui::SameLine(); ImGui::SetNextItemWidth(80); if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere(); @@ -319,12 +319,12 @@ void FindSignalDlg::setInitialSignals() { } void FindSignalDlg::drawContextMenu(int row) { - if (ImGui::BeginPopupContextItem("menu")) { - if (ImGui::MenuItem("Create Signal")) { + if (dropdown::BeginPopupContextItem("menu")) { + if (dropdown::Item("Create Signal")) { auto &s = search_.filtered_signals[row]; UndoStack::instance()->push(new AddSigCommand(s.id, s.sig)); openMessage(s.id); } - ImGui::EndPopup(); + dropdown::EndPopup(); } } diff --git a/openpilot/tools/cabana/ui/tools/findsimilarbits.cc b/openpilot/tools/cabana/ui/tools/findsimilarbits.cc index 1761fd0f51..032308a9a1 100644 --- a/openpilot/tools/cabana/ui/tools/findsimilarbits.cc +++ b/openpilot/tools/cabana/ui/tools/findsimilarbits.cc @@ -64,7 +64,7 @@ bool FindSimilarBitsDlg::draw() { ImGui::TextUnformatted("Equal"); ImGui::SameLine(); ImGui::SetNextItemWidth(60); - ImGui::Combo("##equal", &equal_, "Yes\0No\0"); + dropdown::Combo("##equal", &equal_, "Yes\0No\0"); ImGui::SameLine(); ImGui::TextUnformatted("Minimum Message Count"); ImGui::SameLine(); diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index 69eb94980b..36d0dd8498 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -103,17 +103,17 @@ bool selectable(const char *label, bool selected, ImGuiSelectableFlags flags, co bool comboBox(const char *label, int *index, const std::vector &items) { bool changed = false; const int count = (int)items.size(); - if (ImGui::BeginCombo(label, *index >= 0 && *index < count ? items[*index].c_str() : "")) { + if (dropdown::BeginCombo(label, *index >= 0 && *index < count ? items[*index].c_str() : "")) { for (int i = 0; i < count; ++i) { ImGui::PushID(i); - if (selectable(items[i].c_str(), i == *index) && *index != i) { + if (dropdown::Item(items[i].c_str(), nullptr, i == *index) && *index != i) { *index = i; changed = true; } if (i == *index) ImGui::SetItemDefaultFocus(); ImGui::PopID(); } - ImGui::EndCombo(); + dropdown::EndCombo(); } return changed; } @@ -248,18 +248,6 @@ void disabledItemTooltip(const char *text) { if (ImGui::IsItemHovered(ImGuiHoveredFlags_ForTooltip | ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", text); } -bool radioMenuItem(const char *label, bool checked, float width) { - const float indent = ImGui::GetFontSize(); - const ImVec2 pos = ImGui::GetCursorScreenPos(); - const bool clicked = selectable((std::string("##") + label).c_str(), false, ImGuiSelectableFlags_None, - ImVec2(ImMax(width, ImGui::GetContentRegionAvail().x), 0.0f)); - const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text); - ImDrawList *painter = ImGui::GetWindowDrawList(); - if (checked) ImGui::RenderBullet(painter, ImVec2(pos.x + indent / 2, pos.y + ImGui::GetTextLineHeight() / 2), color); - painter->AddText(ImVec2(pos.x + indent, pos.y), color, label); - return clicked; -} - bool PopupOwner::begin(const char *id) { ImGuiWindow *window = ImGui::GetCurrentWindowRead(); // GetCurrentWindow() would mark the fallback window as used if (popup_id == 0) { @@ -510,9 +498,9 @@ ToolbarItem toolbarMenu(const char *id, const std::string &text, const char *lab ToolbarItem item{width, [id, text, items, bold, width]() { const std::string popup_id = std::string(id) + "_menu"; menuButton(id, text, popup_id.c_str(), bold, width); - if (ImGui::BeginPopup(popup_id.c_str())) { + if (dropdown::BeginPopup(popup_id.c_str())) { items(); - ImGui::EndPopup(); + dropdown::EndPopup(); } }, label}; item.submenu = std::move(items); @@ -579,21 +567,21 @@ void drawToolbar(const std::vector &items, size_t spacer_index, flo } // the popup opens inward: its right edge is aligned with the button so it stays inside the window ImGui::SetNextWindowPos(ImVec2(ImGui::GetItemRectMax().x, ImGui::GetItemRectMax().y), ImGuiCond_Always, ImVec2(1, 0)); - if (ImGui::BeginPopup("toolbar_extension_menu")) { + if (dropdown::BeginPopup("toolbar_extension_menu")) { for (size_t i = visible; i < items.size(); ++i) { if (!items[i].in_menu) continue; if (items[i].menu_label.empty()) { items[i].draw(); } else if (items[i].submenu) { - if (ImGui::BeginMenu(items[i].menu_label.c_str(), items[i].enabled)) { + if (dropdown::BeginMenu(items[i].menu_label.c_str(), items[i].enabled)) { items[i].submenu(); - ImGui::EndMenu(); + dropdown::EndMenu(); } - } else if (ImGui::MenuItem(items[i].menu_label.c_str(), nullptr, false, items[i].enabled)) { + } else if (dropdown::Item(items[i].menu_label.c_str(), nullptr, false, items[i].enabled)) { items[i].trigger(); } } - ImGui::EndPopup(); + dropdown::EndPopup(); } } } diff --git a/openpilot/tools/cabana/ui/util.h b/openpilot/tools/cabana/ui/util.h index f431c9a338..3f3cabb71a 100644 --- a/openpilot/tools/cabana/ui/util.h +++ b/openpilot/tools/cabana/ui/util.h @@ -4,7 +4,7 @@ #include #include -#include "tools/cabana/ui/theme.h" +#include "tools/cabana/ui/dropdown.h" #include "tools/cabana/utils/util.h" struct GLFWwindow; @@ -47,17 +47,17 @@ template inline bool comboBox(const char *label, int *index, const T *values, int count) { bool changed = false; const std::string preview = *index >= 0 && *index < count ? std::to_string(values[*index]) : ""; - if (ImGui::BeginCombo(label, preview.c_str())) { + if (dropdown::BeginCombo(label, preview.c_str())) { for (int i = 0; i < count; ++i) { ImGui::PushID(i); - if (selectable(std::to_string(values[i]).c_str(), i == *index) && *index != i) { + if (dropdown::Item(std::to_string(values[i]).c_str(), nullptr, i == *index) && *index != i) { *index = i; changed = true; } if (i == *index) ImGui::SetItemDefaultFocus(); ImGui::PopID(); } - ImGui::EndCombo(); + dropdown::EndCombo(); } return changed; } @@ -90,10 +90,6 @@ float iconTextButtonWidth(const char *icon, const std::string &text); // tooltip for the last item that also shows while the item is disabled void disabledItemTooltip(const char *text); -// exclusive menu action: the bullet sits in the check column and the whole row highlights. `width` is the -// minimum row width, so a narrow popup stays wide enough for every row while the highlight spans the popup. -bool radioMenuItem(const char *label, bool checked, float width = 0.0f); - // A queued modal popup submitted from whichever call site is nested in the top-most modal. draw() is called // both nested in a modal dialog and at the root level; only the level that opened the popup may submit it // (opening at level 0 would make imgui close the parent modal). diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc index 39fc68683e..944662b7a1 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.cc +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -86,11 +86,11 @@ void DetailWidget::drawToolBar() { const size_t spacer_index = items.size(); const std::string heatmap_text = "Heatmap: " + (heatmap_live_ ? std::string("Live") : heatmap_all_text_); auto heatmap_items = [this]() { - if (ImGui::MenuItem("Live", nullptr, heatmap_live_) && !heatmap_live_) { + if (dropdown::Item("Live", nullptr, heatmap_live_) && !heatmap_live_) { heatmap_live_ = true; binary_view_->setHeatmapLiveMode(true); } - if (ImGui::MenuItem(heatmap_all_text_.c_str(), nullptr, !heatmap_live_) && heatmap_live_) { + if (dropdown::Item(heatmap_all_text_.c_str(), nullptr, !heatmap_live_) && heatmap_live_) { heatmap_live_ = false; binary_view_->setHeatmapLiveMode(false); } @@ -111,13 +111,13 @@ void DetailWidget::drawToolBar() { } void DetailWidget::showTabBarContextMenu(int index) { - if (ImGui::BeginPopupContextItem()) { - if (ImGui::MenuItem("Close Other Tabs")) { + if (dropdown::BeginPopupContextItem()) { + if (dropdown::Item("Close Other Tabs")) { tabbar_.moveTab(index, 0); tabbar_.setCurrentIndex(0); while (tabbar_.count() > 1) tabbar_.removeTab(1); } - ImGui::EndPopup(); + dropdown::EndPopup(); } } diff --git a/openpilot/tools/cabana/ui/widgets/historylog.cc b/openpilot/tools/cabana/ui/widgets/historylog.cc index 19ce2c9f65..92ed043ea7 100644 --- a/openpilot/tools/cabana/ui/widgets/historylog.cc +++ b/openpilot/tools/cabana/ui/widgets/historylog.cc @@ -142,7 +142,7 @@ void LogsWidget::draw() { const float value_w = std::clamp(ImGui::GetContentRegionAvail().x - fixed, 30.0f, 120.0f); ImGui::SetNextItemWidth(DISPLAY_TYPE_WIDTH); - if (ImGui::Combo("##display_type", &display_type_cb_, "Signal\0Hex\0")) { + if (dropdown::Combo("##display_type", &display_type_cb_, "Signal\0Hex\0")) { hex_mode_ = display_type_cb_; reset(); } @@ -155,10 +155,10 @@ void LogsWidget::draw() { } sig_items += '\0'; ImGui::SetNextItemWidth(SIGNALS_WIDTH); - if (ImGui::Combo("##signals", &signals_cb_, sig_items.c_str())) filterChanged(); + if (dropdown::Combo("##signals", &signals_cb_, sig_items.c_str())) filterChanged(); ImGui::SameLine(); ImGui::SetNextItemWidth(COMPARE_WIDTH); - if (ImGui::Combo("##comp", &comp_box_, ">\0=\0!=\0<\0")) filterChanged(); + if (dropdown::Combo("##comp", &comp_box_, ">\0=\0!=\0<\0")) filterChanged(); ImGui::SameLine(); ImGui::SetNextItemWidth(value_w); if (clearableInput("##value", &value_edit_, "", doubleValidator)) { diff --git a/openpilot/tools/cabana/ui/widgets/messageswidget.cc b/openpilot/tools/cabana/ui/widgets/messageswidget.cc index 52e0687204..a12942aa83 100644 --- a/openpilot/tools/cabana/ui/widgets/messageswidget.cc +++ b/openpilot/tools/cabana/ui/widgets/messageswidget.cc @@ -301,22 +301,22 @@ void MessagesWidget::suppressHighlighted(bool from_suppress_add) { } void MessagesWidget::drawContextMenu() { - if (!ImGui::BeginPopup("menu")) return; + if (!dropdown::BeginPopup("menu")) return; for (int i = 0; i < MessageList::COLUMN_COUNT; ++i) { const int column = display_order_[i]; // can't hide the name column - if (ImGui::MenuItem(COLUMN_TITLES[column], nullptr, !hidden_[column], column > 0)) { + if (dropdown::Item(COLUMN_TITLES[column], nullptr, !hidden_[column], column > 0)) { pending_hidden_.emplace_back(column, !hidden_[column]); } } ImGui::Separator(); - if (ImGui::MenuItem("Multiline Bytes", nullptr, settings.multiple_lines_hex)) { + if (dropdown::Item("Multiline Bytes", nullptr, settings.multiple_lines_hex)) { setMultiLineBytes(!settings.multiple_lines_hex); } - if (ImGui::MenuItem("Show Inactive Messages", nullptr, list_.show_inactive_messages)) { + if (dropdown::Item("Show Inactive Messages", nullptr, list_.show_inactive_messages)) { list_.showInactiveMessages(!list_.show_inactive_messages); } - ImGui::EndPopup(); + dropdown::EndPopup(); } void MessagesWidget::setMultiLineBytes(bool multi) { diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index 344bf7a4b3..269df6158c 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -375,7 +375,7 @@ void SignalView::drawEditor(SignalModel::Item *item) { } const ImGuiID popup_id = ImHashStr("##ComboPopup", 0, ImGui::GetID("##editor")); if (take_focus) ImGui::SetKeyboardFocusHere(); - if (ImGui::Combo("##editor", ¤t, names.data(), names.size())) { + if (dropdown::Combo("##editor", ¤t, names.data(), names.size())) { queueCommit(item, items[current].second); open_item_ = nullptr; // commit and close the editor } diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index 7860a95138..8b269fa8b2 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -234,16 +234,9 @@ void VideoWidget::createSpeedDropdown() { } void VideoWidget::drawSpeedMenuItems() { - // every row declares the same width, so the popup is exactly as wide as the widest one and all the - // highlights reach both edges; the label is padded on the right as much as the check column on the left - const float indent = ImGui::GetFontSize(); - float label_width = 0; - for (int i = 0; i < (int)std::size(speeds); ++i) { - label_width = std::max(label_width, ImGui::CalcTextSize(speedText(speeds[i]).c_str()).x); - } for (int i = 0; i < (int)std::size(speeds); ++i) { const float speed = speeds[i]; - if (radioMenuItem(speedText(speed).c_str(), speed_index_ == i, indent + label_width + indent)) { + if (dropdown::Item(speedText(speed).c_str(), nullptr, speed_index_ == i)) { speed_index_ = i; can->setSpeed(speed); speed_text_ = speedText(speed); From 4c0799eb8e2e6675b7908f93edebaedc8d62d40e Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:40:31 -0700 Subject: [PATCH 087/122] cabana: make Charts a native dockable panel (#38883) * cabana: make charts a native dockable panel * cabana: remove redundant charts docking button --- .../tools/cabana/ui/chart/chartswidget.cc | 23 ---- .../tools/cabana/ui/chart/chartswidget.h | 7 +- openpilot/tools/cabana/ui/inistate.cc | 3 + openpilot/tools/cabana/ui/inistate.h | 1 + openpilot/tools/cabana/ui/mainwin.cc | 103 ++++-------------- openpilot/tools/cabana/ui/mainwin.h | 4 +- .../tools/cabana/ui/widgets/videowidget.cc | 4 +- 7 files changed, 31 insertions(+), 114 deletions(-) diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.cc b/openpilot/tools/cabana/ui/chart/chartswidget.cc index 8052931885..12bf656a68 100644 --- a/openpilot/tools/cabana/ui/chart/chartswidget.cc +++ b/openpilot/tools/cabana/ui/chart/chartswidget.cc @@ -56,7 +56,6 @@ ChartsWidget::ChartsWidget() { if (index != -1) updateLayout(); })); - setIsDocked(true); newTab(); } @@ -153,11 +152,6 @@ void ChartsWidget::setMaxChartRange(int value) { updateState(); } -void ChartsWidget::setIsDocked(bool docked) { - is_docked_ = docked; - if (!docked) float_window_init_ = true; -} - void ChartsWidget::drawToolBar() { float slider_width = 150.0f; const bool is_zoomed = can->timeRange().has_value(); @@ -170,11 +164,6 @@ void ChartsWidget::drawToolBar() { items.push_back({iconButtonWidth(), [this]() { if (iconButton("new_tab_btn", icon::WINDOW_PLUS, "New Tab")) newTab(); }}); - const std::string title_label = "Charts: " + std::to_string(charts_.size()); - items.push_back({ImGui::CalcTextSize(title_label.c_str()).x, [&title_label]() { - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(title_label.c_str()); - }}); const int type_count = (int)std::size(SERIES_TYPE_NAMES); const std::string chart_type_text = std::string("Type: ") + SERIES_TYPE_NAMES[std::clamp(settings.chart_series_type, 0, type_count - 1)]; @@ -247,9 +236,6 @@ void ChartsWidget::drawToolBar() { }}); } items.push_back(toolbarAction("remove_all_btn", icon::TRASH, "Remove all charts", [this]() { removeAll(); }, !charts_.empty())); - const char *dock_btn_icon = is_docked_ ? icon::BOX_ARROW_UP_RIGHT : icon::BOX_ARROW_IN_DOWN_LEFT; - const char *dock_label = is_docked_ ? "Float the charts window" : "Dock the charts window"; - items.push_back(toolbarAction("dock_btn", dock_btn_icon, dock_label, [this]() { toggleChartsDocking(); })); // the slider shrinks first, the buttons stay pinned to the right edge if (slider_index != (size_t)-1) { @@ -575,15 +561,6 @@ void ChartsWidget::handleEvents() { void ChartsWidget::draw() { deleted_charts_.clear(); - // the floating window is a top level window sized to its contents: keep it inside the main viewport so its - // toolbar stays reachable, then let the user resize it - if (float_window_init_ && !is_docked_) { - float_window_init_ = false; - const ImGuiViewport *viewport = ImGui::GetMainViewport(); - const ImVec2 size(viewport->WorkSize.x * 0.6f, viewport->WorkSize.y * 0.6f); - ImGui::SetWindowSize(size); - ImGui::SetWindowPos(viewport->WorkPos + (viewport->WorkSize - size) * 0.5f); - } ImGui::PushID(this); if (auto_scroll_timer_active_ && ImGui::GetTime() >= auto_scroll_timer_next_) { auto_scroll_timer_next_ = ImGui::GetTime() + 0.05; diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.h b/openpilot/tools/cabana/ui/chart/chartswidget.h index c8c1ef7e13..16e1700631 100644 --- a/openpilot/tools/cabana/ui/chart/chartswidget.h +++ b/openpilot/tools/cabana/ui/chart/chartswidget.h @@ -68,7 +68,8 @@ class ChartsWidget { public: ChartsWidget(); ~ChartsWidget(); // out of line: the header users only see a forward declared ChartView - void draw(); // content only; MainWindow wraps it in a child region or the floating window + void draw(); // content only; MainWindow owns the dockable panel + size_t chartCount() const { return charts_.size(); } void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge); inline bool hasSignal(const MessageId &id, const cabana::Signal *sig) { return findChart(id, sig) != nullptr; } std::vector serializeChartIds() const; @@ -77,9 +78,7 @@ public: void setColumnCount(int n); void removeAll(); - void setIsDocked(bool dock); - Observable<> toggleChartsDocking; Observable<> seriesChanged; Observable showTip; @@ -116,8 +115,6 @@ private: void drawDragPreview(); LogSlider range_slider_{1000}; - bool is_docked_ = true; - bool float_window_init_ = false; // the floating window geometry is set once, right after undocking UndoStack zoom_undo_stack_; diff --git a/openpilot/tools/cabana/ui/inistate.cc b/openpilot/tools/cabana/ui/inistate.cc index 2243690600..4dffec494f 100644 --- a/openpilot/tools/cabana/ui/inistate.cc +++ b/openpilot/tools/cabana/ui/inistate.cc @@ -39,6 +39,8 @@ void readLine(ImGuiContext *, ImGuiSettingsHandler *, void *entry, const char *l state->video_splitter_ratio = ratio; } else if (sscanf(line, "MessagesVisible=%d", &flag) == 1) { state->messages_visible = flag != 0; + } else if (sscanf(line, "ChartsVisible=%d", &flag) == 1) { + state->charts_visible = flag != 0; } else if (sscanf(line, "VideoVisible=%d", &flag) == 1) { state->video_visible = flag != 0; } @@ -54,6 +56,7 @@ void writeAll(ImGuiContext *, ImGuiSettingsHandler *handler, ImGuiTextBuffer *bu buf->appendf("VideoSplitterRatio=%.4f\n", main_window.video_splitter_ratio); buf->appendf("MessagesVisible=%d\n", main_window.messages_visible ? 1 : 0); buf->appendf("VideoVisible=%d\n", main_window.video_visible ? 1 : 0); + buf->appendf("ChartsVisible=%d\n", main_window.charts_visible ? 1 : 0); buf->append("\n"); } diff --git a/openpilot/tools/cabana/ui/inistate.h b/openpilot/tools/cabana/ui/inistate.h index a460802d24..fd17c7e98e 100644 --- a/openpilot/tools/cabana/ui/inistate.h +++ b/openpilot/tools/cabana/ui/inistate.h @@ -15,6 +15,7 @@ struct MainWindowState { float video_splitter_ratio = -1.0f; // < 0: video at its size hint bool messages_visible = true; bool video_visible = true; + bool charts_visible = true; }; extern MainWindowState main_window; diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index 0683116424..777f947804 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -38,9 +38,9 @@ constexpr const char *CHARTS_WINDOW = "Charts###ChartsWindow"; MainWindow::MainWindow(GLFWwindow *window, std::unique_ptr stream, StreamLoader stream_loader, const std::string &dbc_file) : window_(window) { can = &dummy_; - video_splitter_ratio_ = inistate::main_window.video_splitter_ratio; messages_visible_ = inistate::main_window.messages_visible; video_visible_ = inistate::main_window.video_visible; + charts_visible_ = inistate::main_window.charts_visible; loadFingerprints(); std::error_code ec; for (const auto &entry : std::filesystem::directory_iterator(OPENDBC_FILE_PATH, ec)) { @@ -162,10 +162,10 @@ void MainWindow::drawMenuBar() { ImGui::Separator(); dropdown::Item(messages_widget_ ? messages_widget_->title().c_str() : "MESSAGES", nullptr, &messages_visible_); dropdown::Item(video_dock_title_.empty() ? "Video" : video_dock_title_.c_str(), nullptr, &video_visible_); + dropdown::Item("Charts", nullptr, &charts_visible_); ImGui::Separator(); if (dropdown::Item("Reset Window Layout")) { - messages_visible_ = video_visible_ = true; - video_splitter_ratio_ = -1.0f; + messages_visible_ = video_visible_ = charts_visible_ = true; reset_layout_ = true; } dropdown::EndMenu(); @@ -192,7 +192,6 @@ void MainWindow::createDockWidgets() { charts_widget_ = std::make_unique(); center_widget_.setChartsWidget(charts_widget_.get()); video_widget_ = std::make_unique(); - widget_connections_.push_back(charts_widget_->toggleChartsDocking.connect([this]() { toggleChartsDocking(); })); } void MainWindow::showStatusMessage(const std::string &msg, int timeout_ms) { @@ -578,11 +577,6 @@ void MainWindow::updateDownloadProgress(uint64_t cur, uint64_t total, bool succe } } -void MainWindow::toggleChartsDocking() { - charts_floating_ = !charts_floating_; - charts_widget_->setIsDocked(!charts_floating_); -} - void MainWindow::close() { if (closing_) return; closing_ = true; @@ -604,9 +598,9 @@ void MainWindow::finishClose() { glfwGetWindowSize(window_, &state.size[0], &state.size[1]); } state.has_geometry = state.size[0] > 0 && state.size[1] > 0; - state.video_splitter_ratio = video_splitter_ratio_; state.messages_visible = messages_visible_; state.video_visible = video_visible_; + state.charts_visible = charts_visible_; settings.ui_state = inistate::save(); saveSessionState(); @@ -787,15 +781,18 @@ void MainWindow::drawDockspace() { ImGui::SetCursorPosY(ImGui::GetCursorPosY() + top_gap); const ImVec2 dock_size(ImGui::GetContentRegionAvail().x, ImGui::GetContentRegionAvail().y - status_height); const ImGuiID dock_id = ImGui::GetID("cabana_dockspace"); - if (reset_layout_ || ImGui::DockBuilderGetNode(dock_id) == nullptr) { - // messages left, video (with charts) right, center widget in the middle + if (reset_layout_ || ImGui::DockBuilderGetNode(dock_id) == nullptr || + (!ImGui::FindWindowByName(CHARTS_WINDOW) && !ImGui::FindWindowSettingsByID(ImHashStr(CHARTS_WINDOW)))) { + // Messages left, route above charts on the right, details in the middle. ImGui::DockBuilderRemoveNode(dock_id); ImGui::DockBuilderAddNode(dock_id, ImGuiDockNodeFlags_DockSpace); ImGui::DockBuilderSetNodePos(dock_id, ImGui::GetCursorScreenPos()); ImGui::DockBuilderSetNodeSize(dock_id, dock_size); - ImGuiID center = dock_id, left = 0, right = 0; + ImGuiID center = dock_id, left = 0, right = 0, charts = 0; ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, 0.28f, &left, ¢er); ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.4f, &right, ¢er); + ImGui::DockBuilderSplitNode(right, ImGuiDir_Down, 0.55f, &charts, &right); + ImGui::DockBuilderDockWindow(CHARTS_WINDOW, charts); ImGui::DockBuilderDockWindow(MESSAGES_PANEL_ID, left); ImGui::DockBuilderDockWindow(VIDEO_PANEL, right); ImGui::DockBuilderDockWindow(CENTER_PANEL, center); @@ -856,66 +853,10 @@ void MainWindow::drawVideoPanel() { if (video_widget_ && !video_open) { video_widget_->setVisible(false); // the dock is collapsed or tabbed behind another one, like hideEvent } else if (video_widget_) { - const ImVec2 avail = ImGui::GetContentRegionAvail(); - const bool live = can->liveStreaming(); - // the bordered child pads its content, so the heights the widget asks for grow by the padding - const float video_padding = ImGui::GetStyle().WindowPadding.y * 2.0f; - // the camera is as wide as the child's content region, not the panel - const float default_h = video_widget_->defaultHeight(avail.x - ImGui::GetStyle().WindowPadding.x * 2.0f) + video_padding; - const float video_hint = video_splitter_ratio_ >= 0.0f ? avail.y * video_splitter_ratio_ : default_h; - float video_h = charts_floating_ ? avail.y : std::clamp(video_hint, 0.0f, avail.y - 1.0f); - if (live) video_h = default_h; // display video at minimum size. - // Collapse panes below half their minimum height to keep partially clipped controls out of view. - bool charts_collapsed = false; - const float splitter_h = ImGui::GetStyle().WindowPadding.x * 2.0f + 2.0f; - if (!charts_floating_ && !live) { - const float min_h = std::min(video_widget_->sizeHintHeight() + video_padding, avail.y - 1.0f); - video_h = video_h < min_h / 2 ? 0.0f : std::max(video_h, min_h); - const float charts_min_h = ImGui::GetFrameHeight() + video_padding + ImGui::GetStyle().ChildBorderSize * 2.0f; - const float charts_h = avail.y - video_h - splitter_h; - if (charts_h < charts_min_h / 2) { - charts_collapsed = true; - video_h = avail.y - splitter_h; - } else if (charts_h < charts_min_h) { - video_h = avail.y - splitter_h - charts_min_h; - } - } - // Replay uses a splitter for the gap; live streams use normal item spacing. - if (video_h > 0.0f) { - ImGui::BeginChild("video", ImVec2(0, video_h), ImGuiChildFlags_Borders); - help_overlay_.add(video_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); - video_widget_->draw(); - ImGui::EndChild(); - // The splitter supplies the pane gap; keep normal spacing inside the video child. - if (!charts_floating_ && !live) ImGui::SetCursorPosY(ImGui::GetCursorPosY() - ImGui::GetStyle().ItemSpacing.y); - } else { - video_widget_->setVisible(false); // the splitter collapsed the video: stop the vipc thread - } - if (!charts_floating_ && !live) { - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); - ImGui::InvisibleButton("##splitter", ImVec2(-1.0f, splitter_h)); - const bool splitter_hovered = ImGui::IsItemHovered() && !live, splitter_active = ImGui::IsItemActive() && !live; - if (splitter_active) { - // the size of the video is the position of the handle inside the splitter - const float top = ImGui::GetWindowPos().y + ImGui::GetCursorStartPos().y; - video_splitter_ratio_ = std::clamp((ImGui::GetMousePos().y - top) / avail.y, 0.0f, 1.0f); - } - if (splitter_hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); - const ImRect splitter(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); - const float line_y = std::floor(splitter.GetCenter().y) - 1.0f; - ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(splitter.Min.x, line_y), ImVec2(splitter.Max.x, line_y + 2.0f), - ImGui::GetColorU32(splitter_active ? ImGuiCol_SeparatorActive : splitter_hovered ? ImGuiCol_SeparatorHovered : ImGuiCol_Border)); - ImGui::PopStyleVar(); - } - if (!charts_floating_) { - if (!charts_collapsed) { - // the chart list scrolls in its own child, the container itself never scrolls - ImGui::BeginChild("charts", ImVec2(0, 0), ImGuiChildFlags_Borders, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); - help_overlay_.add(charts_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); - charts_widget_->draw(); - ImGui::EndChild(); - } - } + ImGui::BeginChild("video", ImVec2(0, 0), ImGuiChildFlags_Borders); + help_overlay_.add(video_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); + video_widget_->draw(); + ImGui::EndChild(); } ImGui::End(); if (!video_visible_ && floating) video_visible_ = reset_layout_ = true; @@ -952,16 +893,16 @@ void MainWindow::draw() { if (messages_visible_) drawMessagesPanel(); if (video_widget_ && !video_visible_) video_widget_->setVisible(false); if (video_visible_) drawVideoPanel(); - if (charts_widget_ && charts_floating_) { - bool open = true; - ImGui::SetNextWindowSize(ImGui::GetMainViewport()->WorkSize, ImGuiCond_Appearing); - setNextWindowFloatsOut(); - if (ImGui::Begin(CHARTS_WINDOW, &open, ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { - help_overlay_.add(charts_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); - charts_widget_->draw(); + if (charts_visible_) { + const std::string charts_title = "Charts: " + std::to_string(charts_widget_ ? charts_widget_->chartCount() : 0) + "###ChartsWindow"; + setNextPanelClass(); + if (beginPanel(charts_title.c_str(), &charts_visible_, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { + if (charts_widget_) { + help_overlay_.add(charts_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); + charts_widget_->draw(); + } } ImGui::End(); - if (!open) toggleChartsDocking(); } for (auto it = tool_dialogs_.begin(); it != tool_dialogs_.end();) { it = (*it)->draw() ? it + 1 : tool_dialogs_.erase(it); diff --git a/openpilot/tools/cabana/ui/mainwin.h b/openpilot/tools/cabana/ui/mainwin.h index 72c24a3464..840ca0990a 100644 --- a/openpilot/tools/cabana/ui/mainwin.h +++ b/openpilot/tools/cabana/ui/mainwin.h @@ -26,7 +26,6 @@ public: MainWindow(GLFWwindow *window, std::unique_ptr stream, StreamLoader stream_loader, const std::string &dbc_file); ~MainWindow(); void draw(); - void toggleChartsDocking(); void close(); // remind unsaved changes, save state, exit bool exited() const { return exited_; } void showStatusMessage(const std::string &msg, int timeout_ms = 0); @@ -105,13 +104,12 @@ private: std::string video_dock_title_; bool messages_visible_ = true; bool video_visible_ = true; + bool charts_visible_ = true; bool reset_layout_ = false; bool full_screen_ = false; #ifndef __APPLE__ int windowed_rect_[4] = {0, 0, 1600, 900}; #endif - bool charts_floating_ = false; - float video_splitter_ratio_ = -1.0f; // < 0: the video widget is at its size hint std::vector> tool_dialogs_; bool closing_ = false; bool exited_ = false; diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index 8b269fa8b2..5c8a9ede3c 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -278,9 +278,9 @@ void VideoWidget::drawCameraWidget() { ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); camera_tab_->draw(); - // cam_widget_: minimum height MIN_VIDEO_HEIGHT, takes the space left by the slider and the toolbar + // Reserve the timeline and playback controls even when the native dock is short. const ImVec2 avail = ImGui::GetContentRegionAvail(); - const float cam_height = std::max((float)MIN_VIDEO_HEIGHT, avail.y - SLIDER_HEIGHT - toolbar_height); + const float cam_height = std::max(1.0f, avail.y - SLIDER_HEIGHT - toolbar_height); cam_widget_->draw(ImVec2(avail.x, cam_height), thumbnail_display_time_); if (!slider_->isSliderDown()) slider_->setCurrentSecond(can->currentSec()); From 44914a7cf4108a31aab86fb96efde9dbcd50c889 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:45:32 -0700 Subject: [PATCH 088/122] cabana: remove custom focus-loss handling (#38884) * cabana: fix focus handling across docked and floating windows * cabana: remove custom focus-loss handling --- openpilot/tools/cabana/ui/app.cc | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/openpilot/tools/cabana/ui/app.cc b/openpilot/tools/cabana/ui/app.cc index e45eaac6fb..beb693b1e3 100644 --- a/openpilot/tools/cabana/ui/app.cc +++ b/openpilot/tools/cabana/ui/app.cc @@ -28,36 +28,6 @@ void keyCallback(GLFWwindow *window, int key, int scancode, int action, int mods ImGui_ImplGlfw_KeyCallback(window, key, scancode, action, mods); if (action == GLFW_PRESS) g_key_events.push_back({key, mods}); } -// imgui releases every mouse button when the window loses focus, which aborts a panel tear-off drag and -// docks the panel back. X11 keeps delivering the drag through the implicit grab, so hold a focus loss back -// while a button is down and deliver it after the release (see deliverPendingFocusLoss). -GLFWwindow *g_focus_lost_window = nullptr; -// macOS drops the button on its own when the focus moves, and holding the loss back there swallowed the -// first click in a popup: the click makes the popup's window key, the main window's loss lands on the -// release and imgui clears its mouse state before it sees that release -void windowFocusCallback(GLFWwindow *w, int f) { -#ifdef __APPLE__ - ImGui_ImplGlfw_WindowFocusCallback(w, f); -#else - if (f) { - g_focus_lost_window = nullptr; - ImGui_ImplGlfw_WindowFocusCallback(w, f); - } else { - g_focus_lost_window = w; - } -#endif -} -bool anyMouseButtonDown(GLFWwindow *w) { - for (int b = GLFW_MOUSE_BUTTON_1; b <= GLFW_MOUSE_BUTTON_LAST; ++b) { - if (glfwGetMouseButton(w, b) == GLFW_PRESS) return true; - } - return false; -} -void deliverPendingFocusLoss() { - if (g_focus_lost_window == nullptr || anyMouseButtonDown(g_focus_lost_window)) return; - ImGui_ImplGlfw_WindowFocusCallback(g_focus_lost_window, GLFW_FALSE); - g_focus_lost_window = nullptr; -} void hookViewportCallbacks() { for (ImGuiViewport *viewport : ImGui::GetPlatformIO().Viewports) { @@ -89,7 +59,6 @@ void paceFrame() { void renderFrame(GLFWwindow *window, MainWindow *win) { glfwPollEvents(); - deliverPendingFocusLoss(); utils::drainMainThreadQueue(); int fb_w = 0, fb_h = 0; @@ -175,7 +144,6 @@ public: throw std::runtime_error("ImGui_ImplGlfw_InitForOpenGL failed"); } glfwSetKeyCallback(window, keyCallback); - glfwSetWindowFocusCallback(window, windowFocusCallback); if (!ImGui_ImplOpenGL3_Init("#version 330")) { ImGui_ImplGlfw_Shutdown(); ImPlot::DestroyContext(); From 0d4a4ab9109e54608580c9154b4c59a8fd0b3e72 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:48:57 -0700 Subject: [PATCH 089/122] cabana: disable collapse for dockable panels (#38885) --- openpilot/tools/cabana/ui/mainwin.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index 777f947804..e7c971b6a8 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -825,7 +825,7 @@ void setNextPanelClass() { bool beginPanel(const char *name, bool *open, ImGuiWindowFlags flags = 0) { ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); - const bool visible = ImGui::Begin(name, open, flags); + const bool visible = ImGui::Begin(name, open, flags | ImGuiWindowFlags_NoCollapse); ImGui::PopStyleVar(); return visible; } From d38264c42c599c99747002b19c7f01c112880679 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Sat, 12 Sep 2026 23:57:49 -0700 Subject: [PATCH 090/122] log chestnut telemetry in hardwared (#38866) log chestnut hardware state independently of modeld --- openpilot/cereal/log.capnp | 1 + openpilot/cereal/services.py | 3 +- openpilot/selfdrive/modeld/modeld.py | 65 +++---------------- .../system/hardware/chestnut/monitoring.py | 53 +++++++++++++++ openpilot/system/hardware/hardwared.py | 2 + 5 files changed, 67 insertions(+), 57 deletions(-) create mode 100644 openpilot/system/hardware/chestnut/monitoring.py diff --git a/openpilot/cereal/log.capnp b/openpilot/cereal/log.capnp index 177a220451..086f6ca719 100644 --- a/openpilot/cereal/log.capnp +++ b/openpilot/cereal/log.capnp @@ -2594,6 +2594,7 @@ struct Event { clocks @35 :Clocks; deviceState @6 :DeviceState; chestnutState @152 :ChestnutState; + chestnutGpuState @153 :ChestnutState; logMessage @18 :Text; errorLogMessage @85 :Text; diff --git a/openpilot/cereal/services.py b/openpilot/cereal/services.py index 08633d6975..e8be582072 100755 --- a/openpilot/cereal/services.py +++ b/openpilot/cereal/services.py @@ -25,7 +25,8 @@ _services: dict[str, tuple] = { "accelerometer": (True, 104., 104), "temperatureSensor": (True, 2., 200), "deviceState": (True, 2., 1), - "chestnutState": (True, 10., 10), + "chestnutState": (True, 10., 1), + "chestnutGpuState": (False, 10.), "touch": (True, 20., 1), "can": (True, 100., 2053, QueueSize.BIG), # decimation gives ~3 msgs in a full segment "controlsState": (True, 100., 10, QueueSize.MEDIUM), diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 175c729782..a05361145a 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -5,8 +5,6 @@ from functools import cached_property import os os.environ['GMMU'] = '0' # for chestnut fast loading, noop for qcom from tinygrad.device import Device -import usb1 -import struct import threading import time import numpy as np @@ -31,7 +29,6 @@ from openpilot.selfdrive.modeld.parse_model_outputs import Parser from openpilot.selfdrive.modeld.compile_modeld import make_input_queues, nv12_copy_size, MODELD_INPUTS from openpilot.selfdrive.modeld.fill_model_msg import fill_model_msg, fill_driving_model_data, fill_pose_msg, PublishState from openpilot.common.file_chunker import open_file_chunked -from openpilot.common.hardware.usb import CHESTNUT_USB_IDS from openpilot.selfdrive.modeld.constants import ModelConstants, Plan from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled, modeld_pkl_path, load_oob @@ -71,45 +68,14 @@ def get_action_from_model(model_output: dict[str, np.ndarray], prev_action: log. shouldStop=bool(stop)) -class ChestnutState: - # only modeld can access chestnut +class ChestnutGpuState: + # GPU metrics require modeld's GPU context def __init__(self, pm: PubMaster, big: bool): self.pm = pm self.big = big self.valid = True self.sends = 0 self.metrics = {} - self._asm_usb = None - - def _close_asm_usb(self) -> None: - if self._asm_usb is not None: - self._asm_usb.close() - self._asm_usb = None - - def _open_asm_usb(self): - context = usb1.USBContext() - for vendor_id, product_id in CHESTNUT_USB_IDS: - if (handle := context.openByVendorIDAndProductID(vendor_id, product_id, skip_on_error=True)) is not None: - return handle - context.close() - - def _read_ina(self) -> tuple[int, int, bool]: - if "AMD" in Device._opened_devices and self._asm_usb is None: - try: - raw = Device["AMD"].iface.pci_dev.usb.usb.control_read(0xC0, 5) - return struct.unpack(' int: @@ -117,8 +83,8 @@ class ChestnutState: return smu._send_msg(smu.smu_mod.PPSMC_MSG_GetPptLimit, 0, read_back_arg=True, timeout=100) def send(self) -> None: - msg = messaging.new_message('chestnutState') - state = msg.chestnutState + msg = messaging.new_message('chestnutGpuState') + state = msg.chestnutGpuState self.sends += 1 if self.big and "AMD" in Device._opened_devices and self.sends % 100 == 1: try: @@ -144,21 +110,8 @@ class ChestnutState: for k, v in self.metrics.items(): setattr(state, k, v) - asm_valid = False - try: - # ASM runs on USB-C power, these still read without a gpu - state.supplyVoltage, state.supplyCurrent, state.supplyFault = self._read_ina() - asm_valid = True - except Exception: - pass - if "AMD" in Device._opened_devices: - try: - state.pcieLtssm = Device["AMD"].iface.pci_dev.usb.read(0xB450, 1)[0] - except Exception: - pass - - msg.valid = asm_valid and (not self.big or self.valid) - self.pm.send('chestnutState', msg) + msg.valid = not self.big or (self.valid and bool(self.metrics)) + self.pm.send('chestnutGpuState', msg) class FrameMeta: @@ -295,13 +248,13 @@ def main(demo=False): cloudlog.warning(f"models loaded in {time.monotonic() - st:.1f}s, modeld starting") # messaging - pub_socks = ["modelV2", "drivingModelData", "cameraOdometry"] + (["chestnutState"] if CHESTNUT else []) + pub_socks = ["modelV2", "drivingModelData", "cameraOdometry"] + (["chestnutGpuState"] if CHESTNUT else []) pm = PubMaster(pub_socks) sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState", "carControl", "lateralDelay"]) publish_state = PublishState() params = Params() - chestnut_state = ChestnutState(pm, model.chestnut) if CHESTNUT else None + chestnut_state = ChestnutGpuState(pm, model.chestnut) if CHESTNUT else None # setup filter to track dropped frames frame_dropped_filter = FirstOrderFilter(0., 10., 1. / ModelConstants.MODEL_RUN_FREQ) @@ -410,7 +363,7 @@ def main(demo=False): mt1 = time.perf_counter() try: send_chestnut = (chestnut_state is not None and - run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutState'].frequency) == 0) + run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutGpuState'].frequency) == 0) model_output = model.run(bufs, transforms, inputs, chestnut_state.send if send_chestnut else None) except Exception: if not params.get_bool("ChestnutActive"): diff --git a/openpilot/system/hardware/chestnut/monitoring.py b/openpilot/system/hardware/chestnut/monitoring.py new file mode 100644 index 0000000000..3a8b21f61b --- /dev/null +++ b/openpilot/system/hardware/chestnut/monitoring.py @@ -0,0 +1,53 @@ +import struct + +import usb1 + +import openpilot.cereal.messaging as messaging +from openpilot.cereal.services import SERVICE_LIST +from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, get_usb_state, is_chestnut_usb_id + + +def read_chestnut_state(handle, gpu_state=None): + msg = messaging.new_message('chestnutState') + if gpu_state is not None: + msg.chestnutState = gpu_state + state = msg.chestnutState + try: + raw = handle.controlRead(0xC0, 0xC0, 0, 0, 5, timeout=100) + state.supplyVoltage, state.supplyCurrent, state.supplyFault = struct.unpack(' Date: Sun, 13 Sep 2026 09:19:48 -0700 Subject: [PATCH 091/122] cabana: make the Signals pane a native dock panel, fix pad (#38889) * cabana: align center tabs and keep signal panel docked * cabana: prevent panels from docking into the tabless center * cabana: use native docking for the Signals panel * cabana: migrate dock tabs when loading the layout --- openpilot/tools/cabana/ui/inistate.cc | 11 +++++++++++ openpilot/tools/cabana/ui/mainwin.cc | 8 ++------ openpilot/tools/cabana/ui/widgets/detailwidget.cc | 3 +++ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/openpilot/tools/cabana/ui/inistate.cc b/openpilot/tools/cabana/ui/inistate.cc index 4dffec494f..258344126a 100644 --- a/openpilot/tools/cabana/ui/inistate.cc +++ b/openpilot/tools/cabana/ui/inistate.cc @@ -107,6 +107,15 @@ std::string migrateQtState() { return std::string(buf.c_str()); } +void migrateDockLayout() { + // Show dock tabs hidden by older layouts. + if (const auto *center = ImGui::FindWindowSettingsByID(ImHashStr("###CenterWidget"))) { + if (auto *node = ImGui::DockBuilderGetNode(center->DockId)) { + node->LocalFlags &= ~ImGuiDockNodeFlags_NoTabBar; + } + } +} + } // namespace void addSettingsHandler() { @@ -123,6 +132,8 @@ void load() { if (settings.ui_state.empty()) settings.ui_state = migrateQtState(); if (!settings.ui_state.empty()) ImGui::LoadIniSettingsFromMemory(settings.ui_state.data(), settings.ui_state.size()); + + migrateDockLayout(); } void applyWindowGeometry(GLFWwindow *window) { diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index e7c971b6a8..924c37c0b5 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -31,7 +31,7 @@ namespace { // dock window ids (the visible titles change, the part after ### is the identity) constexpr const char *VIDEO_PANEL = "###VideoPanel"; -constexpr const char *CENTER_PANEL = "###CenterWidget"; +constexpr const char *CENTER_PANEL = "Signals###CenterWidget"; constexpr const char *CHARTS_WINDOW = "Charts###ChartsWindow"; } // namespace @@ -777,8 +777,6 @@ void MainWindow::drawDockspace() { // the status bar sits below the dockspace: reserve its height plus the item spacing between the two, // otherwise the host window is a few pixels taller than the viewport and scrolls const float status_height = full_screen_ ? 0.0f : ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.y; - const float top_gap = full_screen_ ? 0.0f : ImGui::GetStyle().ItemSpacing.y; - ImGui::SetCursorPosY(ImGui::GetCursorPosY() + top_gap); const ImVec2 dock_size(ImGui::GetContentRegionAvail().x, ImGui::GetContentRegionAvail().y - status_height); const ImGuiID dock_id = ImGui::GetID("cabana_dockspace"); if (reset_layout_ || ImGui::DockBuilderGetNode(dock_id) == nullptr || @@ -796,7 +794,6 @@ void MainWindow::drawDockspace() { ImGui::DockBuilderDockWindow(MESSAGES_PANEL_ID, left); ImGui::DockBuilderDockWindow(VIDEO_PANEL, right); ImGui::DockBuilderDockWindow(CENTER_PANEL, center); - ImGui::DockBuilderGetNode(center)->LocalFlags |= ImGuiDockNodeFlags_NoTabBar; ImGui::DockBuilderFinish(dock_id); reset_layout_ = false; } @@ -879,13 +876,12 @@ void MainWindow::draw() { drawDockspace(); // the central widget has no scrollbars of its own (the views inside scroll) + setNextPanelClass(); if (beginPanel(CENTER_PANEL, nullptr, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { - ImGui::BeginChild("center", ImVec2(0, 0), ImGuiChildFlags_Borders, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); center_widget_.draw(); if (auto *detail = center_widget_.getDetailWidget(); detail && help_overlay_.visible()) { for (const auto &[text, rect] : detail->helpRects()) help_overlay_.add(text, rect); } - ImGui::EndChild(); } ImGui::End(); // Submit the same dock windows while loading, so ImGui doesn't collapse their diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc index 944662b7a1..988cf4b3cb 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.cc +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -280,6 +280,8 @@ void DetailWidget::drawTabWidget() { void DetailWidget::draw() { tabbar_.draw(); + ImGui::BeginChild("message_content", ImVec2(0, 0), ImGuiChildFlags_Borders, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); drawToolBar(); if (warning_widget_visible_) { @@ -289,6 +291,7 @@ void DetailWidget::draw() { } drawTabWidget(); + ImGui::EndChild(); if (edit_dlg_ && !edit_dlg_->draw()) { if (edit_dlg_->accepted()) { From 064a51fe59af5a9e7fac47f7bee47cdbd2e65371 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Sun, 13 Sep 2026 09:38:23 -0700 Subject: [PATCH 092/122] model replay: use a mici segment (#38878) * model replay: use a mici segment * process replay: wait for vision listener startup --- openpilot/selfdrive/test/process_replay/model_replay.py | 4 ++-- openpilot/selfdrive/test/process_replay/process_replay.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/openpilot/selfdrive/test/process_replay/model_replay.py b/openpilot/selfdrive/test/process_replay/model_replay.py index 47ca3fb104..fb46e83ef0 100755 --- a/openpilot/selfdrive/test/process_replay/model_replay.py +++ b/openpilot/selfdrive/test/process_replay/model_replay.py @@ -20,8 +20,8 @@ from openpilot.tools.lib.framereader import FrameReader from openpilot.tools.lib.logreader import LogReader, save_log from openpilot.tools.lib.github_utils import GithubUtils -TEST_ROUTE = "8494c69d3c710e81|000001d4--2648a9a404" -SEGMENT = 4 +TEST_ROUTE = "98395b7c5b27882e|0000002b--2686b5a2d0" +SEGMENT = 1 START_FRAME = 0 END_FRAME = 60 diff --git a/openpilot/selfdrive/test/process_replay/process_replay.py b/openpilot/selfdrive/test/process_replay/process_replay.py index 5abfab2c35..df60d54e88 100755 --- a/openpilot/selfdrive/test/process_replay/process_replay.py +++ b/openpilot/selfdrive/test/process_replay/process_replay.py @@ -17,7 +17,7 @@ from openpilot.common.hardware.hw import Paths import openpilot.cereal.messaging as messaging from opendbc.car.structs import car from openpilot.cereal.services import SERVICE_LIST -from msgq.visionipc import VisionIpcServer, get_endpoint_name as vipc_get_endpoint_name +from msgq.visionipc import VisionIpcClient, VisionIpcServer, get_endpoint_name as vipc_get_endpoint_name from opendbc.car.can_definitions import CanData from opendbc.car.car_helpers import get_car, interfaces from openpilot.common.params import Params @@ -209,6 +209,7 @@ class ProcessContainer: stride, y_height, _, yuv_size = get_nv12_info(frame_size[0], frame_size[1]) vipc_server.create_buffers_with_sizes(meta.stream, 2, frame_size[0], frame_size[1], yuv_size, stride, stride * y_height) vipc_server.start_listener() + VisionIpcClient.available_streams("camerad", block=True) self.vipc_server = vipc_server self.cfg.vision_pubs = [meta.camera_state for meta in streams_metas if meta.camera_state in self.cfg.vision_pubs] From 45a7747ca371148bb94ab861444146f12c1b560d Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 09:46:43 -0700 Subject: [PATCH 093/122] cabana: fix detached panel menus (#38890) * cabana: keep dropdowns above detached panels and within the screen * cabana: shorten dropdown comments --- openpilot/tools/cabana/ui/dropdown.h | 47 ++++++++++++++++++++++++++++ openpilot/tools/cabana/ui/util.cc | 6 ++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/openpilot/tools/cabana/ui/dropdown.h b/openpilot/tools/cabana/ui/dropdown.h index dc46d55252..b73d8e39a2 100644 --- a/openpilot/tools/cabana/ui/dropdown.h +++ b/openpilot/tools/cabana/ui/dropdown.h @@ -14,6 +14,26 @@ constexpr float ROUNDING = 6.0f; constexpr float BORDER = 1.0f; constexpr int WINDOW_STYLE_VARS = 3; +class PopupViewportScope { +public: + PopupViewportScope() : main_(ImGui::GetMainViewport()), flags_(main_->Flags) { + // Keep detached popups above their owner; reset ownership when docked. + ImGuiWindowClass window_class; + const ImGuiViewport *owner = ImGui::GetWindowViewport(); + if (owner != main_) { + // Popups ignore NoAutoMerge, so exclude the main viewport during Begin. + main_->Flags &= ~ImGuiViewportFlags_CanHostOtherWindows; + window_class.ParentViewportId = owner->ID; + } + ImGui::SetNextWindowClass(&window_class); + } + ~PopupViewportScope() { main_->Flags = flags_; } + +private: + ImGuiViewport *main_; + ImGuiViewportFlags flags_; +}; + inline void pushStyle() { ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(PADDING_X, PADDING_Y)); ImGui::PushStyleVar(ImGuiStyleVar_PopupRounding, ROUNDING); @@ -25,11 +45,36 @@ inline bool finishBegin(bool open) { else ImGui::PopStyleVar(WINDOW_STYLE_VARS); return open; } + +inline void PositionBelowItem(const char *id, bool align_right = false) { + const ImRect anchor(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + char name[32]; + ImFormatString(name, IM_ARRAYSIZE(name), "##Popup_%08x", ImGui::GetID(id)); + if (ImGuiWindow *popup = ImGui::FindWindowByName(name); popup && popup->WasActive) { + // Place like a combo using the owner's current monitor bounds. + const auto *viewport = static_cast(ImGui::GetWindowViewport()); + ImRect bounds = viewport->GetMainRect(); + if ((ImGui::GetIO().ConfigFlags & ImGuiConfigFlags_ViewportsEnable) && viewport->PlatformMonitor >= 0) { + const auto &monitor = ImGui::GetPlatformIO().Monitors[viewport->PlatformMonitor]; + bounds = ImRect(monitor.WorkPos, ImVec2(monitor.WorkPos.x + monitor.WorkSize.x, monitor.WorkPos.y + monitor.WorkSize.y)); + } + bounds.Expand(ImVec2(-ImGui::GetStyle().DisplaySafeAreaPadding.x, -ImGui::GetStyle().DisplaySafeAreaPadding.y)); + ImGuiDir direction = align_right ? ImGuiDir_Left : ImGuiDir_Down; + const ImVec2 pos = ImGui::FindBestWindowPosForPopupEx(anchor.GetBL(), ImGui::CalcWindowNextAutoFitSize(popup), + &direction, bounds, anchor, ImGuiPopupPositionPolicy_ComboBox); + ImGui::SetNextWindowPos(pos); + } else { + ImGui::SetNextWindowPos(align_right ? anchor.GetBR() : anchor.GetBL(), ImGuiCond_Always, ImVec2(align_right ? 1.0f : 0.0f, 0)); + } +} + inline bool BeginPopup(const char *id, ImGuiWindowFlags flags = 0) { + PopupViewportScope viewport_scope; pushStyle(); return finishBegin(ImGui::BeginPopup(id, flags)); } inline bool BeginPopupContextItem(const char *id = nullptr, ImGuiPopupFlags flags = ImGuiPopupFlags_MouseButtonRight) { + PopupViewportScope viewport_scope; pushStyle(); return finishBegin(ImGui::BeginPopupContextItem(id, flags)); } @@ -39,6 +84,7 @@ inline void EndPopup() { ImGui::PopStyleVar(WINDOW_STYLE_VARS); } inline bool BeginMenu(const char *label, bool enabled = true) { + PopupViewportScope viewport_scope; pushStyle(); return finishBegin(ImGui::BeginMenu(label, enabled)); } @@ -48,6 +94,7 @@ inline void EndMenu() { ImGui::PopStyleVar(WINDOW_STYLE_VARS); } inline bool BeginCombo(const char *label, const char *preview, ImGuiComboFlags flags = 0) { + PopupViewportScope viewport_scope; pushStyle(); return finishBegin(ImGui::BeginCombo(label, preview, flags)); } diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index 36d0dd8498..64e1b914be 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -565,8 +565,7 @@ void drawToolbar(const std::vector &items, size_t spacer_index, flo (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByPopup) && ImGui::IsMouseClicked(ImGuiMouseButton_Left)))) { ImGui::OpenPopup("toolbar_extension_menu"); } - // the popup opens inward: its right edge is aligned with the button so it stays inside the window - ImGui::SetNextWindowPos(ImVec2(ImGui::GetItemRectMax().x, ImGui::GetItemRectMax().y), ImGuiCond_Always, ImVec2(1, 0)); + dropdown::PositionBelowItem("toolbar_extension_menu", true); if (dropdown::BeginPopup("toolbar_extension_menu")) { for (size_t i = visible; i < items.size(); ++i) { if (!items[i].in_menu) continue; @@ -624,8 +623,7 @@ bool menuButton(const char *id, const std::string &text, const char *popup_id, b ImVec2(x + MENU_ARROW_SIZE * 0.5f, baseline), ImGui::GetColorU32(ImGuiCol_TextDisabled)); if (clicked && !popup_open) ImGui::OpenPopup(popup_id); - // the menu drops down from below the button, not at the mouse cursor - ImGui::SetNextWindowPos(ImVec2(min.x, ImGui::GetItemRectMax().y), ImGuiCond_Always); + dropdown::PositionBelowItem(popup_id); return clicked; } From c5cf29cf5e0e8090c7c3150dc42500690ec2e19f Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:53:35 -0700 Subject: [PATCH 094/122] cabana: contain tooltips and capture plot drags (#38896) * cabana: contain chart tooltips within columns * cabana: refresh tooltip bounds after chart resize * cabana: capture plot drags to prevent window movement --- openpilot/tools/cabana/ui/chart/chart.cc | 14 ++++++-- openpilot/tools/cabana/ui/chart/tiplabel.cc | 40 +++++++++++++++++++-- openpilot/tools/cabana/ui/chart/tiplabel.h | 3 +- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/openpilot/tools/cabana/ui/chart/chart.cc b/openpilot/tools/cabana/ui/chart/chart.cc index 060bc91541..4f1d7891ea 100644 --- a/openpilot/tools/cabana/ui/chart/chart.cc +++ b/openpilot/tools/cabana/ui/chart/chart.cc @@ -507,8 +507,10 @@ void ChartView::draw(float width) { } ImGui::EndChild(); // a chart scrolled out of the viewport draws no tip - const ImRect visible_rect = charts_widget_->chartVisibleRect(this); - if (!drawing_ghost_ && visible_rect.GetWidth() > 0 && visible_rect.GetHeight() > 0) tip_label_.draw(); + ImRect visible_rect = charts_widget_->chartVisibleRect(this); + visible_rect.ClipWith(ImRect(ImVec2(layout_.rect.Min.x, layout_.plot_area.Min.y), + ImVec2(layout_.rect.Max.x, layout_.plot_area.Max.y))); + if (!drawing_ghost_ && visible_rect.GetWidth() > 0 && visible_rect.GetHeight() > 0) tip_label_.draw(visible_rect); ImGui::PopID(); } @@ -575,6 +577,14 @@ void ChartView::drawAxes() { // ImPlotFlags_NoInputs disables implot's own hover tracking layout_.plot_hovered = layout_.plot_area.Contains(ImGui::GetMousePos()) && ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows | ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); drawSeries(); + if (!drawing_ghost_) { + // Own plot clicks so custom scrubbing/zooming cannot also move the floating window. + const ImGuiID input_id = ImGui::GetID("plot_input"); + if (ImGui::ItemAdd(layout_.plot_area, input_id)) { + bool hovered, held; + ImGui::ButtonBehavior(layout_.plot_area, input_id, &hovered, &held); + } + } handleMousePress(); handleMouseMove(); handleMouseRelease(); diff --git a/openpilot/tools/cabana/ui/chart/tiplabel.cc b/openpilot/tools/cabana/ui/chart/tiplabel.cc index 0efc4e7ad6..6f3a1ee54e 100644 --- a/openpilot/tools/cabana/ui/chart/tiplabel.cc +++ b/openpilot/tools/cabana/ui/chart/tiplabel.cc @@ -19,6 +19,31 @@ ImVec2 TipLabel::layoutLines(ImDrawList *p, const ImVec2 &origin, ImU32 fg) cons }; const char *heading = !text_.empty() && !text_[0].has_marker ? text_[0].name.c_str() : "Signal"; const char *headers[] = {heading, "Value", "Min", "Max"}; + if (compact_) { + const float right = origin.x + std::max(0.0f, area_.GetWidth() - MARGIN * 2 - 1); + auto cell = [&](float left, const std::string &text, ImU32 color) { + if (p) drawElidedText(p, ImRect(ImVec2(left, y), ImVec2(right, y + font_size)), text, color); + }; + cell(origin.x, heading, muted); + y += line_height; + for (const auto &line : text_) { + if (!line.has_marker) continue; + if (p) p->AddRectFilled(ImVec2(origin.x, y + (font_size - marker) * 0.5f), + ImVec2(origin.x + marker, y + (font_size + marker) * 0.5f), line.marker); + cell(origin.x + marker + 6, line.name, fg); + y += line_height; + const std::string *values[] = {&line.value, &line.min, &line.max}; + const float label_width = ImGui::CalcTextSize("Value").x + gap; + for (int i = 0; i < 3; ++i) { + draw(origin.x, headers[i + 1], muted); + pushMonoFont(font_size); + cell(origin.x + label_width, *values[i], i == 0 ? fg : muted); + popMonoFont(); + y += line_height; + } + } + return ImVec2(right - origin.x, y - origin.y); + } float x = origin.x; for (int i = 0; i < 4; ++i) { draw(i ? x + column_widths_[i] - ImGui::CalcTextSize(headers[i]).x : x, headers[i], muted); @@ -77,8 +102,15 @@ void TipLabel::updateLayout() { } popMonoFont(); const ImGuiViewport *viewport = ImGui::GetWindowViewport(); - const ImRect bounds(viewport->WorkPos, viewport->WorkPos + viewport->WorkSize); + ImRect bounds(viewport->WorkPos, viewport->WorkPos + viewport->WorkSize); + bounds.ClipWith(area_); + if (bounds.GetWidth() <= 0 || bounds.GetHeight() <= 0) { + visible_ = false; + return; + } + area_ = bounds; const float numeric_width = column_widths_[1] + column_widths_[2] + column_widths_[3] + 24 + MARGIN * 2 + 1; + compact_ = bounds.GetWidth() < numeric_width + ImGui::GetFontSize() * 4; column_widths_[0] = std::min(column_widths_[0], std::max(40.0f, std::min(ImGui::GetFontSize() * 16, bounds.GetWidth() - numeric_width))); if (!text_.empty()) { ImVec2 extra(1, 1); @@ -96,13 +128,17 @@ void TipLabel::updateLayout() { visible_ = false; } -void TipLabel::draw() { +void TipLabel::draw(const ImRect &rect) { if (!visible_) return; + area_ = rect; updateLayout(); + if (!visible_) return; ImDrawList *p = ImGui::GetForegroundDrawList(); + p->PushClipRect(area_.Min, area_.Max, true); // filled panel with a 1px frame p->AddRectFilled(pos_, pos_ + size_, ImGui::GetColorU32(ImGuiCol_PopupBg), ImGui::GetStyle().PopupRounding); p->AddRect(pos_, pos_ + size_, ImGui::GetColorU32(ImGuiCol_Border), ImGui::GetStyle().PopupRounding); layoutLines(p, pos_ + ImVec2(MARGIN, MARGIN), ImGui::GetColorU32(ImGuiCol_Text)); + p->PopClipRect(); } diff --git a/openpilot/tools/cabana/ui/chart/tiplabel.h b/openpilot/tools/cabana/ui/chart/tiplabel.h index fe7b099d37..48f20b59f8 100644 --- a/openpilot/tools/cabana/ui/chart/tiplabel.h +++ b/openpilot/tools/cabana/ui/chart/tiplabel.h @@ -20,7 +20,7 @@ public: void showText(const ImVec2 &pt, const std::vector &text, const ImRect &rect); void hide() { visible_ = false; } bool isVisible() const { return visible_; } - void draw(); // draws the tip on the foreground draw list; call once per frame + void draw(const ImRect &rect); // draws the tip on the foreground draw list; call once per frame private: // lays the lines out from origin, drawing them when p is given; returns the size of the text block @@ -35,5 +35,6 @@ private: ImRect area_; ImVec2 pos_; ImVec2 size_; + bool compact_ = false; bool visible_ = false; }; From 9c054e285d8980daae01b45672c46899f3af237d Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:19:59 -0700 Subject: [PATCH 095/122] replay: retry failed segment loads (#38897) * replay: retry failed segment loads * replay: rename test helper for spellcheck * replay: include portable temporary directory declaration * replay: name attempt limit and shorten test delays * replay: drop timing-dependent retry tests --- openpilot/tools/replay/seg_mgr.cc | 29 ++++++++++++++++++++++++++--- openpilot/tools/replay/seg_mgr.h | 9 +++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/openpilot/tools/replay/seg_mgr.cc b/openpilot/tools/replay/seg_mgr.cc index 074e7e53db..2c616bd004 100644 --- a/openpilot/tools/replay/seg_mgr.cc +++ b/openpilot/tools/replay/seg_mgr.cc @@ -51,10 +51,11 @@ void SegmentManager::setCurrentSegment(int seg_num) { void SegmentManager::manageSegmentCache() { while (true) { std::unique_lock lock(mutex_); - cv_.wait(lock, [this]() { return exit_ || needs_update_; }); + cv_.wait_until(lock, next_retry_, [this]() { return exit_ || needs_update_; }); if (exit_) break; needs_update_ = false; + next_retry_ = std::chrono::steady_clock::time_point::max(); auto cur = segments_.lower_bound(cur_seg_num_); if (cur == segments_.end()) continue; @@ -69,8 +70,12 @@ void SegmentManager::manageSegmentCache() { bool merged = mergeSegments(begin, end); // Free segments outside the current range - std::for_each(segments_.begin(), begin, [](auto &segment) { segment.second.reset(); }); - std::for_each(end, segments_.end(), [](auto &segment) { segment.second.reset(); }); + auto evict = [this](auto &segment) { + segment.second.reset(); + load_attempts_.erase(segment.first); + }; + std::for_each(segments_.begin(), begin, evict); + std::for_each(end, segments_.end(), evict); if (merged && onSegmentMergedCallback_) { onSegmentMergedCallback_(); // Notify listener that segments have been merged @@ -121,7 +126,25 @@ void SegmentManager::loadSegmentsInRange(SegmentMap::iterator begin, SegmentMap: auto tryLoadSegment = [this](auto first, auto last) { for (auto it = first; it != last; ++it) { auto &segment_ptr = it->second; + auto &attempt = load_attempts_[it->first]; + if (segment_ptr && segment_ptr->getState() == Segment::LoadState::Failed) { + // A failed object must not permanently occupy its cache slot. Back off + // between retries while allowing other segments to load meanwhile. + if (attempt.count >= MAX_SEGMENT_LOAD_ATTEMPTS) continue; + const auto now = std::chrono::steady_clock::now(); + if (attempt.retry_at == std::chrono::steady_clock::time_point::max()) { + attempt.retry_at = now + std::chrono::seconds(attempt.count); + } + if (now < attempt.retry_at) { + next_retry_ = std::min(next_retry_, attempt.retry_at); + continue; + } + segment_ptr.reset(); + rWarning("retrying segment %d (attempt %d/%d)", it->first, attempt.count + 1, MAX_SEGMENT_LOAD_ATTEMPTS); + } if (!segment_ptr) { + ++attempt.count; + attempt.retry_at = std::chrono::steady_clock::time_point::max(); if (onBenchmarkEvent_) { onBenchmarkEvent_(it->first, "loading"); } diff --git a/openpilot/tools/replay/seg_mgr.h b/openpilot/tools/replay/seg_mgr.h index 55b412b25a..6542335caf 100644 --- a/openpilot/tools/replay/seg_mgr.h +++ b/openpilot/tools/replay/seg_mgr.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -9,6 +10,7 @@ #include "tools/replay/route.h" constexpr int MIN_SEGMENTS_CACHE = 5; +constexpr int MAX_SEGMENT_LOAD_ATTEMPTS = 3; // Includes the initial load. using SegmentMap = std::map>; @@ -56,4 +58,11 @@ private: std::function onSegmentMergedCallback_ = nullptr; std::function onBenchmarkEvent_ = nullptr; std::set merged_segments_; + struct LoadAttempt { + int count = 0; + std::chrono::steady_clock::time_point retry_at = std::chrono::steady_clock::time_point::max(); + }; + // Accessed only by the cache management thread. + std::map load_attempts_; + std::chrono::steady_clock::time_point next_retry_ = std::chrono::steady_clock::time_point::max(); }; From ebb202f29d5847a6d57387aa064c5004f0ce7b38 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:35:46 -0700 Subject: [PATCH 096/122] tools: share cancellable browser sign-in (#38893) * tools: share cancellable browser sign-in * tools: invoke auth JSON mode directly from Cabana * tools: require an explicit Python module * tools: order module constants alphabetically * tools: use shared unauthorized handling for devices --- openpilot/tools/lib/auth.py | 101 ++++++++++++++++-------- openpilot/tools/lib/auth_config.py | 3 +- openpilot/tools/replay/py_downloader.cc | 23 ++++-- openpilot/tools/replay/py_downloader.h | 3 + 4 files changed, 88 insertions(+), 42 deletions(-) diff --git a/openpilot/tools/lib/auth.py b/openpilot/tools/lib/auth.py index 6a685e38d9..02df4954a5 100755 --- a/openpilot/tools/lib/auth.py +++ b/openpilot/tools/lib/auth.py @@ -22,34 +22,50 @@ Examples:: """ import argparse +import json import sys +import subprocess import pprint -import webbrowser -from http.server import BaseHTTPRequestHandler, HTTPServer +import time +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any -from urllib.parse import parse_qs, urlencode +from urllib.parse import parse_qs, urlencode, urlsplit -from openpilot.tools.lib.api import APIError, CommaApi, UnauthorizedError +from openpilot.tools.lib.api import CommaApi, UnauthorizedError from openpilot.tools.lib.auth_config import set_token, get_token -class ClientRedirectServer(HTTPServer): - query_params: dict[str, Any] = {} +class ClientRedirectServer(ThreadingHTTPServer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.query_params: dict[str, Any] = {} + self.result_lock = threading.Lock() + + def get_request(self): + request, address = super().get_request() + request.settimeout(1) # Bound incomplete requests, including browser preconnections. + return request, address class ClientRedirectHandler(BaseHTTPRequestHandler): def do_GET(self): - if not self.path.startswith('/auth'): + if urlsplit(self.path).path not in ('/auth', '/auth/'): self.send_response(204) + self.end_headers() return - query = self.path.split('?', 1)[-1] - query_parsed = parse_qs(query, keep_blank_values=True) - self.server.query_params = query_parsed + query_parsed = parse_qs(urlsplit(self.path).query, keep_blank_values=True) + with self.server.result_lock: + if not self.server.query_params and ('code' in query_parsed or 'error' in query_parsed): + self.server.query_params = query_parsed self.send_response(200) self.send_header('Content-type', 'text/plain') self.end_headers() - self.wfile.write(b'Return to the CLI to continue') + try: + self.wfile.write(b'Sign-in received. You can close this tab and return to Cabana or your terminal.') + except ConnectionError: + pass # A closing browser tab must not discard the received callback. def log_message(self, format: str, *args: object) -> None: # noqa: A002 # stdlib override pass # this prevent http server from dumping messages to stdout @@ -94,36 +110,52 @@ def auth_redirect_link(method, port): raise NotImplementedError(f"no redirect implemented for method {method}") -def login(method): - # Let the OS select an available port to avoid colliding with other services. - web_server = ClientRedirectServer(('localhost', 0), ClientRedirectHandler) - oauth_uri = auth_redirect_link(method, web_server.server_port) - print(f'To sign in, use your browser and navigate to {oauth_uri}') - webbrowser.open(oauth_uri, new=2) - - while True: - web_server.handle_request() - if 'code' in web_server.query_params: - break - elif 'error' in web_server.query_params: - print('Authentication Error: "{}". Description: "{}" '.format( - web_server.query_params['error'], - web_server.query_params.get('error_description')), file=sys.stderr) - break - +def login(method, timeout=180): + """Sign in through a browser and save the token, returning a success/error status.""" try: - auth_resp = CommaApi().post('v2/auth/', data={'code': web_server.query_params['code'], 'provider': web_server.query_params['provider']}) - set_token(auth_resp['access_token']) - except APIError as e: - print(f'Authentication Error: {e}', file=sys.stderr) + with ClientRedirectServer(('localhost', 0), ClientRedirectHandler) as server: + url = auth_redirect_link(method, server.server_port) + print(f'To sign in, use your browser and navigate to {url}', file=sys.stderr) + browser = subprocess.Popen(['open' if sys.platform == 'darwin' else 'xdg-open', url], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + server.timeout = min(0.1, max(0, deadline - time.monotonic())) + server.handle_request() + params = server.query_params + if 'error' in params: + return {"error": "Sign-in was declined. Choose a provider to try again."} + if 'code' in params: + provider = {'google': 'g', 'apple': 'a', 'github': 'h'}[method] + if len(params['code']) != 1 or not params['code'][0].strip() or params.get('provider') != [provider]: + return {"error": "Invalid sign-in response. Please try again."} + response = CommaApi().post('v2/auth/', data={'code': params['code'], 'provider': params['provider']}, timeout=30) + token = response.get('access_token') + if not isinstance(token, str) or not token.strip(): + return {"error": "Sign-in did not return an access token. Please try again."} + CommaApi(token).get('v1/me', timeout=30) + set_token(token) + return {"success": True} + if browser.poll() not in (None, 0): + return {"error": "Could not open your browser. Check your default browser and try again."} + return {"error": "Sign-in timed out. Choose a provider to try again."} + except Exception: + return {"error": "Could not complete sign-in. Check your connection and try again."} if __name__ == '__main__': parser = argparse.ArgumentParser(description='Login to your comma account') parser.add_argument('method', default='google', const='google', nargs='?', choices=['google', 'apple', 'github', 'jwt']) parser.add_argument('jwt', nargs='?') + parser.add_argument('--json', action='store_true', help='Return browser sign-in status as JSON') args = parser.parse_args() + if args.json: + if args.method == 'jwt': + parser.error('--json requires a browser sign-in provider') + print(json.dumps(login(args.method))) + sys.exit(0) + if args.method == 'jwt': if args.jwt is None: print("method JWT selected, but no JWT was provided") @@ -131,7 +163,10 @@ if __name__ == '__main__': set_token(args.jwt) else: - login(args.method) + result = login(args.method) + if "error" in result: + print(result["error"], file=sys.stderr) + sys.exit(1) try: me = CommaApi(token=get_token()).get('/v1/me') diff --git a/openpilot/tools/lib/auth_config.py b/openpilot/tools/lib/auth_config.py index 5966bd34a9..954225d4d3 100644 --- a/openpilot/tools/lib/auth_config.py +++ b/openpilot/tools/lib/auth_config.py @@ -1,6 +1,7 @@ import json import os from openpilot.common.hardware.hw import Paths +from openpilot.common.utils import atomic_write class MissingAuthConfigError(Exception): @@ -18,7 +19,7 @@ def get_token(): def set_token(token): os.makedirs(Paths.config_root(), exist_ok=True) - with open(os.path.join(Paths.config_root(), 'auth.json'), 'w') as f: + with atomic_write(os.path.join(Paths.config_root(), 'auth.json'), overwrite=True) as f: json.dump({'access_token': token}, f) diff --git a/openpilot/tools/replay/py_downloader.cc b/openpilot/tools/replay/py_downloader.cc index a7ab5baa91..286d8a7319 100644 --- a/openpilot/tools/replay/py_downloader.cc +++ b/openpilot/tools/replay/py_downloader.cc @@ -18,6 +18,9 @@ namespace { +constexpr const char *AUTH_MODULE = "openpilot.tools.lib.auth"; +constexpr const char *DOWNLOADER_MODULE = "openpilot.tools.lib.file_downloader"; + static std::mutex handler_mutex; static DownloadProgressHandler progress_handler = nullptr; @@ -30,12 +33,12 @@ void reportProgress(const char *line) { // Run a Python command and capture stdout. Stderr is scanned for PROGRESS lines and otherwise passed // through to the parent's stderr. Returns stdout content. If abort is signaled, kills the child process. -std::string runPython(const std::vector &args, std::atomic *abort = nullptr) { - // Build argv for the downloader module +std::string runPython(const char *module, const std::vector &args, std::atomic *abort = nullptr) { + // Build argv for the Python module std::vector argv; argv.push_back("python3"); argv.push_back("-m"); - argv.push_back("openpilot.tools.lib.file_downloader"); + argv.push_back(module); for (const auto &a : args) { argv.push_back(a.c_str()); } @@ -215,19 +218,23 @@ std::string download(const std::string &url, bool use_cache, std::atomic * if (!use_cache) { args.push_back("--no-cache"); } - return runPython(args, abort); + return runPython(DOWNLOADER_MODULE, args, abort); } std::string decompress(const std::string &path, std::atomic *abort) { - return runPython({"decompress", path}, abort); + return runPython(DOWNLOADER_MODULE, {"decompress", path}, abort); } std::string getRouteFiles(const std::string &route) { - return runPython({"route-files", route}); + return runPython(DOWNLOADER_MODULE, {"route-files", route}); +} + +std::string authenticate(const std::string &provider, std::atomic *abort) { + return runPython(AUTH_MODULE, {provider, "--json"}, abort); } std::string getDevices() { - return runPython({"devices"}); + return runPython(DOWNLOADER_MODULE, {"devices"}); } std::string getDeviceRoutes(const std::string &dongle_id, int64_t start_ms, int64_t end_ms, bool preserved) { @@ -244,7 +251,7 @@ std::string getDeviceRoutes(const std::string &dongle_id, int64_t start_ms, int6 args.push_back(std::to_string(end_ms)); } } - return runPython(args); + return runPython(DOWNLOADER_MODULE, args); } } // namespace PyDownloader diff --git a/openpilot/tools/replay/py_downloader.h b/openpilot/tools/replay/py_downloader.h index 80fab6ab00..fac66dabd5 100644 --- a/openpilot/tools/replay/py_downloader.h +++ b/openpilot/tools/replay/py_downloader.h @@ -18,6 +18,9 @@ std::string decompress(const std::string &path, std::atomic *abort = nullp // Returns JSON string of route files (same format as /v1/route/.../files API) std::string getRouteFiles(const std::string &route); +// Browser sign-in; abort closes the local callback server. Returns a JSON status. +std::string authenticate(const std::string &provider, std::atomic *abort); + // Returns JSON string of user's devices std::string getDevices(); From 11093e743e6e8cbefdc0e7db6f04c5880271c65a Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:44:13 -0700 Subject: [PATCH 097/122] cabana: improve dark slider track contrast (#38899) --- openpilot/tools/cabana/ui/theme.cc | 2 ++ openpilot/tools/cabana/ui/theme.h | 1 + openpilot/tools/cabana/ui/util.cc | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/openpilot/tools/cabana/ui/theme.cc b/openpilot/tools/cabana/ui/theme.cc index 696a933b82..14d0949835 100644 --- a/openpilot/tools/cabana/ui/theme.cc +++ b/openpilot/tools/cabana/ui/theme.cc @@ -19,6 +19,7 @@ constexpr Palette DARK_PALETTE = { .header = rgb(0x2f65ca), .header_hovered = rgb(0x414e65), .header_active = rgb(0x2f65ca), .accent = rgb(0x2f65ca), .border = rgb(0x282828), .separator = rgb(0x353535), .scrollbar_grab = rgb(0x484b4d), + .slider_track = rgb(0x777777), .tab = rgb(0x353535), .tab_hovered = rgb(0x484b4d), .table_header = rgb(0x484b4d), .grid = rgb(0xbbbbbb, 50.0f / 255.0f), .badge = rgb(0x808080), .bit_background = rgb(0xffffff, 20.0f / 255.0f), @@ -34,6 +35,7 @@ constexpr Palette LIGHT_PALETTE = { .header = rgb(0x308cc6), .header_hovered = rgb(0xe7f3fb), .header_active = rgb(0x308cc6), .accent = rgb(0x308cc6), .border = rgb(0xb6b6b6), .separator = rgb(0xd0d0d0), .scrollbar_grab = rgb(0xb6b6b6), + .slider_track = rgb(0xd0d0d0), .tab = rgb(0xe5e5e5), .tab_hovered = rgb(0xefefef), .table_header = rgb(0xefefef), .grid = rgb(0x000000, 50.0f / 255.0f), .badge = rgb(0xa0a0a4), .bit_background = rgb(0xffffff, 0.0f), diff --git a/openpilot/tools/cabana/ui/theme.h b/openpilot/tools/cabana/ui/theme.h index 020b137c62..9c8653cfe9 100644 --- a/openpilot/tools/cabana/ui/theme.h +++ b/openpilot/tools/cabana/ui/theme.h @@ -14,6 +14,7 @@ struct Palette { ImVec4 header, header_hovered, header_active; // selections ImVec4 accent; ImVec4 border, separator, scrollbar_grab; + ImVec4 slider_track; ImVec4 tab, tab_hovered, table_header; ImVec4 grid; ImVec4 badge; // the fill behind the time labels drawn over a chart diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index 64e1b914be..1b7a2a7837 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -656,7 +656,7 @@ bool fusionSliderInt(const char *label, int *v, int min, int max, float width) { const float hx = x0 + (x1 - x0) * t; ImDrawList *dl = ImGui::GetWindowDrawList(); const float groove_y0 = cy - groove_h * 0.5f, groove_y1 = cy + groove_h * 0.5f; - dl->AddRectFilled(ImVec2(bb_min.x, groove_y0), ImVec2(bb_max.x, groove_y1), u32(palette().separator), groove_h * 0.5f); + dl->AddRectFilled(ImVec2(bb_min.x, groove_y0), ImVec2(bb_max.x, groove_y1), u32(palette().slider_track), groove_h * 0.5f); dl->AddRectFilled(ImVec2(bb_min.x, groove_y0), ImVec2(hx, groove_y1), u32(palette().accent), groove_h * 0.5f); drawSliderHandle(dl, ImRect(ImVec2(hx - SLIDER_LENGTH * 0.5f, cy - handle_h * 0.5f), ImVec2(hx + SLIDER_LENGTH * 0.5f, cy + handle_h * 0.5f))); From b6918cb31b298235203fce67b92fea60a9548adc Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:22:13 -0700 Subject: [PATCH 098/122] cabana: fix download bar layout (#38900) * cabana: fix download bar layout * cabana: reduce download label font size --- openpilot/tools/cabana/ui/mainwin.cc | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index 924c37c0b5..48239602bf 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -718,6 +718,10 @@ void MainWindow::drawStatusBar() { // WindowPadding.x, which lines the text up with the content of the docked panels above (the messages table). const float width = ImGui::GetContentRegionAvail().x; const float pad = ImGui::GetStyle().WindowPadding.x; + const float progress_width = std::min(300.0f, std::max(0.0f, width - 2 * pad)); + const float progress_x = width - pad - progress_width; + const float message_end = status_bar_.progress_visible ? progress_x - ImGui::GetStyle().ItemSpacing.x : width - pad; + ImGui::PushClipRect(min, ImVec2(min.x + std::max(pad, message_end), min.y + ImGui::GetWindowHeight()), true); ImGui::SetCursorPosX(pad); ImGui::AlignTextToFramePadding(); // a temporary message hides the normal widgets, permanent widgets stay on the right @@ -728,9 +732,20 @@ void MainWindow::drawStatusBar() { bar.message.clear(); ImGui::TextUnformatted("For help, press F1"); } - if (bar.progress_visible) { - ImGui::SameLine(width - pad - 300.0f); - ImGui::ProgressBar(bar.progress_value, ImVec2(300.0f, 16.0f), bar.progress_text.c_str()); + ImGui::PopClipRect(); + if (bar.progress_visible && progress_width > 0) { + const float progress_height = 16.0f; + ImGui::PushFont(ImGui::GetFont(), 12.0f); + const std::string percentage = std::to_string((int)(bar.progress_value * 100)) + "%"; + const char *label = bar.progress_text.c_str(); + const float text_width = progress_width - 2 * ImGui::GetStyle().FramePadding.x; + if (ImGui::CalcTextSize(label).x > text_width) label = percentage.c_str(); + if (ImGui::CalcTextSize(label).x > text_width) label = ""; + ImGui::SameLine(progress_x); + ImGui::SetCursorPosY((ImGui::GetWindowHeight() - progress_height) / 2.0f); + ImGui::ProgressBar(bar.progress_value, ImVec2(progress_width, progress_height), label); + ImGui::PopFont(); + ImGui::SetItemTooltip("%s", bar.progress_text.c_str()); } ImGui::EndChild(); ImGui::PopStyleColor(); From 94f71fce00cf905f019c00acf5454bf0a2010a92 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:23:22 -0700 Subject: [PATCH 099/122] cabana: add browser sign-in for remote routes (#38894) * cabana: add browser sign-in for remote routes * cabana: use shared dialog styling for browser sign-in * cabana: support spacious centered action buttons * cabana: vertically center sign-in states * cabana: keep sign-in layout stable between states * cabana: center sign-in error text * cabana: avoid hint flash when retrying sign-in --- .../tools/cabana/ui/dialogs/routesdialog.cc | 156 +++++++++++++++++- .../tools/cabana/ui/dialogs/routesdialog.h | 10 ++ .../tools/cabana/ui/dialogs/streamselector.cc | 2 + openpilot/tools/cabana/ui/util.cc | 30 ++-- openpilot/tools/cabana/ui/util.h | 12 +- 5 files changed, 195 insertions(+), 15 deletions(-) diff --git a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc index f0a5eb0b9e..e669ac5fd5 100644 --- a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc +++ b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc @@ -1,6 +1,13 @@ #include "tools/cabana/ui/dialogs/routesdialog.h" +#include +#include #include +#include + +#include "json11/json11.hpp" +#include "tools/replay/py_downloader.h" +#include "tools/cabana/ui/theme.h" #include "imgui.h" #include "imgui_internal.h" @@ -14,12 +21,17 @@ const int PERIOD_DAYS[] = {7, 14, 30, 180, -1}; } // namespace void RoutesDialog::open(std::function on_done) { + if (alive_) return; on_done_ = std::move(on_done); - open_ = true; + open_ = false; popup_.reset(); s_ = State{}; alive_ = std::make_shared(true); + fetchDevices(); +} + +void RoutesDialog::fetchDevices() { routes::fetchDevices([this, alive = std::weak_ptr(alive_)](std::vector devices, bool success, int error_code) { utils::runOnMainThread(utils::guarded(alive.lock(), [this, devices = std::move(devices), success, error_code]() { setDeviceList(devices, success, error_code); @@ -29,14 +41,19 @@ void RoutesDialog::open(std::function on_done) void RoutesDialog::setDeviceList(const std::vector &devices, bool success, int error_code) { if (success) { + s_.login = false; + open_ = true; s_.devices.clear(); for (const auto &device : devices) s_.devices.push_back(device.dongle_id); s_.devices_loaded = true; s_.device_index = 0; fetchRoutes(); + } else if (error_code == 401) { + s_.login = true; + open_ = true; } else { - // the box shows on top of the dialog, which is rejected once the box is dismissed - MessageBox::warning("Error", error_code == 401 ? "Unauthorized. Authenticate with openpilot/tools/lib/auth.py" : "Network error", "", + // Initial failures are shown on the calling window without opening the route browser. + MessageBox::warning("Error", "Network error", "", utils::guarded(alive_, [this]() { finish(false); })); } } @@ -72,6 +89,8 @@ void RoutesDialog::setRouteList(const std::vector &list, bool } void RoutesDialog::finish(bool accepted) { + if (auth_abort_) *auth_abort_ = true; + auth_abort_.reset(); alive_.reset(); open_ = false; auto on_done = std::move(on_done_); @@ -82,6 +101,16 @@ void RoutesDialog::draw() { if (!open_) return; if (!beginDialog("Remote Routes", &popup_, ImVec2(480.0f, 420.0f))) return; + if (s_.login) { + drawLogin(); + if (open_) { + MessageBox::draw(); + if (!open_) ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + return; + } + ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Device"); ImGui::SameLine(); @@ -122,3 +151,124 @@ void RoutesDialog::draw() { ImGui::EndPopup(); if (accepted || rejected) finish(accepted); } + +void RoutesDialog::signIn(const std::string &provider) { + s_.provider = provider == "google" ? "Google" : provider == "apple" ? "Apple" : "GitHub"; + s_.auth_error.clear(); + auth_abort_ = std::make_shared>(false); + std::thread([this, alive = std::weak_ptr(alive_), abort = auth_abort_, provider]() { + const std::string result = PyDownloader::authenticate(provider, abort.get()); + utils::runOnMainThread(utils::guarded(alive.lock(), [this, abort, result]() { + if (*abort) return; + auth_abort_.reset(); + std::string error; + auto status = json11::Json::parse(result, error); + if (status["success"].bool_value()) { + s_.login = false; + fetchDevices(); + } else { + s_.auth_error = status["error"].string_value(); + if (s_.auth_error.empty()) s_.auth_error = "Could not start sign-in. Please try again."; + } + })); + }).detach(); +} + +void RoutesDialog::drawLogin() { + const auto &p = palette(); + const char *providers[] = {"Google", "Apple", "GitHub"}; + const char *methods[] = {"google", "apple", "github"}; + const char *icons[] = {"\xef\x8f\xb0", "\xef\x99\x9b", "\xef\x8f\xad"}; + IconTextButtonOptions button_options{.height = 44.0f, .rounding = 8.0f, .icon_gap = 16.0f, .center_content = true}; + float button_width = iconTextButtonWidth("", "Choose another method", button_options); + for (int i = 0; i < 3; ++i) { + const std::string label = std::string("Sign in with ") + providers[i]; + button_width = std::max(button_width, iconTextButtonWidth(icons[i], label, button_options)); + button_options.label_width = std::max(button_options.label_width, ImGui::CalcTextSize(label.c_str()).x); + } + button_width += ImGui::GetStyle().FramePadding.x * 4; + // Keep the footer anchored while longer errors scroll inside the content area. + const float footer = ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y; + ImGui::BeginChild("login_content", ImVec2(0, -footer)); + ImGui::Indent(16); + const float gap = ImGui::GetStyle().ItemSpacing.y; + const float wrap_x = ImGui::GetWindowWidth() - 28; + const float text_width = wrap_x - ImGui::GetCursorPosX(); + const char *chooser_intro = "Use your comma account to browse recorded drives and open a route for analysis."; + const auto waiting_intro = [](const std::string &provider) { + return "Sign in with " + provider + " in your browser, then return to Cabana to choose a device and route."; + }; + const std::string intro = auth_abort_ ? waiting_intro(s_.provider) : chooser_intro; + const char *hint = "Use the comma account paired with your device."; + // Reserve the same space in every state so the heading and controls never jump. + float intro_height = ImGui::CalcTextSize(chooser_intro, nullptr, false, text_width).y; + for (const char *provider : providers) { + intro_height = std::max(intro_height, ImGui::CalcTextSize(waiting_intro(provider).c_str(), nullptr, false, text_width).y); + } + const float controls_height = 3 * (button_options.height + 2 * gap) + + ImGui::CalcTextSize(hint, nullptr, false, text_width).y; + const float content_height = 28 + 2 * gap + intro_height + 2 * gap + 12 + controls_height; + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + std::max(0.0f, (ImGui::GetContentRegionAvail().y - content_height) * 0.5f)); + ImGui::PushFont(boldFont(), 28.0f); + ImGui::TextUnformatted("Open your routes in Cabana"); + ImGui::PopFont(); + ImGui::Spacing(); + ImGui::PushTextWrapPos(wrap_x); + const float controls_y = ImGui::GetCursorPosY() + intro_height + 2 * gap + 12; + const float buttons_x = ImGui::GetCursorPosX() + (ImGui::GetContentRegionAvail().x - 16 - button_width) * 0.5f; + if (auth_abort_) { + ImGui::TextWrapped("%s", intro.c_str()); + ImGui::SetCursorPosY(controls_y); + ImGui::BeginChild("auth_status", ImVec2(-16, 76), ImGuiChildFlags_None, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoBackground); + const char *status = "Waiting for browser sign-in"; + const char *timeout = "This request expires after 3 minutes."; + const float spinner_size = ImGui::GetFontSize(); + const float status_width = spinner_size + 8 + ImGui::CalcTextSize(status).x; + ImGui::SetCursorPos(ImVec2((ImGui::GetWindowWidth() - status_width) * 0.5f, 16)); + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const float angle = std::fmod(ImGui::GetTime() * 4.0, 2.0 * IM_PI); + auto *draw_list = ImGui::GetWindowDrawList(); + draw_list->PathArcTo(ImVec2(pos.x + spinner_size * 0.5f, pos.y + spinner_size * 0.5f), + spinner_size * 0.35f, angle, angle + IM_PI * 1.5f, 24); + draw_list->PathStroke(ImGui::GetColorU32(p.accent), 0, 2.0f); + ImGui::Dummy(ImVec2(spinner_size, spinner_size)); + ImGui::SameLine(0, 8); + ImGui::TextUnformatted(status); + ImGui::SetCursorPos(ImVec2((ImGui::GetWindowWidth() - ImGui::CalcTextSize(timeout).x) * 0.5f, 40)); + ImGui::TextUnformatted(timeout); + ImGui::EndChild(); + ImGui::SetCursorPosY(controls_y + 2 * (button_options.height + 2 * gap)); + ImGui::SetCursorPosX(buttons_x); + if (iconTextButton("auth_retry", "", "Choose another method", button_width, button_options)) { + *auth_abort_ = true; + auth_abort_.reset(); + } + } else { + ImGui::TextWrapped("%s", intro.c_str()); + ImGui::SetCursorPosY(controls_y); + const char *selected_method = nullptr; + for (int i = 0; i < 3; ++i) { + ImGui::SetCursorPosX(buttons_x); + if (iconTextButton(methods[i], icons[i], std::string("Sign in with ") + providers[i], + button_width, button_options)) selected_method = methods[i]; + ImGui::Spacing(); + } + const char *message = s_.auth_error.empty() ? hint : s_.auth_error.c_str(); + const float width = ImGui::GetContentRegionAvail().x - 16; + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (width - ImGui::CalcTextSize(message).x) * 0.5f)); + ImGui::TextWrapped("%s", message); + // Finish drawing this state before signIn clears its error text. + if (selected_method) signIn(selected_method); + } + ImGui::PopTextWrapPos(); + ImGui::Unindent(16); + ImGui::EndChild(); + ImGui::Separator(); + bool rejected = false; + dialogButtons("Cancel", &rejected, nullptr, true, nullptr); + if (rejected || dialogEscapePressed()) { + ImGui::CloseCurrentPopup(); + finish(false); + } +} diff --git a/openpilot/tools/cabana/ui/dialogs/routesdialog.h b/openpilot/tools/cabana/ui/dialogs/routesdialog.h index 8f84096973..c0f6db791c 100644 --- a/openpilot/tools/cabana/ui/dialogs/routesdialog.h +++ b/openpilot/tools/cabana/ui/dialogs/routesdialog.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -11,10 +12,15 @@ // "Remote routes" browser. on_done gets accepted=true with the selected route name ("" if none), accepted=false on cancel. class RoutesDialog { public: + ~RoutesDialog() { if (auth_abort_) *auth_abort_ = true; } void open(std::function on_done); void draw(); + bool isActive() const { return alive_ != nullptr; } private: + void fetchDevices(); + void signIn(const std::string &provider); + void drawLogin(); void setDeviceList(const std::vector &devices, bool success, int error_code); void setRouteList(const std::vector &list, bool success); void fetchRoutes(); @@ -26,6 +32,9 @@ private: }; struct State { + bool login = false; + std::string provider; + std::string auth_error; bool devices_loaded = false; std::vector devices; int device_index = 0; @@ -36,6 +45,7 @@ private: int fetch_id = 0; // the reply of an older request is dropped }; + std::shared_ptr> auth_abort_; bool open_ = false; PopupOwner popup_; State s_; diff --git a/openpilot/tools/cabana/ui/dialogs/streamselector.cc b/openpilot/tools/cabana/ui/dialogs/streamselector.cc index 17cae9635c..d320216109 100644 --- a/openpilot/tools/cabana/ui/dialogs/streamselector.cc +++ b/openpilot/tools/cabana/ui/dialogs/streamselector.cc @@ -22,11 +22,13 @@ void OpenReplayWidget::draw() { ImGui::GetStyle().ItemSpacing.x * 2)); inputText("##route", &route_, "Enter a route name or browse for a local or remote route"); ImGui::SameLine(); + ImGui::BeginDisabled(routes_dialog_.isActive()); if (ImGui::Button("Remote Route...")) { routes_dialog_.open(utils::guarded(alive_, [this](bool accepted, const std::string &route) { if (accepted) route_ = route; })); } + ImGui::EndDisabled(); ImGui::SameLine(); if (ImGui::Button("Local Route...")) { FileDialog::getExistingDirectory("Open Local Route", settings.last_route_dir, utils::guarded(alive_, [this](const std::string &dir) { diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index 1b7a2a7837..b11ecf28fa 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -224,23 +224,33 @@ bool iconButton(const char *id, const char *icon, const char *tooltip) { return clicked; } -float iconTextButtonWidth(const char *icon, const std::string &text) { +float iconTextButtonWidth(const char *icon, const std::string &text, const IconTextButtonOptions &options) { const ImGuiStyle &style = ImGui::GetStyle(); - return ImGui::CalcTextSize(icon).x + style.ItemInnerSpacing.x + ImGui::CalcTextSize(text.c_str(), nullptr, true).x + style.FramePadding.x * 2; + const float gap = *icon ? (options.icon_gap >= 0.0f ? options.icon_gap : style.ItemInnerSpacing.x) : 0.0f; + return ImGui::CalcTextSize(icon).x + gap + std::max(ImGui::CalcTextSize(text.c_str(), nullptr, true).x, options.label_width) + + style.FramePadding.x * 2; } -bool iconTextButton(const char *id, const char *icon, const std::string &text, float width) { +bool iconTextButton(const char *id, const char *icon, const std::string &text, float width, const IconTextButtonOptions &options) { const ImGuiStyle &style = ImGui::GetStyle(); - if (width <= 0.0f) width = iconTextButtonWidth(icon, text); - const bool clicked = ImGui::Button((std::string("###") + id).c_str(), ImVec2(width, 0.0f)); + const float icon_width = ImGui::CalcTextSize(icon).x; + const float gap = *icon ? (options.icon_gap >= 0.0f ? options.icon_gap : style.ItemInnerSpacing.x) : 0.0f; + const float text_width = ImGui::CalcTextSize(text.c_str(), nullptr, true).x; + const float label_width = std::max(text_width, options.label_width); + if (width <= 0.0f) width = icon_width + gap + label_width + style.FramePadding.x * 2; + if (options.rounding >= 0.0f) ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, options.rounding); + const bool clicked = ImGui::Button((std::string("###") + id).c_str(), ImVec2(width, options.height)); + if (options.rounding >= 0.0f) ImGui::PopStyleVar(); const ImVec2 min = ImGui::GetItemRectMin(), max = ImGui::GetItemRectMax(); + const float top = min.y + (max.y - min.y - ImGui::GetFontSize()) * 0.5f; + const float left = min.x + (options.center_content ? std::max(style.FramePadding.x, (max.x - min.x - icon_width - gap - label_width) * 0.5f) + : style.FramePadding.x); + const float text_left = left + icon_width + gap; + const float slack = options.center_content ? 0.0f : std::max(0.0f, (max.x - style.FramePadding.x - text_left - text_width) * 0.5f); const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text); auto *draw_list = ImGui::GetWindowDrawList(); - draw_list->AddText(ImVec2(min.x + style.FramePadding.x, min.y + style.FramePadding.y), color, icon); - // the text is centered between the icon and the right padding - const float left = min.x + style.FramePadding.x + ImGui::CalcTextSize(icon).x + style.ItemInnerSpacing.x; - const float slack = max.x - style.FramePadding.x - left - ImGui::CalcTextSize(text.c_str(), nullptr, true).x; - draw_list->AddText(ImVec2(left + std::max(0.0f, slack * 0.5f), min.y + style.FramePadding.y), color, text.c_str()); + draw_list->AddText(ImVec2(left, top), color, icon); + draw_list->AddText(ImVec2(text_left + slack, top), color, text.c_str()); return clicked; } diff --git a/openpilot/tools/cabana/ui/util.h b/openpilot/tools/cabana/ui/util.h index 3f3cabb71a..d8f11700a7 100644 --- a/openpilot/tools/cabana/ui/util.h +++ b/openpilot/tools/cabana/ui/util.h @@ -84,8 +84,16 @@ inline std::string shortcut(const char *keys) { return std::string(MOD_KEY) + "+ bool iconButton(const char *id, const char *icon, const char *tooltip = nullptr); float iconButtonWidth(); bool stepButton(const char *id, bool increment, const char *tooltip = nullptr); -bool iconTextButton(const char *id, const char *icon, const std::string &text, float width = 0.0f); -float iconTextButtonWidth(const char *icon, const std::string &text); +struct IconTextButtonOptions { + float height = 0.0f; + float rounding = -1.0f; // Negative uses the theme default. + float icon_gap = -1.0f; // Negative uses the theme default. + bool center_content = false; + float label_width = 0.0f; // Shared width aligns labels in a group of centered buttons. +}; +bool iconTextButton(const char *id, const char *icon, const std::string &text, float width = 0.0f, + const IconTextButtonOptions &options = {}); +float iconTextButtonWidth(const char *icon, const std::string &text, const IconTextButtonOptions &options = {}); // tooltip for the last item that also shows while the item is disabled void disabledItemTooltip(const char *text); From e6f9b3c6c49bf56dab9688ae846a718d031109af Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:46:14 -0700 Subject: [PATCH 100/122] cabana: preserve menu bar border on highlight (#38902) * cabana: preserve menu bar border on highlight * cabana: remove border drawing comment --- openpilot/tools/cabana/ui/mainwin.cc | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index 48239602bf..aa33e8adc2 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -138,11 +138,6 @@ void MainWindow::drawMenuBar() { const bool open = ImGui::BeginMainMenuBar(); ImGui::PopStyleVar(); if (!open) return; - { - const ImVec2 min = ImGui::GetWindowPos(); - const ImVec2 max(min.x + ImGui::GetWindowWidth(), min.y + ImGui::GetWindowHeight()); - ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(min.x, max.y - 1.0f), max, ImGui::GetColorU32(ImGuiCol_Border)); - } if (dropdown::BeginMenu("File")) { drawFileMenu(); dropdown::EndMenu(); @@ -181,6 +176,9 @@ void MainWindow::drawMenuBar() { if (dropdown::Item("Help", "F1")) toggleHelp(); dropdown::EndMenu(); } + const ImVec2 min = ImGui::GetWindowPos(); + const ImVec2 max(min.x + ImGui::GetWindowWidth(), min.y + ImGui::GetWindowHeight()); + ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(min.x, max.y - 1.0f), max, ImGui::GetColorU32(ImGuiCol_Border)); ImGui::EndMainMenuBar(); } From 5c365d3398e92acac97ddaf93e2dfa054ddf87d9 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:46:29 -0700 Subject: [PATCH 101/122] cabana: remove inner video container (#38901) --- openpilot/tools/cabana/ui/mainwin.cc | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index aa33e8adc2..545d846b12 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -863,10 +863,8 @@ void MainWindow::drawVideoPanel() { if (video_widget_ && !video_open) { video_widget_->setVisible(false); // the dock is collapsed or tabbed behind another one, like hideEvent } else if (video_widget_) { - ImGui::BeginChild("video", ImVec2(0, 0), ImGuiChildFlags_Borders); help_overlay_.add(video_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); video_widget_->draw(); - ImGui::EndChild(); } ImGui::End(); if (!video_visible_ && floating) video_visible_ = reset_layout_ = true; From 4e60afe70d7f76b11e3270280bef628aa13e135a Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:48:35 -0700 Subject: [PATCH 102/122] cabana: show persistent fps counter (#38898) * cabana: show persistent UI FPS * cabana: render whole fps in monospace * cabana: keep fps aligned beside centered progress * cabana: move fps to top right * Revert "cabana: move fps to top right" This reverts commit 51a5334aec79807f8102b9a6f1f902f22814d5c7. --- openpilot/tools/cabana/ui/mainwin.cc | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index 545d846b12..2e42b79242 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -716,12 +716,16 @@ void MainWindow::drawStatusBar() { // WindowPadding.x, which lines the text up with the content of the docked panels above (the messages table). const float width = ImGui::GetContentRegionAvail().x; const float pad = ImGui::GetStyle().WindowPadding.x; - const float progress_width = std::min(300.0f, std::max(0.0f, width - 2 * pad)); - const float progress_x = width - pad - progress_width; - const float message_end = status_bar_.progress_visible ? progress_x - ImGui::GetStyle().ItemSpacing.x : width - pad; + pushMonoFont(ImGui::GetStyle().FontSizeBase); + const float fps_x = std::max(pad, width - pad - ImGui::CalcTextSize("999 FPS").x); + popMonoFont(); + const float progress_width = std::min(300.0f, std::max(0.0f, fps_x - pad - 20.0f)); + const float progress_x = fps_x - progress_width - 10.0f; + const float message_end = status_bar_.progress_visible ? progress_x - ImGui::GetStyle().ItemSpacing.x : fps_x - 10.0f; ImGui::PushClipRect(min, ImVec2(min.x + std::max(pad, message_end), min.y + ImGui::GetWindowHeight()), true); ImGui::SetCursorPosX(pad); ImGui::AlignTextToFramePadding(); + const float text_y = ImGui::GetCursorPosY(); // a temporary message hides the normal widgets, permanent widgets stay on the right auto &bar = status_bar_; if (!bar.message.empty() && (bar.message_until == 0 || ImGui::GetTime() < bar.message_until)) { @@ -745,6 +749,12 @@ void MainWindow::drawStatusBar() { ImGui::PopFont(); ImGui::SetItemTooltip("%s", bar.progress_text.c_str()); } + ImGui::SameLine(fps_x); + ImGui::SetCursorPosY(text_y); + pushMonoFont(ImGui::GetStyle().FontSizeBase); + ImGui::Text("%3.0f FPS", ImGui::GetIO().Framerate); + popMonoFont(); + ImGui::SetItemTooltip("UI rendering rate (frames per second)"); ImGui::EndChild(); ImGui::PopStyleColor(); } @@ -789,7 +799,7 @@ void MainWindow::drawDockspace() { // the status bar sits below the dockspace: reserve its height plus the item spacing between the two, // otherwise the host window is a few pixels taller than the viewport and scrolls - const float status_height = full_screen_ ? 0.0f : ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.y; + const float status_height = ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.y; const ImVec2 dock_size(ImGui::GetContentRegionAvail().x, ImGui::GetContentRegionAvail().y - status_height); const ImGuiID dock_id = ImGui::GetID("cabana_dockspace"); if (reset_layout_ || ImGui::DockBuilderGetNode(dock_id) == nullptr || @@ -815,7 +825,7 @@ void MainWindow::drawDockspace() { ImGui::PushStyleVar(ImGuiStyleVar_WindowMinSize, ImVec2(min_panel_width, ImGui::GetStyle().WindowMinSize.y)); ImGui::DockSpace(dock_id, dock_size); ImGui::PopStyleVar(); - if (!full_screen_) drawStatusBar(); + drawStatusBar(); ImGui::End(); } From 2ae3598b51829b4fbed16605739bae272c90e1b2 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:56:21 -0700 Subject: [PATCH 103/122] cabana: prevent signal panel collapse (#38903) * cabana: keep signal scrolling inside the list * cabana: reserve space for the signal panel --- openpilot/tools/cabana/ui/widgets/detailwidget.cc | 7 ++++--- openpilot/tools/cabana/ui/widgets/signalview.cc | 13 ++++++++++--- openpilot/tools/cabana/ui/widgets/signalview.h | 1 + 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc index 988cf4b3cb..8f42b63d80 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.cc +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -218,16 +218,17 @@ void DetailWidget::drawTabWidget() { ImGui::BeginChild("page", ImVec2(0, std::max(page_rect.GetHeight() - pill_height - gap, 1.0f)), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); if (tab_widget_index_ == 0) { - // binary_view_ keeps its size hint, signal_view_ takes the rest + // Reserve the signal toolbar and rows before giving the byte grid its preferred height. const float min_height = binary_view_->minimumSizeHint().y; const float avail = ImGui::GetContentRegionAvail().y; - const float max_height = std::max(avail - style.ItemSpacing.y - 1.0f, 1.0f); + const float max_height = std::max(avail - style.ItemSpacing.y - SignalView::minimumHeight(), 1.0f); const float height = std::clamp(min_height, 1.0f, max_height); ImGui::BeginChild("binary_view", ImVec2(0, height), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar); binary_view_rect_ = ImGui::GetCurrentWindow()->Rect(); binary_view_->draw(); ImGui::EndChild(); - ImGui::BeginChild("signal_view", ImVec2(0, 0)); + ImGui::BeginChild("signal_view", ImVec2(0, 0), ImGuiChildFlags_None, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); signal_view_rect_ = ImGui::GetCurrentWindow()->Rect(); signal_view_->draw(); ImGui::EndChild(); diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index 269df6158c..48a74fd111 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -690,9 +690,16 @@ float SignalView::minimumWidth() { return left_width + style.ItemSpacing.x + toolBarRightWidth("00:00") + (style.WindowPadding.x + style.ChildBorderSize) * 2; } +float SignalView::minimumHeight() { + const ImGuiStyle &style = ImGui::GetStyle(); + return ImGui::GetFrameHeight() + style.ItemSpacing.y + signalRowHeight() * 3 + + (style.WindowPadding.y + style.ChildBorderSize + CONTROL_OUTLINE_PADDING) * 2; +} + void SignalView::draw() { ImGui::PushStyleColor(ImGuiCol_ChildBg, palette().surface); - if (!ImGui::BeginChild("SignalView", ImVec2(0, 0), ImGuiChildFlags_Borders)) { + if (!ImGui::BeginChild("SignalView", ImVec2(0, 0), ImGuiChildFlags_Borders, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { ImGui::EndChild(); ImGui::PopStyleColor(); return; @@ -740,8 +747,8 @@ void SignalView::collapseAll() { void SignalView::drawTree() { ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); - const float min_height = std::max(ImGui::GetContentRegionAvail().y, 300.0f); - const bool visible = beginControlChild("tree", ImVec2(0, min_height)); + // Keep the toolbar fixed; only the signal rows scroll within the remaining space. + const bool visible = beginControlChild("tree", ImVec2(0, 0)); if (visible) { DrawContext ctx{ImGui::GetWindowDrawList(), ImGui::GetCursorScreenPos().x, ImGui::GetContentRegionAvail().x, rowHeight()}; // the press that closes an open editor is consumed by the focus change, the index widgets never see it diff --git a/openpilot/tools/cabana/ui/widgets/signalview.h b/openpilot/tools/cabana/ui/widgets/signalview.h index dc77ad7b37..d6ba246097 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.h +++ b/openpilot/tools/cabana/ui/widgets/signalview.h @@ -110,6 +110,7 @@ public: void setMessage(const MessageId &id); void draw(); static float minimumWidth(); + static float minimumHeight(); void signalHovered(const cabana::Signal *sig); // handler for BinaryView::signalHovered void updateChartState(); void selectSignal(const cabana::Signal *sig, bool expand = false); From c715ef47627d1d7ae69577641667e25aeeb4317c Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:15:25 -0700 Subject: [PATCH 104/122] cabana: flatten signal panel containers (#38904) * cabana: flatten signal panel containers * cabana: keep messages and logs switch border --- openpilot/tools/cabana/ui/widgets/detailwidget.cc | 2 +- openpilot/tools/cabana/ui/widgets/signalview.cc | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc index 8f42b63d80..6bfcccf144 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.cc +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -281,7 +281,7 @@ void DetailWidget::drawTabWidget() { void DetailWidget::draw() { tabbar_.draw(); - ImGui::BeginChild("message_content", ImVec2(0, 0), ImGuiChildFlags_Borders, + ImGui::BeginChild("message_content", ImVec2(0, 0), ImGuiChildFlags_AlwaysUseWindowPadding, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); drawToolBar(); diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index 48a74fd111..c8ed72f956 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -682,12 +682,12 @@ float SignalView::toolBarRightWidth(const std::string &range_label) { } // the width at which the tool bar stops squishing: the signal count and the filter box on the left, the -// sparkline controls on the right, plus the borders and padding of the view's own child window +// sparkline controls on the right. Padding is supplied by the message panel. float SignalView::minimumWidth() { const ImGuiStyle &style = ImGui::GetStyle(); const float left_width = ImGui::CalcTextSize("Signals: 000").x + style.ItemSpacing.x + FILTER_WIDTH; // formatSeconds is mm:ss for every value the range slider allows - return left_width + style.ItemSpacing.x + toolBarRightWidth("00:00") + (style.WindowPadding.x + style.ChildBorderSize) * 2; + return left_width + style.ItemSpacing.x + toolBarRightWidth("00:00") + style.WindowPadding.x * 2; } float SignalView::minimumHeight() { @@ -698,7 +698,7 @@ float SignalView::minimumHeight() { void SignalView::draw() { ImGui::PushStyleColor(ImGuiCol_ChildBg, palette().surface); - if (!ImGui::BeginChild("SignalView", ImVec2(0, 0), ImGuiChildFlags_Borders, + if (!ImGui::BeginChild("SignalView", ImVec2(0, 0), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { ImGui::EndChild(); ImGui::PopStyleColor(); @@ -885,6 +885,8 @@ void SignalView::drawIndexWidget(SignalModel::Item *item, const ImRect &rect) { const ImVec2 size = indexButtonsSize(iconButtonWidth()); ImGui::SetCursorScreenPos(ImVec2(rect.Max.x - size.x, rect.Min.y + (rect.GetHeight() - size.y) * 0.5f)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0)); const auto sig = item->sig; const bool checked = item->chart_opened; if (checked) ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive)); @@ -899,6 +901,8 @@ void SignalView::drawIndexWidget(SignalModel::Item *item, const ImRect &rect) { pending_action_ = [this, sig]() { UndoStack::instance()->push(new RemoveSigCommand(model_.msgId(), sig)); }; } ImGui::SetItemTooltip("Remove signal"); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); button_size_ = size; } From 164a3a1e307c8efcd8ad03f1533d82732af410df Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:27:40 -0700 Subject: [PATCH 105/122] cabana: stop chart tooltips over menus (#38905) * cabana: keep chart tooltips below menus * cabana: exclude popup menus from plot hover --- openpilot/tools/cabana/ui/chart/chart.cc | 13 +++++++------ openpilot/tools/cabana/ui/chart/chartswidget.cc | 2 +- openpilot/tools/cabana/ui/chart/tiplabel.cc | 2 +- openpilot/tools/cabana/ui/chart/tiplabel.h | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/openpilot/tools/cabana/ui/chart/chart.cc b/openpilot/tools/cabana/ui/chart/chart.cc index 4f1d7891ea..1c319b4faf 100644 --- a/openpilot/tools/cabana/ui/chart/chart.cc +++ b/openpilot/tools/cabana/ui/chart/chart.cc @@ -504,13 +504,13 @@ void ChartView::draw(float width) { updateLayout(); paint(); drawContextMenu(); + // Keep the tip above the plot, but below popup menus and other windows. + ImRect visible_rect = charts_widget_->chartVisibleRect(this); + visible_rect.ClipWith(ImRect(ImVec2(layout_.rect.Min.x, layout_.plot_area.Min.y), + ImVec2(layout_.rect.Max.x, layout_.plot_area.Max.y))); + if (!drawing_ghost_ && visible_rect.GetWidth() > 0 && visible_rect.GetHeight() > 0) tip_label_.draw(visible_rect); } ImGui::EndChild(); - // a chart scrolled out of the viewport draws no tip - ImRect visible_rect = charts_widget_->chartVisibleRect(this); - visible_rect.ClipWith(ImRect(ImVec2(layout_.rect.Min.x, layout_.plot_area.Min.y), - ImVec2(layout_.rect.Max.x, layout_.plot_area.Max.y))); - if (!drawing_ghost_ && visible_rect.GetWidth() > 0 && visible_rect.GetHeight() > 0) tip_label_.draw(visible_rect); ImGui::PopID(); } @@ -575,7 +575,8 @@ void ChartView::drawAxes() { layout_.plot_area = ImRect(ImPlot::GetPlotPos(), ImPlot::GetPlotPos() + ImPlot::GetPlotSize()); // ImPlotFlags_NoInputs disables implot's own hover tracking - layout_.plot_hovered = layout_.plot_area.Contains(ImGui::GetMousePos()) && ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows | ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); + // A popup is a descendant of the chart, but hovering its menu must not hover the plot underneath. + layout_.plot_hovered = layout_.plot_area.Contains(ImGui::GetMousePos()) && ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); drawSeries(); if (!drawing_ghost_) { // Own plot clicks so custom scrubbing/zooming cannot also move the floating window. diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.cc b/openpilot/tools/cabana/ui/chart/chartswidget.cc index 12bf656a68..c89dc702db 100644 --- a/openpilot/tools/cabana/ui/chart/chartswidget.cc +++ b/openpilot/tools/cabana/ui/chart/chartswidget.cc @@ -551,7 +551,7 @@ void ChartsWidget::handleEvents() { if (!value_tip_visible_) return; - // the tip is drawn on the foreground draw list, so the mouse is never "on the tip" + // The tip is drawn without an input item, so the mouse is never "on the tip". const ImVec2 delta = ImGui::GetIO().MouseDelta; if (!any_plot_hovered_ && (delta.x != 0 || delta.y != 0 || !ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows))) { diff --git a/openpilot/tools/cabana/ui/chart/tiplabel.cc b/openpilot/tools/cabana/ui/chart/tiplabel.cc index 6f3a1ee54e..83c693babf 100644 --- a/openpilot/tools/cabana/ui/chart/tiplabel.cc +++ b/openpilot/tools/cabana/ui/chart/tiplabel.cc @@ -134,7 +134,7 @@ void TipLabel::draw(const ImRect &rect) { updateLayout(); if (!visible_) return; - ImDrawList *p = ImGui::GetForegroundDrawList(); + ImDrawList *p = ImGui::GetWindowDrawList(); p->PushClipRect(area_.Min, area_.Max, true); // filled panel with a 1px frame p->AddRectFilled(pos_, pos_ + size_, ImGui::GetColorU32(ImGuiCol_PopupBg), ImGui::GetStyle().PopupRounding); diff --git a/openpilot/tools/cabana/ui/chart/tiplabel.h b/openpilot/tools/cabana/ui/chart/tiplabel.h index 48f20b59f8..7128a56384 100644 --- a/openpilot/tools/cabana/ui/chart/tiplabel.h +++ b/openpilot/tools/cabana/ui/chart/tiplabel.h @@ -20,7 +20,7 @@ public: void showText(const ImVec2 &pt, const std::vector &text, const ImRect &rect); void hide() { visible_ = false; } bool isVisible() const { return visible_; } - void draw(const ImRect &rect); // draws the tip on the foreground draw list; call once per frame + void draw(const ImRect &rect); // call inside the owning chart window, after drawing the plot private: // lays the lines out from origin, drawing them when p is given; returns the size of the text block From 5319474ee57abeaafcd787a69e66823c873e1f93 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:31:19 -0700 Subject: [PATCH 106/122] cabana: disable tool window collapse (#38906) --- openpilot/tools/cabana/ui/tools/tooldialog.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/tools/cabana/ui/tools/tooldialog.h b/openpilot/tools/cabana/ui/tools/tooldialog.h index 06807bcd33..4a8aeab7b2 100644 --- a/openpilot/tools/cabana/ui/tools/tooldialog.h +++ b/openpilot/tools/cabana/ui/tools/tooldialog.h @@ -28,7 +28,7 @@ protected: ImGui::SetNextWindowSize(size, ImGuiCond_Appearing); setNextWindowFloatsOut(); began_ = true; - return visible_ = ImGui::Begin(title_.c_str(), &open_, ImGuiWindowFlags_NoSavedSettings); + return visible_ = ImGui::Begin(title_.c_str(), &open_, ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoCollapse); } bool end() { From 885792de3fbcf0eff5c558dd4070cb4fb0f135fa Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:36:16 -0700 Subject: [PATCH 107/122] cabana: remove message table outer border (#38907) --- openpilot/tools/cabana/ui/widgets/messageswidget.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpilot/tools/cabana/ui/widgets/messageswidget.cc b/openpilot/tools/cabana/ui/widgets/messageswidget.cc index a12942aa83..a3cacea680 100644 --- a/openpilot/tools/cabana/ui/widgets/messageswidget.cc +++ b/openpilot/tools/cabana/ui/widgets/messageswidget.cc @@ -365,8 +365,8 @@ void MessagesWidget::drawTable() { const bool multiple_lines = settings.multiple_lines_hex; const ImGuiTableFlags flags = ImGuiTableFlags_Sortable | ImGuiTableFlags_Resizable | ImGuiTableFlags_Reorderable | - ImGuiTableFlags_ScrollX | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Borders | - ImGuiTableFlags_Hideable; + ImGuiTableFlags_ScrollX | ImGuiTableFlags_ScrollY | ImGuiTableFlags_BordersInner | + ImGuiTableFlags_Hideable | ImGuiTableFlags_PadOuterX; // with ScrollX a stretch column needs an explicit inner width const float bytes_width = bytesCellSize(bytes_section_bytes_, multiple_lines).x; const float avail_width = ImGui::GetContentRegionAvail().x - (has_scrollbar_y_ ? ImGui::GetStyle().ScrollbarSize : 0); From d3106c2b4b8a0f168496837eab88f95fac74cc87 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:43:53 -0700 Subject: [PATCH 108/122] cabana: unify video and live stream titles (#38908) * cabana: unify video and live stream titles * cabana: put stream before dbc in window title * cabana: show fingerprint in route details --- openpilot/tools/cabana/ui/mainwin.cc | 13 ++++++++----- openpilot/tools/cabana/ui/mainwin.h | 2 +- openpilot/tools/cabana/ui/tools/routeinfo.cc | 6 ++++-- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index 2e42b79242..674f2c3aa1 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -156,7 +156,7 @@ void MainWindow::drawMenuBar() { if (dropdown::Item("Full Screen", shortcut("F11").c_str())) toggleFullScreen(); ImGui::Separator(); dropdown::Item(messages_widget_ ? messages_widget_->title().c_str() : "MESSAGES", nullptr, &messages_visible_); - dropdown::Item(video_dock_title_.empty() ? "Video" : video_dock_title_.c_str(), nullptr, &video_visible_); + dropdown::Item(videoPanelTitle(), nullptr, &video_visible_); dropdown::Item("Charts", nullptr, &charts_visible_); ImGui::Separator(); if (dropdown::Item("Reset Window Layout")) { @@ -199,11 +199,15 @@ void MainWindow::showStatusMessage(const std::string &msg, int timeout_ms) { void MainWindow::updateWindowTitle() { std::string title; - for (auto f : dbc()->allDBCFiles()) { + for (auto f : dbc()->nonEmptyDBCFiles()) { if (!title.empty()) title += " | "; title += "(" + toString(dbc()->sources(f)) + ") " + f->name(); } if (window_modified_) title += "*"; + if (hasStream()) { + const std::string stream_title = can->liveStreaming() ? videoPanelTitle() : can->routeName(); + title = title.empty() ? stream_title : stream_title + " \xe2\x80\x94 " + title; + } if (!title.empty()) title += " \xe2\x80\x94 "; // em dash separator title += "Cabana"; glfwSetWindowTitle(window_, title.c_str()); @@ -362,12 +366,12 @@ void MainWindow::startStream(std::unique_ptr stream, const std:: MessageBox::warning("Error", msg); })); can->start(); + updateWindowTitle(); loadFile(dbc_file, SOURCE_ALL, [this]() { showStatusMessage("Stream [" + can->routeName() + "] started", 2000); createDockWidgets(); - video_dock_title_ = can->routeName(); // Don't overwrite already loaded DBC if (!dbc()->nonEmptyDBCCount()) { newFile(); @@ -391,7 +395,6 @@ void MainWindow::startStream(std::unique_ptr stream, const std:: void MainWindow::eventsMerged() { const std::string fingerprint = can->carFingerprint(); if (!can->liveStreaming() && std::exchange(car_fingerprint_, fingerprint) != fingerprint) { - video_dock_title_ = "ROUTE: " + can->routeName() + " FINGERPRINT: " + (car_fingerprint_.empty() ? "Unknown Car" : car_fingerprint_); // Don't overwrite already loaded DBC auto it = fingerprint_to_dbc_.find(car_fingerprint_); if (!dbc()->nonEmptyDBCCount() && it != fingerprint_to_dbc_.end()) { @@ -866,7 +869,7 @@ void MainWindow::drawMessagesPanel() { } void MainWindow::drawVideoPanel() { - const std::string name = (video_dock_title_.empty() ? "Video" : video_dock_title_) + VIDEO_PANEL; + const std::string name = std::string(videoPanelTitle()) + VIDEO_PANEL; setNextPanelClass(); const bool video_open = beginPanel(name.c_str(), &video_visible_); const bool floating = floatingOut(); diff --git a/openpilot/tools/cabana/ui/mainwin.h b/openpilot/tools/cabana/ui/mainwin.h index 840ca0990a..b7afa1769d 100644 --- a/openpilot/tools/cabana/ui/mainwin.h +++ b/openpilot/tools/cabana/ui/mainwin.h @@ -45,6 +45,7 @@ public: private: bool hasStream() const { return dynamic_cast(can) == nullptr; } + const char *videoPanelTitle() const { return hasStream() && can->liveStreaming() ? "Live Stream" : "Video"; } void releaseStream(); void startStream(std::unique_ptr stream, const std::string &dbc_file); void loadStartupStream(const std::string &dbc_file); @@ -101,7 +102,6 @@ private: std::vector opendbc_names_; enum { MAX_RECENT_FILES = 15 }; std::string car_fingerprint_; - std::string video_dock_title_; bool messages_visible_ = true; bool video_visible_ = true; bool charts_visible_ = true; diff --git a/openpilot/tools/cabana/ui/tools/routeinfo.cc b/openpilot/tools/cabana/ui/tools/routeinfo.cc index 1761da2a1f..92b77cf2a8 100644 --- a/openpilot/tools/cabana/ui/tools/routeinfo.cc +++ b/openpilot/tools/cabana/ui/tools/routeinfo.cc @@ -16,10 +16,12 @@ bool RouteInfoDlg::draw() { static const char *headers[] = {"", "rlog", "narrow road", "wide road", "driver", "qlog", "qcam"}; auto yn = [](const std::string &s) { return s.empty() ? "--" : "Yes"; }; const auto &segments = replay_->route().segments(); - // minimum size: header + min(rowCount, 13) rows + // minimum size: fingerprint, header, and min(rowCount, 13) rows float row_h = ImGui::GetTextLineHeightWithSpacing(); - float min_h = row_h * (std::min((int)segments.size(), 13) + 1) + ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().WindowPadding.y * 2; + float min_h = row_h * (std::min((int)segments.size(), 13) + 2) + ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().WindowPadding.y * 2; if (begin(ImVec2(520, min_h))) { + const std::string fingerprint = replay_->carFingerprint(); + ImGui::TextWrapped("Fingerprint: %s", fingerprint.empty() ? "Unknown" : fingerprint.c_str()); const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingFixedFit; if (ImGui::BeginTable("table", 7, flags, ImVec2(0, 0))) { ImGui::TableSetupScrollFreeze(0, 1); From 64e5d1e5e387cb35b8419ad25e53f6dbe687969a Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:54:34 -0700 Subject: [PATCH 109/122] cabana: preserve selected menu color on hover (#38909) * cabana: unify menu highlight colors * cabana: preserve selected menu color on hover --- openpilot/tools/cabana/ui/dropdown.h | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openpilot/tools/cabana/ui/dropdown.h b/openpilot/tools/cabana/ui/dropdown.h index b73d8e39a2..e6ce25f16b 100644 --- a/openpilot/tools/cabana/ui/dropdown.h +++ b/openpilot/tools/cabana/ui/dropdown.h @@ -86,7 +86,12 @@ inline void EndPopup() { inline bool BeginMenu(const char *label, bool enabled = true) { PopupViewportScope viewport_scope; pushStyle(); - return finishBegin(ImGui::BeginMenu(label, enabled)); + // An open menu keeps its selection color while the pointer is over its label. + const bool selected = ImGui::IsPopupOpen(label); + if (selected) ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImGui::GetStyleColorVec4(ImGuiCol_Header)); + const bool open = ImGui::BeginMenu(label, enabled); + if (selected) ImGui::PopStyleColor(); + return finishBegin(open); } inline void EndMenu() { ImGui::PopStyleVar(); From 5b34f151de990e780ce8f30feb0a0b13b64b5bc7 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:01:04 -0700 Subject: [PATCH 110/122] cabana: balance signal action spacing (#38911) --- openpilot/tools/cabana/ui/widgets/signalview.cc | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index c8ed72f956..5b2eab8b43 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -495,7 +495,9 @@ void SignalView::drawValueDescriptionDlg() { } static ImVec2 indexButtonsSize(float button) { - return ImVec2(button * 2 + ImGui::GetStyle().ItemSpacing.x, button); + const auto &style = ImGui::GetStyle(); + // Include the value-to-button gap; the extra padding balances the inset icon glyphs. + return ImVec2(button * 2 + style.ItemInnerSpacing.x * 2 + style.FramePadding.x, button); } SignalView::SignalView(ChartsWidget *charts) : charts_(charts) { @@ -750,6 +752,7 @@ void SignalView::drawTree() { // Keep the toolbar fixed; only the signal rows scroll within the remaining space. const bool visible = beginControlChild("tree", ImVec2(0, 0)); if (visible) { + button_size_ = indexButtonsSize(iconButtonWidth()); DrawContext ctx{ImGui::GetWindowDrawList(), ImGui::GetCursorScreenPos().x, ImGui::GetContentRegionAvail().x, rowHeight()}; // the press that closes an open editor is consumed by the focus change, the index widgets never see it if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) editor_open_on_press_ = open_item_ != nullptr; @@ -881,9 +884,11 @@ bool SignalView::drawItem(SignalModel::Item *item, int depth, DrawContext &ctx) } void SignalView::drawIndexWidget(SignalModel::Item *item, const ImRect &rect) { - const float spacing = ImGui::GetStyle().ItemSpacing.x; - const ImVec2 size = indexButtonsSize(iconButtonWidth()); - ImGui::SetCursorScreenPos(ImVec2(rect.Max.x - size.x, rect.Min.y + (rect.GetHeight() - size.y) * 0.5f)); + const float spacing = ImGui::GetStyle().ItemInnerSpacing.x; + const float button = iconButtonWidth(); + // Anchor fixed-size buttons inside the viewport, which excludes the scrollbar. + ImGui::SetCursorScreenPos(ImVec2(rect.Max.x - H_MARGIN - button * 2 - spacing, + rect.Min.y + (rect.GetHeight() - button) * 0.5f)); ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0)); @@ -903,7 +908,6 @@ void SignalView::drawIndexWidget(SignalModel::Item *item, const ImRect &rect) { ImGui::SetItemTooltip("Remove signal"); ImGui::PopStyleColor(); ImGui::PopStyleVar(); - button_size_ = size; } ValueDescriptionDlg::ValueDescriptionDlg(const ValueDescription &descriptions) { From 5f50bda7ba1a78862d0886c75f03125446bc6f1b Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:06:52 -0700 Subject: [PATCH 111/122] cabana: improve signal panel layout (#38910) * cabana: improve signal panel layout * cabana: match row actions to selection colors * cabana: preserve master remove action * cabana: match remove icon to selected row --- .../tools/cabana/ui/widgets/detailwidget.cc | 87 ++++++------------- .../tools/cabana/ui/widgets/detailwidget.h | 2 + .../tools/cabana/ui/widgets/signalview.cc | 86 ++++++++++++------ .../tools/cabana/ui/widgets/signalview.h | 1 + 4 files changed, 86 insertions(+), 90 deletions(-) diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc index 6bfcccf144..830a51b889 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.cc +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -52,6 +52,13 @@ DetailWidget::DetailWidget(ChartsWidget *charts) : charts_(charts) { signal_view_ = std::make_unique(charts); history_log_ = std::make_unique(); + page_tabs_.addTab("Messages"); + page_tabs_.addTab("Logs"); + connections_.push_back(page_tabs_.currentChanged.connect([this](int index) { + tab_widget_index_ = index; + if (index == 1) history_log_->onShown(); + updateState(); + })); connections_.push_back(binary_view_->signalHovered.connect([this](const cabana::Signal *s) { signal_view_->signalHovered(s); })); connections_.push_back(binary_view_->signalClicked.connect([this](const cabana::Signal *s) { signal_view_->selectSignal(s, true); })); @@ -84,13 +91,16 @@ void DetailWidget::drawToolBar() { }}); items.back().in_menu = false; const size_t spacer_index = items.size(); - const std::string heatmap_text = "Heatmap: " + (heatmap_live_ ? std::string("Live") : heatmap_all_text_); + const std::string heatmap_text = "Heatmap: " + (!heatmap_visible_ ? std::string("Hidden") : heatmap_live_ ? std::string("Live") : heatmap_all_text_); auto heatmap_items = [this]() { - if (dropdown::Item("Live", nullptr, heatmap_live_) && !heatmap_live_) { + if (dropdown::Item("Hidden", nullptr, !heatmap_visible_)) heatmap_visible_ = false; + if (dropdown::Item("Live", nullptr, heatmap_visible_ && heatmap_live_)) { + heatmap_visible_ = true; heatmap_live_ = true; binary_view_->setHeatmapLiveMode(true); } - if (dropdown::Item(heatmap_all_text_.c_str(), nullptr, !heatmap_live_) && heatmap_live_) { + if (dropdown::Item(heatmap_all_text_.c_str(), nullptr, heatmap_visible_ && !heatmap_live_)) { + heatmap_visible_ = true; heatmap_live_ = false; binary_view_->setHeatmapLiveMode(false); } @@ -210,23 +220,18 @@ void DetailWidget::editMsg(float parent_width) { void DetailWidget::drawTabWidget() { const ImGuiStyle &style = ImGui::GetStyle(); - const float pad = style.ItemInnerSpacing.x, pill_height = ImGui::GetFrameHeight() + pad * 2; - ImGui::BeginChild("tab_widget", ImVec2(0, 0), ImGuiChildFlags_None, - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); - const ImRect page_rect = ImGui::GetCurrentWindow()->Rect(); - const float gap = style.WindowPadding.y; - ImGui::BeginChild("page", ImVec2(0, std::max(page_rect.GetHeight() - pill_height - gap, 1.0f)), - ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + page_tabs_.draw(); if (tab_widget_index_ == 0) { - // Reserve the signal toolbar and rows before giving the byte grid its preferred height. - const float min_height = binary_view_->minimumSizeHint().y; - const float avail = ImGui::GetContentRegionAvail().y; - const float max_height = std::max(avail - style.ItemSpacing.y - SignalView::minimumHeight(), 1.0f); - const float height = std::clamp(min_height, 1.0f, max_height); - ImGui::BeginChild("binary_view", ImVec2(0, height), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar); - binary_view_rect_ = ImGui::GetCurrentWindow()->Rect(); - binary_view_->draw(); - ImGui::EndChild(); + if (heatmap_visible_) { + // Keep most of a short window available for signal rows. + const float avail = ImGui::GetContentRegionAvail().y; + const float max_height = std::max(avail - style.ItemSpacing.y - SignalView::minimumHeight(), 1.0f); + const float height = std::min(binary_view_->minimumSizeHint().y, max_height); + ImGui::BeginChild("binary_view", ImVec2(0, std::max(height, 1.0f)), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar); + binary_view_rect_ = ImGui::GetCurrentWindow()->Rect(); + binary_view_->draw(); + ImGui::EndChild(); + } ImGui::BeginChild("signal_view", ImVec2(0, 0), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); signal_view_rect_ = ImGui::GetCurrentWindow()->Rect(); @@ -235,48 +240,6 @@ void DetailWidget::drawTabWidget() { } else { history_log_->draw(); } - ImGui::EndChild(); - - std::string labels[] = {std::string(icon::FILE_EARMARK_RULED) + " Messages", std::string(icon::STOPWATCH) + " Logs"}; - auto pill_width = [&]() { - float w = pad; - for (const auto &label : labels) w += ImGui::CalcTextSize(label.c_str()).x + style.FramePadding.x * 2 + pad; - return w; - }; - float width = pill_width(); - if (width > page_rect.GetWidth()) { - labels[0] = icon::FILE_EARMARK_RULED; - labels[1] = icon::STOPWATCH; - width = pill_width(); - } - const ImVec2 size(width, pill_height); - const ImVec2 min(std::round(page_rect.GetCenter().x - width * 0.5f), page_rect.Max.y - size.y); - ImGui::SetNextWindowPos(min); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(pad, pad)); - ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::GetColorU32(ImGuiCol_PopupBg)); - ImGui::BeginChild("page_switch", size, ImGuiChildFlags_Borders | ImGuiChildFlags_AlwaysUseWindowPadding, - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); - ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(pad, 0.0f)); - for (int i = 0; i < 2; ++i) { - const bool selected = tab_widget_index_ == i; - ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetColorU32(selected ? ImGuiCol_Header : ImGuiCol_Button, selected ? 1.0f : 0.0f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::GetColorU32(selected ? ImGuiCol_HeaderActive : ImGuiCol_ButtonHovered)); - ImGui::PushStyleColor(ImGuiCol_ButtonActive, selected ? palette().header_active : palette().button_active); - ImGui::PushStyleColor(ImGuiCol_Text, selected ? palette().text_selected : palette().text); - if (i) ImGui::SameLine(); - if (ImGui::Button(labels[i].c_str()) && !selected) { - tab_widget_index_ = i; - if (i == 1) history_log_->onShown(); - updateState(); - } - ImGui::PopStyleColor(4); - } - ImGui::PopStyleVar(2); - ImGui::EndChild(); - ImGui::PopStyleColor(); - ImGui::PopStyleVar(); - ImGui::EndChild(); } void DetailWidget::draw() { @@ -307,7 +270,7 @@ void DetailWidget::draw() { std::vector> DetailWidget::helpRects() const { std::vector> rects; if (tab_widget_index_ == 0) { - rects.emplace_back(binary_view_->whatsThis(), binary_view_rect_); + if (heatmap_visible_) rects.emplace_back(binary_view_->whatsThis(), binary_view_rect_); rects.emplace_back(signal_view_->whatsThis(), signal_view_rect_); } return rects; diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.h b/openpilot/tools/cabana/ui/widgets/detailwidget.h index 7f8efde99c..4745ed5c84 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.h +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.h @@ -82,9 +82,11 @@ private: ElidedLabel name_label_; bool warning_widget_visible_ = false; TabBar tabbar_; + TabBar page_tabs_; int tab_widget_index_ = 0; bool action_remove_msg_enabled_ = false; bool heatmap_live_ = true; + bool heatmap_visible_ = true; std::string heatmap_all_text_ = "All"; ImRect binary_view_rect_, signal_view_rect_; // child window rects of the last drawTabWidget std::unique_ptr history_log_; diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index 5b2eab8b43..bc8b9c4d23 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -244,13 +244,14 @@ float SignalView::textWidth(const std::string &text, float font_size) { } float SignalView::nameColumnWidth(const SignalModel::Item *item, float widget_width, const std::string &text) const { - float spacing = INDENTATION + COLOR_LABEL_WIDTH + 8; + float spacing = INDENTATION + COLOR_LABEL_WIDTH + H_MARGIN * 4; std::string txt = text; if (item->type == SignalModel::Item::Sig && item->sig->type != cabana::Signal::Type::Normal) { txt += multiplexIndicator(item->sig); spacing += H_MARGIN * 2; } - return std::min(widget_width / 3.0, textWidth(txt) + spacing); + const float preferred = ImGui::CalcTextSize(txt.c_str()).x + spacing; + return std::min(widget_width * (compact_ ? 0.6f : 0.4f), preferred); } void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const SignalModel::Item *item, int column, @@ -286,7 +287,7 @@ void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const // name if (rect.GetWidth() > 0) drawElidedText(painter, rect, text, text_color, false); } else if (column == 1) { - if (!item->sparkline.isEmpty()) { + if (!compact_ && !item->sparkline.isEmpty()) { const ImVec2 sparkline_size = item->sparkline.size; item->sparkline.draw(painter, rect.Min, selected ? text_color : 0); // min-max value @@ -649,7 +650,7 @@ void SignalView::updateState(const std::set *msgs) { max_value_width = std::max(max_value_width, widestValueWidth(item->sig)); } - if (first_visible_row_ != -1 && last_visible_row_ != -1 && last_visible_row_ < model_.rowCount()) { + if (!compact_ && first_visible_row_ != -1 && last_visible_row_ != -1 && last_visible_row_ < model_.rowCount()) { const float min_max_width = textWidth("-000.00", MINMAX_FONT) + 5; float available_width = value_column_width_ - button_size_.x; float value_width = std::min(max_value_width + min_max_width, available_width / 2); @@ -694,7 +695,7 @@ float SignalView::minimumWidth() { float SignalView::minimumHeight() { const ImGuiStyle &style = ImGui::GetStyle(); - return ImGui::GetFrameHeight() + style.ItemSpacing.y + signalRowHeight() * 3 + + return ImGui::GetFrameHeightWithSpacing() + signalRowHeight() * 4 + (style.WindowPadding.y + style.ChildBorderSize + CONTROL_OUTLINE_PADDING) * 2; } @@ -707,26 +708,37 @@ void SignalView::draw() { return; } - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(signal_count_lb_.c_str()); - ImGui::SameLine(); - ImGui::SetNextItemWidth(FILTER_WIDTH); - if (clearableInput("##filter_edit", &filter_edit_, "Filter Signal", nonWhitespaceValidator)) { - model_.setFilter(filter_edit_); - } - - // stretch: the sparkline controls sit at the right edge - alignRight(toolBarRightWidth(sparkline_label_)); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(sparkline_label_.c_str()); - ImGui::SameLine(); - int range = settings.sparkline_range; - if (fusionSliderInt("##sparkline_range_slider", &range, 1, SPARKLINE_RANGE_MAX, SPARKLINE_SLIDER_WIDTH)) { - setSparklineRange(range); - } - ImGui::SetItemTooltip("Sparkline time range"); - ImGui::SameLine(); - if (iconButton("collapse_all", icon::ARROWS_COLLAPSE, "Collapse All")) collapseAll(); + const ImGuiStyle &style = ImGui::GetStyle(); + const float width = ImGui::GetContentRegionAvail().x; + compact_ = width < minimumWidth() - style.WindowPadding.x * 2; + const float count_width = ImGui::CalcTextSize(signal_count_lb_.c_str()).x; + // The standard overflow button keeps the range and collapse actions reachable on narrow panels. + const float filter_width = compact_ ? std::clamp(width - count_width - iconButtonWidth() - style.ItemSpacing.x * 2, + 1.0f, FILTER_WIDTH) : FILTER_WIDTH; + std::vector items; + items.push_back({count_width, [this]() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(signal_count_lb_.c_str()); + }}); + items.back().in_menu = false; + items.push_back({filter_width, [this, filter_width]() { + ImGui::SetNextItemWidth(filter_width); + if (clearableInput("##filter_edit", &filter_edit_, "Filter Signal", nonWhitespaceValidator)) { + model_.setFilter(filter_edit_); + } + }}); + items.push_back({toolBarRightWidth(sparkline_label_) - iconButtonWidth() - style.ItemSpacing.x, [this]() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(sparkline_label_.c_str()); + ImGui::SameLine(); + int range = settings.sparkline_range; + if (fusionSliderInt("##sparkline_range_slider", &range, 1, SPARKLINE_RANGE_MAX, SPARKLINE_SLIDER_WIDTH)) { + setSparklineRange(range); + } + ImGui::SetItemTooltip("Sparkline time range"); + }}); + items.push_back(toolbarAction("collapse_all", icon::ARROWS_COLLAPSE, "Collapse All", [this]() { collapseAll(); })); + drawToolbar(items, 2); drawTree(); drawValueDescriptionDlg(); @@ -759,6 +771,13 @@ void SignalView::drawTree() { int first_visible = -1, last_visible = -1; auto &children = model_.root()->children; + // Measure before painting so resizing never leaves the old columns for a frame. + auto measure = [&](auto &&self, SignalModel::Item *item) -> void { + ctx.name_width = std::max(ctx.name_width, nameColumnWidth(item, ctx.width, nameText(item))); + if (item->expanded) for (auto child : item->children) self(self, child); + }; + for (auto item : children) measure(measure, item); + if (ctx.name_width > 0) name_column_width_ = ctx.name_width; for (int i = 0; i < children.size(); ++i) { ctx.any_visible = false; const bool header_visible = drawItem(children[i], 0, ctx); @@ -894,15 +913,26 @@ void SignalView::drawIndexWidget(SignalModel::Item *item, const ImRect &rect) { ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0)); const auto sig = item->sig; const bool checked = item->chart_opened; - if (checked) ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive)); - if (iconButton("plot", icon::GRAPH_UP) && !editor_open_on_press_) { + const bool selected = current_sig_ == sig && current_type_ == SignalModel::Item::Sig; + auto row_button = [selected](const char *id, const char *glyph) { + if (selected) { + ImGui::PushStyleColor(ImGuiCol_Text, palette().text_selected); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, palette().header_active); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, palette().header_active); + } + const bool clicked = iconButton(id, glyph); + if (selected) ImGui::PopStyleColor(3); + return clicked; + }; + if (checked) ImGui::PushStyleColor(ImGuiCol_Button, selected ? palette().header_active : ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive)); + if (row_button("plot", icon::GRAPH_UP) && !editor_open_on_press_) { item->chart_opened = !checked; showChart(model_.msgId(), sig, item->chart_opened, ImGui::GetIO().KeyShift); } if (checked) ImGui::PopStyleColor(); ImGui::SetItemTooltip("%s", checked ? "Close Plot" : "Show Plot\nShift-click to add to the previously opened plot"); ImGui::SameLine(0.0f, spacing); - if (iconButton("remove", icon::X_LG) && !editor_open_on_press_) { + if (row_button("remove", icon::X_LG) && !editor_open_on_press_) { pending_action_ = [this, sig]() { UndoStack::instance()->push(new RemoveSigCommand(model_.msgId(), sig)); }; } ImGui::SetItemTooltip("Remove signal"); diff --git a/openpilot/tools/cabana/ui/widgets/signalview.h b/openpilot/tools/cabana/ui/widgets/signalview.h index d6ba246097..1ef830ca50 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.h +++ b/openpilot/tools/cabana/ui/widgets/signalview.h @@ -166,6 +166,7 @@ private: static ValidState validateEditor(const SignalModel::Item *item, std::string &text); float value_column_width_ = 0; + bool compact_ = false; float name_column_width_ = 150; bool editor_open_on_press_ = false; // computed while drawing the tree: the first top-level row whose own row is visible (a signal whose header From 26899177578542b7eb7b8b2795f755e73bd17b1c Mon Sep 17 00:00:00 2001 From: Matt Purnell <65473602+mpurnell1@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:43:43 -0500 Subject: [PATCH 112/122] locationd: fix critical_services spelling (#38912) --- openpilot/selfdrive/locationd/locationd.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openpilot/selfdrive/locationd/locationd.py b/openpilot/selfdrive/locationd/locationd.py index 9fbec991d5..3c76793e1c 100755 --- a/openpilot/selfdrive/locationd/locationd.py +++ b/openpilot/selfdrive/locationd/locationd.py @@ -278,12 +278,12 @@ def main(): estimator = LocationEstimator(DEBUG) filter_initialized = False - critcal_services = ["accelerometer", "gyroscope", "cameraOdometry"] + critical_services = ["accelerometer", "gyroscope", "cameraOdometry"] observation_input_invalid = defaultdict(int) - input_invalid_limit = {s: round(INPUT_INVALID_LIMIT * (SERVICE_LIST[s].frequency / 20.)) for s in critcal_services} - input_invalid_threshold = {s: input_invalid_limit[s] - 0.5 for s in critcal_services} - input_invalid_decay = {s: calculate_invalid_input_decay(input_invalid_limit[s], INPUT_INVALID_RECOVERY, SERVICE_LIST[s].frequency) for s in critcal_services} + input_invalid_limit = {s: round(INPUT_INVALID_LIMIT * (SERVICE_LIST[s].frequency / 20.)) for s in critical_services} + input_invalid_threshold = {s: input_invalid_limit[s] - 0.5 for s in critical_services} + input_invalid_decay = {s: calculate_invalid_input_decay(input_invalid_limit[s], INPUT_INVALID_RECOVERY, SERVICE_LIST[s].frequency) for s in critical_services} initial_pose_data = params.get("LocationFilterInitialState") if initial_pose_data is not None: @@ -313,7 +313,7 @@ def main(): if valid: t = log_mono_time * 1e-9 res = estimator.handle_log(t, which, msg) - if which not in critcal_services: + if which not in critical_services: continue if res == HandleLogResult.TIMING_INVALID: @@ -328,7 +328,7 @@ def main(): filter_initialized = sm.all_checks() and sensor_all_checks(acc_msgs, gyro_msgs, sensor_valid, sensor_recv_time, sensor_alive, SIMULATION) if sm.updated["cameraOdometry"]: - critical_service_inputs_valid = all(observation_input_invalid[s] < input_invalid_threshold[s] for s in critcal_services) + critical_service_inputs_valid = all(observation_input_invalid[s] < input_invalid_threshold[s] for s in critical_services) inputs_valid = sm.all_valid() and critical_service_inputs_valid sensors_valid = sensor_all_checks(acc_msgs, gyro_msgs, sensor_valid, sensor_recv_time, sensor_alive, SIMULATION) From e6aa405e08e41428fac6dd01bc98f391aaa49ed2 Mon Sep 17 00:00:00 2001 From: stef <19478336+stefpi@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:14:38 -0400 Subject: [PATCH 113/122] ui: fix touch validity on alerts menu (#38915) fix touch validity on alerts menu --- openpilot/system/ui/widgets/scroller.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/openpilot/system/ui/widgets/scroller.py b/openpilot/system/ui/widgets/scroller.py index 55195b30fb..e79c4592fc 100644 --- a/openpilot/system/ui/widgets/scroller.py +++ b/openpilot/system/ui/widgets/scroller.py @@ -146,9 +146,9 @@ class _Scroller(Widget): # preserve original touch valid callback original_touch_valid_callback = item._touch_valid_callback - item.set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid() and self.enabled and not self._scrolling_to[2] - and not self.moving_items and (original_touch_valid_callback() if - original_touch_valid_callback else True)) + item.set_touch_valid_callback(lambda: self._touch_valid() and self.scroll_panel.is_touch_valid() + and self.enabled and not self._scrolling_to[2] and not self.moving_items + and (original_touch_valid_callback() if original_touch_valid_callback else True)) def add_widgets(self, items: Sequence[Widget]) -> None: for item in items: @@ -397,6 +397,10 @@ class Scroller(Widget): # pass down enabled to child widget for nav stack self._scroller.set_enabled(lambda: self.enabled) + def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: + super().set_touch_valid_callback(touch_callback) + self._scroller.set_touch_valid_callback(touch_callback) + def _render(self, _, /): self._scroller.render(self._rect) From ffef0e6d3ba10c9fac470b4d316db92f05ca1d35 Mon Sep 17 00:00:00 2001 From: stef <19478336+stefpi@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:03:25 -0400 Subject: [PATCH 114/122] ui: custom alert icons (#38917) * custom alerts and alert pill better * add comment back * fix --- openpilot/selfdrive/ui/mici/layouts/home.py | 32 +++++++---------- openpilot/selfdrive/ui/mici/layouts/main.py | 2 +- .../ui/mici/layouts/offroad_alerts.py | 36 ++++++++++++------- 3 files changed, 38 insertions(+), 32 deletions(-) diff --git a/openpilot/selfdrive/ui/mici/layouts/home.py b/openpilot/selfdrive/ui/mici/layouts/home.py index 9517701b50..b852bf9ea6 100644 --- a/openpilot/selfdrive/ui/mici/layouts/home.py +++ b/openpilot/selfdrive/ui/mici/layouts/home.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import datetime import math import time @@ -39,16 +41,13 @@ class AlertsPill(Widget): self.set_rect(rl.Rectangle(0, 0, 104, 52)) self._pill_bg_txt = gui_app.texture("icons_mici/alerts_pill.png", 104, 52) - self._icon_red = gui_app.texture("icons_mici/offroad_alerts/red_warning.png", 36, 36) - self._icon_orange = gui_app.texture("icons_mici/offroad_alerts/orange_warning.png", 36, 36) - self._icon_green = gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", 36, 36) self._alert_count_callback: Callable[[], int] | None = None - self._max_severity_callback: Callable[[], int | None] | None = None + self._alert_icon_callback: Callable[[], rl.Texture | None] | None = None def set_alert_count_callback(self, callback: Callable[[], int] | None, - severity_callback: Callable[[], int | None] | None = None): + icon_callback: Callable[[], rl.Texture | None] | None = None): self._alert_count_callback = callback - self._max_severity_callback = severity_callback + self._alert_icon_callback = icon_callback def _render(self, _): alert_count = self._alert_count_callback() if self._alert_count_callback else 0 @@ -56,17 +55,12 @@ class AlertsPill(Widget): pill_w, pill_h = self._pill_bg_txt.width, self._pill_bg_txt.height rl.draw_texture_ex(self._pill_bg_txt, rl.Vector2(self.rect.x, self.rect.y), 0.0, 1.0, rl.WHITE) - severity = self._max_severity_callback() if self._max_severity_callback else None - if severity == -1: - warning_txt = self._icon_green - elif severity is not None and severity > 0: - warning_txt = self._icon_red - else: - warning_txt = self._icon_orange - - warn_x = self.rect.x + self.ICON_OFFSET - warn_y = self.rect.y + (pill_h - warning_txt.height) / 2 - rl.draw_texture_ex(warning_txt, rl.Vector2(warn_x, warn_y), 0.0, 1.0, rl.WHITE) + warning_txt = self._alert_icon_callback() if self._alert_icon_callback else None + if warning_txt is not None: + scale = 36 / max(warning_txt.width, warning_txt.height) + warn_x = self.rect.x + self.ICON_OFFSET + warn_y = self.rect.y + (pill_h - warning_txt.height * scale) / 2 + rl.draw_texture_ex(warning_txt, rl.Vector2(warn_x, warn_y), 0.0, scale, rl.WHITE) count_rect = rl.Rectangle(self.rect.x + self.COUNT_OFFSET, self.rect.y, pill_w - self.COUNT_OFFSET, pill_h) gui_label(count_rect, str(alert_count), font_size=36, @@ -187,11 +181,11 @@ class MiciHomeLayout(Widget): def set_callbacks(self, on_settings: Callable | None = None, on_alerts: Callable | None = None, alert_count_callback: Callable[[], int] | None = None, - max_severity_callback: Callable[[], int | None] | None = None): + alert_icon_callback: Callable[[], rl.Texture | None] | None = None): self._on_settings_click = on_settings self._on_alerts_click = on_alerts self._alert_count_callback = alert_count_callback - self._alerts_pill.set_alert_count_callback(alert_count_callback, max_severity_callback) + self._alerts_pill.set_alert_count_callback(alert_count_callback, alert_icon_callback) def _handle_mouse_release(self, mouse_pos: MousePos): if not self._did_long_press: diff --git a/openpilot/selfdrive/ui/mici/layouts/main.py b/openpilot/selfdrive/ui/mici/layouts/main.py index 7b37747ef7..5364e36da1 100644 --- a/openpilot/selfdrive/ui/mici/layouts/main.py +++ b/openpilot/selfdrive/ui/mici/layouts/main.py @@ -74,7 +74,7 @@ class MiciMainLayout(Scroller): on_settings=lambda: gui_app.push_widget(self._settings_layout), on_alerts=lambda: self._scroll_to(self._alerts_layout), alert_count_callback=self._alerts_layout.active_alerts, - max_severity_callback=self._alerts_layout.max_severity, + alert_icon_callback=self._alerts_layout.highest_severity_icon, ) for layout in (self._car_onroad_layout, self._body_onroad_layout): layout.set_click_callback(lambda: self._scroll_to(self._home_layout)) diff --git a/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py b/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py index 40e074d455..6175a753bb 100644 --- a/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py +++ b/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pyray as rl import re import threading @@ -29,6 +31,7 @@ class AlertData: text: str severity: int visible: bool = False + icon: str | None = None class AlertItem(Widget): @@ -56,10 +59,20 @@ class AlertItem(Widget): self._bg_big = gui_app.texture("icons_mici/offroad_alerts/big_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_BIG) self._bg_big_pressed = gui_app.texture("icons_mici/offroad_alerts/big_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_BIG) - # Load warning icons + # Load alert icons self._icon_orange = gui_app.texture("icons_mici/offroad_alerts/orange_warning.png", self.ICON_SIZE, self.ICON_SIZE) self._icon_red = gui_app.texture("icons_mici/offroad_alerts/red_warning.png", self.ICON_SIZE, self.ICON_SIZE) self._icon_green = gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", self.ICON_SIZE, self.ICON_SIZE) + self._custom_icon = gui_app.texture(alert_data.icon, self.ICON_SIZE, self.ICON_SIZE) if alert_data.icon else None + + if self._custom_icon is not None: + self._icon = self._custom_icon + elif alert_data.severity == -1: + self._icon = self._icon_green + elif alert_data.severity > 0: + self._icon = self._icon_red + else: + self._icon = self._icon_orange self._title_label = UnifiedLabel(text="", font_size=32, font_weight=FontWeight.SEMI_BOLD, text_color=self.TEXT_COLOR, alignment=TextAlignment.LEFT, @@ -75,6 +88,10 @@ class AlertItem(Widget): self._update_content() + @property + def icon(self) -> rl.Texture: + return self._icon + def _split_text(self, text: str) -> tuple[str, str]: """Split text into title (first sentence) and body (remaining text).""" # Find the end of the first sentence (period, exclamation, or question mark followed by space or end) @@ -176,16 +193,9 @@ class AlertItem(Widget): self._body_label.render(body_rect) # Draw warning icon on the right side - # Use green icon for update alerts (severity = -1), red for high severity, orange for low severity - if self.alert_data.severity == -1: - icon_texture = self._icon_green - elif self.alert_data.severity > 0: - icon_texture = self._icon_red - else: - icon_texture = self._icon_orange icon_x = self._rect.x + self.ALERT_WIDTH - self.ALERT_PADDING - self.ICON_SIZE icon_y = self._rect.y + self.ALERT_PADDING - rl.draw_texture_ex(icon_texture, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.WHITE) + rl.draw_texture_ex(self._icon, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.WHITE) class MiciOffroadAlerts(Scroller): @@ -214,8 +224,10 @@ class MiciOffroadAlerts(Scroller): def active_alerts(self) -> int: return sum(alert.visible for alert in self.sorted_alerts) - def max_severity(self) -> int | None: - return max((alert.severity for alert in self.sorted_alerts if alert.visible), default=None) + def highest_severity_icon(self) -> rl.Texture | None: + item = max((item for item in self.alert_items if item.alert_data.visible), + key=lambda item: (item.alert_data.severity, bool(item.alert_data.icon)), default=None) + return item.icon if item is not None else None def scrolling(self): return self._scroller.scroll_panel.is_touch_valid() @@ -235,7 +247,7 @@ class MiciOffroadAlerts(Scroller): # Add regular alerts sorted by severity for key, config in sorted(OFFROAD_ALERTS.items(), key=lambda x: x[1].get("severity", 0), reverse=True): severity = config.get("severity", 0) - alert_data = AlertData(key=key, text="", severity=severity) + alert_data = AlertData(key=key, text="", severity=severity, icon=config.get("icon")) self.sorted_alerts.append(alert_data) # Create alert item widget From 2c88d1ed645fc38bbb2621dfa3f7ad61921618c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Mon, 14 Sep 2026 20:20:53 -0700 Subject: [PATCH 115/122] Revert "bump msgq (#38836)" (#38920) This reverts commit 1e0914051fd22d963309b77996181d8b9a97e267. --- msgq_repo | 2 +- pyproject.toml | 4 ++-- uv.lock | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/msgq_repo b/msgq_repo index 326a9f5aa6..0e266c1dbc 160000 --- a/msgq_repo +++ b/msgq_repo @@ -1 +1 @@ -Subproject commit 326a9f5aa6cf630f647fd6996106aa267dd01c2a +Subproject commit 0e266c1dbcf7328beee3e57b4a8688555387c877 diff --git a/pyproject.toml b/pyproject.toml index 2c97e6444e..7be8f97c0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ tools = [ ] submodules = [ - "msgq-ipc", + "msgq", "opendbc", "pandacan", "rednose", @@ -155,7 +155,7 @@ override-dependencies = [ ] [tool.uv.sources] -msgq-ipc = { path = "msgq_repo", editable = true } +msgq = { path = "msgq_repo", editable = true } opendbc = { path = "opendbc_repo", editable = true } pandacan = { path = "panda", editable = true } rednose = { path = "rednose_repo", editable = true } diff --git a/uv.lock b/uv.lock index 9b1ffef34c..8144a10bfa 100644 --- a/uv.lock +++ b/uv.lock @@ -511,8 +511,8 @@ wheels = [ ] [[package]] -name = "msgq-ipc" -version = "1.1" +name = "msgq" +version = "0.0.1" source = { editable = "msgq_repo" } [package.metadata] @@ -617,7 +617,7 @@ dependencies = [ [package.optional-dependencies] submodules = [ - { name = "msgq-ipc" }, + { name = "msgq" }, { name = "opendbc" }, { name = "pandacan" }, { name = "rednose" }, @@ -663,7 +663,7 @@ requires-dist = [ { name = "inputs" }, { name = "jeepney" }, { name = "matplotlib", marker = "extra == 'tools'" }, - { name = "msgq-ipc", marker = "extra == 'submodules'", editable = "msgq_repo" }, + { name = "msgq", marker = "extra == 'submodules'", editable = "msgq_repo" }, { name = "numpy", specifier = ">=2.0" }, { name = "opendbc", marker = "extra == 'submodules'", editable = "opendbc_repo" }, { name = "pandacan", marker = "extra == 'submodules'", editable = "panda" }, From 7b469af4388e15ecb25cb0683dc89d3d22a71857 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Mon, 14 Sep 2026 20:21:16 -0700 Subject: [PATCH 116/122] ci: run docs builds on Namespace (#38921) --- .github/workflows/docs.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index c7229190e7..7997b86b7e 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -23,7 +23,12 @@ env: jobs: docs: name: build docs - runs-on: ubuntu-24.04 + runs-on: ${{ + (github.repository == 'commaai/openpilot') && + ((github.event_name != 'pull_request') || + (github.event.pull_request.head.repo.full_name == 'commaai/openpilot')) + && fromJSON('["namespace-profile-amd64-8x16"]') + || fromJSON('["ubuntu-24.04"]') }} steps: - uses: commaai/timeout@v1 - uses: actions/checkout@v7 From 64f9b47b6ea7b622967acd7042148e7a92f0ccc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Mon, 14 Sep 2026 20:44:33 -0700 Subject: [PATCH 117/122] modeld: move history queues into ONNX (#38916) * modeld: move history queues into ONNX * Move ONNX history export into xx exporter * Rename ONNX image input to new_img * Combine ONNX model history state * Revert model history README addition * Keep float32 input packing from master * Rename model input specs to input shapes --- openpilot/selfdrive/modeld/SConscript | 4 +- openpilot/selfdrive/modeld/compile_modeld.py | 120 +++++------------- openpilot/selfdrive/modeld/modeld.py | 15 +-- .../modeld/models/big_driving_supercombo.onnx | 4 +- .../modeld/models/driving_supercombo.onnx | 4 +- 5 files changed, 42 insertions(+), 105 deletions(-) diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index 2f3e19a315..a302016460 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -6,7 +6,6 @@ from SCons.Script import Action, Value from openpilot.common.file_chunker import chunk_file, get_chunk_targets, get_existing_chunks from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE, DM_INPUT_SIZE -from openpilot.selfdrive.modeld.constants import ModelConstants from openpilot.selfdrive.modeld.helpers import TG_INPUT_DEVICES_PATH, chestnut_present, modeld_pkl_path @@ -68,7 +67,6 @@ compile_modeld_script = [ File("#openpilot/common/hardware/hw.py"), ] model_w, model_h = MEDMODEL_INPUT_SIZE -frame_skip = ModelConstants.MODEL_RUN_FREQ // ModelConstants.MODEL_CONTEXT_FREQ for chestnut in [False, True] if CHESTNUT else [False]: target_pkl_path = File(modeld_pkl_path(chestnut)).abspath @@ -81,7 +79,7 @@ for chestnut in [False, True] if CHESTNUT else [False]: f'--model-size {model_w}x{model_h} ' f'--camera-resolutions {camera_res_args} ' f'--onnx {File(f"models/{file_prefix}driving_supercombo.onnx").abspath} ' - f'--output {target_pkl_path} --frame-skip {frame_skip}') + f'--output {target_pkl_path}') onnx_sizes_sum = sum(os.path.getsize(f) for f in driving_onnx_deps) chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum * len(camera_configs))) def do_compile(target, source, env, command=cmd, pkl=target_pkl_path, chunks=chunk_targets): diff --git a/openpilot/selfdrive/modeld/compile_modeld.py b/openpilot/selfdrive/modeld/compile_modeld.py index 52be6897c0..c2fd116f1a 100755 --- a/openpilot/selfdrive/modeld/compile_modeld.py +++ b/openpilot/selfdrive/modeld/compile_modeld.py @@ -37,7 +37,6 @@ from tinygrad.engine.jit import TinyJit NV12Frame = namedtuple("NV12Frame", ['width', 'height', 'stride', 'y_height', 'uv_height', 'size']) -MODELD_INPUTS = ['img_q', 'big_img_q', 'feat_q', 'desire_q', 'packed_npy_inputs'] def nv12_copy_size(stride: int, y_height: int, uv_height: int) -> int: @@ -113,58 +112,26 @@ def make_frame_prepare(nv12: NV12Frame, model_w, model_h): return frame_prepare_tinygrad -def get_policy_npy_shapes(input_shapes): - dp = input_shapes['desire_pulse'] # (1, 25, 8) - tc = input_shapes['traffic_convention'] # (1, 2) - at = input_shapes['action_t'] # (1, 2) - fb = input_shapes['features_buffer'] # (1, T-1, ...) e.g. (1, 24, 32, 512) with spatial features - feat_dim = math.prod(fb[2:]) - # TODO prev_feat shouldn't exist and be handled inside the JIT, but corrupt on QCOM for now - shapes = {'desire': (dp[2],), 'traffic_convention': tuple(tc), 'action_t': tuple(at), 'prev_feat': (fb[0], feat_dim)} +def get_npy_shapes(input_shapes, state_pairs): + shapes = {'tfm': (3, 3), 'big_tfm': (3, 3)} | { + name: shape for name, (shape, _) in input_shapes.items() if name not in state_pairs and name != 'new_img'} return shapes, [math.prod(s) for s in shapes.values()] -def make_input_queues(input_shapes, frame_skip, device, frame_copy_size): - img = input_shapes['img'] # (1, 12, 128, 256) - fb = input_shapes['features_buffer'] # (1, T-1, ...), past features only; the model appends the current frame's feature - feat_dim = math.prod(fb[2:]) - dp = input_shapes['desire_pulse'] # (1, 25, 8) - n_frames = img[1] // 6 - img_buf_shape = (frame_skip * (n_frames - 1) + 1, 6, img[2], img[3]) - - policy_shapes, _ = get_policy_npy_shapes(input_shapes) - shapes = {'tfm': (3, 3), 'big_tfm': (3, 3)} | policy_shapes - sizes = [math.prod(s) for s in shapes.values()] +def make_input_queues(input_shapes, state_pairs, device, frame_copy_size): + shapes, sizes = get_npy_shapes(input_shapes, state_pairs) packed_npy_size = sum(sizes) * np.dtype(np.float32).itemsize packed_input = np.zeros(packed_npy_size + 2 * frame_copy_size, dtype=np.uint8) packed_npy_inputs = packed_input[:packed_npy_size].view(np.float32) frames = packed_input[packed_npy_size:] frame_views = {'img': frames[:frame_copy_size], 'big_img': frames[frame_copy_size:]} - # views into the packed inputs, to be refilled at runtime npy = {k: v.reshape(s) for (k, s), v in zip(shapes.items(), np.split(packed_npy_inputs, np.cumsum(sizes[:-1])), strict=True)} - input_queues = { - 'img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(), - 'big_img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(), - 'feat_q': Tensor(np.zeros((frame_skip * fb[1], fb[0], feat_dim), dtype=np.float32), device=device).contiguous().realize(), - 'desire_q': Tensor(np.zeros((frame_skip * dp[1], dp[0], dp[2]), dtype=np.float32), device=device).contiguous().realize(), - 'packed_npy_inputs': Tensor(packed_input, device='NPY').realize(), - } + input_queues = {name: Tensor(np.zeros(shape, dtype=dtype.fmt), device=device).realize() + for name, (shape, dtype) in input_shapes.items() if name in state_pairs} + input_queues['packed_npy_inputs'] = Tensor(packed_input, device='NPY').realize() return input_queues, npy, frame_views -def shift_and_sample(buf, new_val, sample_fn): - buf.assign(buf[1:].cat(new_val, dim=0).contiguous()) - return sample_fn(buf) - - -def sample_skip(buf, frame_skip): - return buf[::frame_skip].contiguous().flatten(0, 1).unsqueeze(0) - - -def sample_desire(buf, frame_skip): - return buf.reshape(-1, frame_skip, *buf.shape[1:]).max(1).flatten(0, 1).unsqueeze(0) - - def make_warp(nv12, model_w, model_h): frame_prepare = make_frame_prepare(nv12, model_w, model_h) @@ -182,54 +149,27 @@ def make_warp(nv12, model_w, model_h): return warp -def make_run_policy(model_runner, model_metadata, frame_skip): - sample_desire_fn = partial(sample_desire, frame_skip=frame_skip) - sample_skip_fn = partial(sample_skip, frame_skip=frame_skip) - npy_shapes, npy_sizes = get_policy_npy_shapes(model_metadata['input_shapes']) - model_input_dtypes = {name: spec.dtype for name, spec in model_runner.graph_inputs.items()} +def make_run_model(warp, model_runner, input_shapes, state_pairs, frame_copy_size): + shapes, sizes = get_npy_shapes(input_shapes, state_pairs) + packed_npy_size = sum(sizes) * np.dtype(np.float32).itemsize - def run_policy(warped, img_q, big_img_q, feat_q, desire_q, packed_npy_inputs): - packed_npy_inputs = packed_npy_inputs.to(Device.DEFAULT) - Tensor.realize(packed_npy_inputs, warped) - - img = shift_and_sample(img_q, warped[0:1], sample_skip_fn) - big_img = shift_and_sample(big_img_q, warped[1:2], sample_skip_fn) - - desire, traffic_convention, action_t, prev_feat = (t.reshape(s) for t, s in zip(packed_npy_inputs.split(npy_sizes), npy_shapes.values(), strict=True)) - desire_buf = shift_and_sample(desire_q, desire.reshape(1, 1, -1), sample_desire_fn) - feat_buf = shift_and_sample(feat_q, prev_feat.reshape(1, 1, -1), sample_skip_fn) - - inputs = { - 'img': img, - 'big_img': big_img, - 'features_buffer': feat_buf.reshape(model_metadata['input_shapes']['features_buffer']), - 'desire_pulse': desire_buf, - 'traffic_convention': traffic_convention, - 'action_t': action_t, - } - inputs = {name: value.cast(model_input_dtypes[name]) for name, value in inputs.items()} - out = next(iter(model_runner(inputs).values())).cast('float32') - return out, - return run_policy - - -def make_run_model(warp, run_policy, model_metadata, frame_copy_size): - _, policy_sizes = get_policy_npy_shapes(model_metadata['input_shapes']) - packed_npy_size = (18 + sum(policy_sizes)) * np.dtype(np.float32).itemsize - - def run_model(img_q, big_img_q, feat_q, desire_q, packed_npy_inputs): - packed_input = packed_npy_inputs.to(Device.DEFAULT) - Tensor.realize(packed_input) + def run_model(packed_npy_inputs, **state_inputs): + packed_input = packed_npy_inputs.to(Device.DEFAULT).realize() packed_npy_inputs = packed_input[:packed_npy_size].bitcast('float32') + inputs = {name: t.reshape(s) for (name, s), t in zip(shapes.items(), packed_npy_inputs.split(sizes), strict=True)} frame = packed_input[packed_npy_size:packed_npy_size + frame_copy_size] big_frame = packed_input[packed_npy_size + frame_copy_size:] - tfm, big_tfm, policy_inputs = packed_npy_inputs.split([9, 9, sum(policy_sizes)]) - warped = warp(tfm.reshape(3, 3), big_tfm.reshape(3, 3), frame, big_frame) - return run_policy(warped, img_q, big_img_q, feat_q, desire_q, policy_inputs) + inputs['new_img'] = warp(inputs.pop('tfm'), inputs.pop('big_tfm'), frame, big_frame) + inputs = {name: value.cast(input_shapes[name][1]) for name, value in inputs.items()} + outputs = {name: value.contiguous() for name, value in model_runner(inputs | state_inputs).items()} + Tensor.realize(*outputs.values()) + if state_pairs: + Tensor.realize(*(state_inputs[name].assign(outputs[next_name]) for name, next_name in state_pairs.items())) + return tuple(value for name, value in outputs.items() if name not in state_pairs.values()) return run_model -def compile_jit(jit, input_keys, make_queues, benchmark_runs): +def compile_jit(jit, make_queues, benchmark_runs): if benchmark_runs < 1: raise ValueError("benchmark_runs must be at least 1") @@ -245,7 +185,7 @@ def compile_jit(jit, input_keys, make_queues, benchmark_runs): v[:] = rng.integers(0, 256, size=v.shape, dtype=np.uint8) Device.default.synchronize() st = time.perf_counter() - outs = fn(**{k: input_queues[k] for k in input_keys}) + outs = fn(**input_queues) mt = time.perf_counter() Device.default.synchronize() et = time.perf_counter() @@ -300,7 +240,6 @@ if __name__ == "__main__": help='camera resolutions WxH (one or more)') p.add_argument('--onnx', required=True) p.add_argument('--output', required=True) - p.add_argument('--frame-skip', type=int, required=True) p.add_argument('--benchmark-runs', type=int, default=1, help='timed loaded-JIT runs for each correctness seed') args = p.parse_args() @@ -309,23 +248,24 @@ if __name__ == "__main__": model_w, model_h = args.model_size model_runner = OnnxRunner(model_path) + input_shapes = {name: (spec.shape, spec.dtype) for name, spec in model_runner.graph_inputs.items()} + state_pairs = {name: f'next_{name}' for name in input_shapes if f'next_{name}' in model_runner.graph_outputs} out = { 'metadata': make_metadata_dict(model_path), + 'input_shapes': input_shapes, + 'state_pairs': state_pairs, 'input_devices': {'model': Device.DEFAULT}, 'run_model': {}, } - run_policy = make_run_policy(model_runner, out['metadata'], args.frame_skip) - for cam_w, cam_h in args.camera_resolutions: nv12 = NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) frame_copy_size = nv12_copy_size(nv12.stride, nv12.y_height, nv12.uv_height) - make_model_queues = partial(make_input_queues, out['metadata']['input_shapes'], args.frame_skip, + make_model_queues = partial(make_input_queues, input_shapes, state_pairs, frame_copy_size=frame_copy_size) warp = make_warp(nv12, model_w, model_h) - run_model_jit = TinyJit(make_run_model(warp, run_policy, out['metadata'], frame_copy_size), prune=True) - out['run_model'][(cam_w,cam_h)] = compile_jit(run_model_jit, MODELD_INPUTS, make_model_queues, - args.benchmark_runs) + run_model_jit = TinyJit(make_run_model(warp, model_runner, input_shapes, state_pairs, frame_copy_size), prune=True) + out['run_model'][(cam_w,cam_h)] = compile_jit(run_model_jit, make_model_queues, args.benchmark_runs) with open(args.output, "wb") as f: dump_oob(out, f) diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index a05361145a..994fda8b35 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -26,7 +26,7 @@ from openpilot.common.transformations.model import get_warp_matrix from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper from openpilot.selfdrive.controls.lib.drive_helpers import get_accel_from_plan, should_stop, smooth_value, get_curvature_from_plan from openpilot.selfdrive.modeld.parse_model_outputs import Parser -from openpilot.selfdrive.modeld.compile_modeld import make_input_queues, nv12_copy_size, MODELD_INPUTS +from openpilot.selfdrive.modeld.compile_modeld import make_input_queues, nv12_copy_size from openpilot.selfdrive.modeld.fill_model_msg import fill_model_msg, fill_driving_model_data, fill_pose_msg, PublishState from openpilot.common.file_chunker import open_file_chunked from openpilot.selfdrive.modeld.constants import ModelConstants, Plan @@ -132,17 +132,17 @@ class ModelState: input_devices = jits['input_devices'] self.model_device = input_devices['model'] metadata = jits['metadata'] - self.input_shapes = metadata['input_shapes'] - self.vision_input_names = [k for k in self.input_shapes if 'img' in k] + self.input_shapes = jits['input_shapes'] + self.state_pairs = jits['state_pairs'] + self.vision_input_names = ('img', 'big_img') self.output_slices = metadata['output_slices'] self.prev_desire = np.zeros(ModelConstants.DESIRE_LEN, dtype=np.float32) self.chestnut = chestnut - self.frame_skip = ModelConstants.MODEL_RUN_FREQ // ModelConstants.MODEL_CONTEXT_FREQ self.frame_copy_size = nv12_copy_size(*get_nv12_info(cam_w, cam_h)[:3]) self.input_queues, self.npy, self.frame_views = make_input_queues( - self.input_shapes, self.frame_skip, device=self.model_device, frame_copy_size=self.frame_copy_size) + self.input_shapes, self.state_pairs, device=self.model_device, frame_copy_size=self.frame_copy_size) self.parser = Parser() self.run_model = jits['run_model'][(cam_w,cam_h)] @@ -164,14 +164,13 @@ class ModelState: self.npy['tfm'][:,:] = transforms['img'][:,:] self.npy['big_tfm'][:,:] = transforms['big_img'][:,:] - outs, = self.run_model(**{k: self.input_queues[k] for k in MODELD_INPUTS}) + outs, = self.run_model(**self.input_queues) if after_enqueue is not None: after_enqueue() model_output = outs.numpy()[0] if self.chestnut and not np.all(np.isfinite(model_output)): raise RuntimeError("model output not finite") outputs_dict = self.parser.parse_outputs(self.slice_outputs(model_output, self.output_slices)) - self.npy['prev_feat'][:] = model_output[self.output_slices['hidden_state']] if SEND_RAW_PRED: outputs_dict['raw_pred'] = model_output.copy() @@ -183,7 +182,7 @@ class ModelState: dims = {'desire_pulse': ModelConstants.DESIRE_LEN, 'traffic_convention': 2, 'action_t': 2} self.run(dummy_frames, dict.fromkeys(self.vision_input_names, eye), {k: np.zeros(v, dtype=np.float32) for k, v in dims.items()}) self.input_queues, self.npy, self.frame_views = make_input_queues( - self.input_shapes, self.frame_skip, device=self.model_device, frame_copy_size=self.frame_copy_size) + self.input_shapes, self.state_pairs, device=self.model_device, frame_copy_size=self.frame_copy_size) self.prev_desire[:] = 0 diff --git a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx b/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx index 13e3d32f47..3646adc744 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx +++ b/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:09d080f36965bb2a0790500452bd328aa03c484d0222aa79d1ad9f021a522aec -size 766040736 +oid sha256:6fee5937923c74848df4a63f6239eb6331c6274dd4bdb7a5d6ec0388a8b543d5 +size 766018462 diff --git a/openpilot/selfdrive/modeld/models/driving_supercombo.onnx b/openpilot/selfdrive/modeld/models/driving_supercombo.onnx index f0672eab48..f030157ccd 100644 --- a/openpilot/selfdrive/modeld/models/driving_supercombo.onnx +++ b/openpilot/selfdrive/modeld/models/driving_supercombo.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:659727c4d4839adc4992a254409a54259a8756a743f2d567bf5fdc6579f8009b -size 60881999 +oid sha256:65a08adc31d5c456219687d99b7bf5e44d61dae2d49ea67850e76105c7248cce +size 60918562 From fa9f56ed766182f3c30f37c4155f363749168520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Mon, 14 Sep 2026 23:07:26 -0700 Subject: [PATCH 118/122] Compile warp and onnx separately (#38864) * Extract shared model warp routines * Move DM warp compilation into compile_warp * Configure shared warp compilation through SCons flags * Move the driving warp wrapper into compile_warp * Build driving warps separately from the model * Compile model inputs directly from ONNX * modeld: simplify split input setup * modeld: extract input packing setup * modeld: simplify packed views and share firmware setup --- openpilot/selfdrive/modeld/SConscript | 35 +++- openpilot/selfdrive/modeld/compile_dm_warp.py | 56 ----- openpilot/selfdrive/modeld/compile_modeld.py | 192 ++---------------- openpilot/selfdrive/modeld/compile_warp.py | 150 ++++++++++++++ openpilot/selfdrive/modeld/helpers.py | 15 ++ openpilot/selfdrive/modeld/modeld.py | 68 +++++-- 6 files changed, 255 insertions(+), 261 deletions(-) delete mode 100755 openpilot/selfdrive/modeld/compile_dm_warp.py create mode 100644 openpilot/selfdrive/modeld/compile_warp.py diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index a302016460..6af7f09092 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -7,6 +7,7 @@ from openpilot.common.file_chunker import chunk_file, get_chunk_targets, get_exi from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE, DM_INPUT_SIZE from openpilot.selfdrive.modeld.helpers import TG_INPUT_DEVICES_PATH, chestnut_present, modeld_pkl_path +from openpilot.system.camerad.cameras.nv12_info import get_nv12_info Import('env', 'arch') @@ -63,25 +64,23 @@ compile_modeld_script = [ File(f"{modeld_dir}/compile_modeld.py"), File(f"{modeld_dir}/get_model_metadata.py"), File(f"{modeld_dir}/helpers.py"), - File("#openpilot/system/camerad/cameras/nv12_info.py"), File("#openpilot/common/hardware/hw.py"), ] +compile_warp_script = [File(f"{modeld_dir}/compile_warp.py"), File(f"{modeld_dir}/helpers.py"), + File("#openpilot/system/camerad/cameras/nv12_info.py")] model_w, model_h = MEDMODEL_INPUT_SIZE for chestnut in [False, True] if CHESTNUT else [False]: target_pkl_path = File(modeld_pkl_path(chestnut)).abspath file_prefix, cmd_flags = ('big_', chestnut_tg_flags) if chestnut else ('', tg_flags) driving_onnx_deps = get_existing_chunks(File(f"models/{file_prefix}driving_supercombo.onnx").abspath) - camera_res_args = ' '.join(f'{cw}x{ch}' for cw, ch in camera_configs) # CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it. taskset = 'taskset -c 7 ' if arch == 'comma_arm64' else '' cmd = (f'{cmd_flags} {mac_brew_string} {taskset}python3 {modeld_dir}/compile_modeld.py ' - f'--model-size {model_w}x{model_h} ' - f'--camera-resolutions {camera_res_args} ' f'--onnx {File(f"models/{file_prefix}driving_supercombo.onnx").abspath} ' f'--output {target_pkl_path}') onnx_sizes_sum = sum(os.path.getsize(f) for f in driving_onnx_deps) - chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum * len(camera_configs))) + chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum)) def do_compile(target, source, env, command=cmd, pkl=target_pkl_path, chunks=chunk_targets): from openpilot.system.hardware.chestnut.flash import link_up # chestnut can enumerate before its PCIe link is up due to varying 12V power behavior across cars @@ -94,18 +93,33 @@ for chestnut in [False, True] if CHESTNUT else [False]: return if ret := env.Execute(command): return ret - chunk_file(pkl, chunks) + if chunks: + chunk_file(pkl, chunks) def do_chunk(target, source, env, pkl=target_pkl_path, chunks=chunk_targets): chunk_file(pkl, chunks) actions = Action(do_compile, " [CHESTNUT] $TARGET") if chestnut else [cmd, Action(do_chunk, " [CHUNK] $TARGET")] node = lenv.Command( chunk_targets, - tinygrad_files + compile_modeld_script + driving_onnx_deps + [Value(camera_res_args), Value(chunk_targets), chunker_file], + tinygrad_files + compile_modeld_script + driving_onnx_deps + [Value(cmd), Value(chunk_targets), chunker_file], actions, ) if chestnut: lenv.SideEffect(chestnut_lock, node) + for cam_w, cam_h in camera_configs: + warp_pkl_path = File(f"models/{file_prefix}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath + stride, y_height, uv_height, _ = get_nv12_info(cam_w, cam_h) + cmd = (f'{cmd_flags} {mac_brew_string} {taskset}python3 {modeld_dir}/compile_warp.py ' + f'--camera-resolution {cam_w}x{cam_h} --warp-to {model_w}x{model_h} --layout yuv420 ' + f'--frames 2 --frame-size {stride * (y_height + uv_height)} ' + f'--output {warp_pkl_path}') + def do_compile_warp(target, source, env, command=cmd): + return do_compile(target, source, env, command=command, chunks=()) + action = Action(do_compile_warp, " [CHESTNUT] $TARGET") if chestnut else cmd + node = lenv.Command(warp_pkl_path, tinygrad_files + compile_warp_script + [Value(cmd)], action) + if chestnut: + lenv.SideEffect(chestnut_lock, node) + # get model metadata fn = File(f"models/dmonitoring_model").abspath script_files = [File(Dir("#openpilot/selfdrive/modeld").File("get_model_metadata.py").abspath)] @@ -113,13 +127,12 @@ cmd = f'{tg_flags} {mac_brew_string} python3 {Dir("#openpilot/selfdrive/modeld") lenv.Command(fn + "_metadata.pkl", [fn + ".onnx"] + tinygrad_files + script_files + [tg_devices_node], cmd) dm_w, dm_h = DM_INPUT_SIZE -compile_dm_warp_script = [File(f"{modeld_dir}/compile_dm_warp.py")] for cam_w, cam_h in camera_configs: dm_pkl_path = File(f"models/dm_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath - cmd = (f'{tg_flags} {mac_brew_string} python3 {modeld_dir}/compile_dm_warp.py ' - f'--camera-resolution {cam_w}x{cam_h} --warp-to {dm_w}x{dm_h} ' + cmd = (f'{tg_flags} {mac_brew_string} python3 {modeld_dir}/compile_warp.py ' + f'--camera-resolution {cam_w}x{cam_h} --warp-to {dm_w}x{dm_h} --layout luma --border-fill 16 --transform-device NPY ' f'--output {dm_pkl_path}') - lenv.Command(dm_pkl_path, tinygrad_files + compile_dm_warp_script + compile_modeld_script + [tg_devices_node], cmd) + lenv.Command(dm_pkl_path, tinygrad_files + compile_warp_script + [tg_devices_node], cmd) def tg_compile(flags, model_name): pythonpath_string = 'PYTHONPATH="${PYTHONPATH}:' + env.Dir("#tinygrad_repo").abspath + '"' diff --git a/openpilot/selfdrive/modeld/compile_dm_warp.py b/openpilot/selfdrive/modeld/compile_dm_warp.py deleted file mode 100755 index 16e0d39fbb..0000000000 --- a/openpilot/selfdrive/modeld/compile_dm_warp.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import pickle -import time - -from tinygrad.tensor import Tensor -from tinygrad.device import Device -from tinygrad.engine.jit import TinyJit - -from openpilot.system.camerad.cameras.nv12_info import get_nv12_info -from openpilot.selfdrive.modeld.compile_modeld import NV12Frame, warp_perspective_tinygrad, _parse_size - - -def make_warp_dm(nv12: NV12Frame, dm_w, dm_h): - cam_w, cam_h, stride, _, _, _ = nv12 - stride_pad = stride - cam_w - - def warp_dm(input_frame, M_inv): - M_inv = M_inv.to(Device.DEFAULT).realize() - return warp_perspective_tinygrad(input_frame[:cam_h*stride], M_inv, - (dm_w, dm_h), (cam_h, cam_w), stride_pad, border_fill_val=16).reshape(-1, dm_h * dm_w) # Y - return warp_dm - - -def compile_dm_warp(nv12: NV12Frame, dm_w, dm_h, pkl_path): - print(f"Compiling DM warp for {nv12.width}x{nv12.height} -> {dm_w}x{dm_h}...") - - warp_dm_jit = TinyJit(make_warp_dm(nv12, dm_w, dm_h), prune=True) - - for i in range(10): - frame = Tensor.randint(nv12.size, low=0, high=256, dtype='uint8').realize() - M_inv = Tensor(Tensor.randn(3, 3).mul(8).realize().numpy(), device='NPY') - Device.default.synchronize() - st = time.perf_counter() - warp_dm_jit(frame, M_inv).realize() - mt = time.perf_counter() - Device.default.synchronize() - et = time.perf_counter() - print(f" [{i+1}/10] enqueue {(mt-st)*1e3:6.2f} ms -- total {(et-st)*1e3:6.2f} ms") - - with open(pkl_path, "wb") as f: - pickle.dump(warp_dm_jit, f) - print(f" Saved to {pkl_path}") - - -if __name__ == "__main__": - p = argparse.ArgumentParser() - p.add_argument('--camera-resolution', type=_parse_size, required=True, help='camera resolution WxH') - p.add_argument('--warp-to', type=_parse_size, required=True, help='DM input WxH') - p.add_argument('--output', required=True) - args = p.parse_args() - - cam_w, cam_h = args.camera_resolution - nv12 = NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) - dm_w, dm_h = args.warp_to - compile_dm_warp(nv12, dm_w, dm_h, args.output) diff --git a/openpilot/selfdrive/modeld/compile_modeld.py b/openpilot/selfdrive/modeld/compile_modeld.py index c2fd116f1a..463e697519 100755 --- a/openpilot/selfdrive/modeld/compile_modeld.py +++ b/openpilot/selfdrive/modeld/compile_modeld.py @@ -1,188 +1,48 @@ #!/usr/bin/env python3 import argparse import atexit -import math import os import tempfile import time import shutil -from functools import partial -from collections import namedtuple import numpy as np -from openpilot.selfdrive.modeld.helpers import dump_oob, load_oob - -def _patch_tinygrad_fetch_fw(): - import hashlib - import pathlib - import zstandard - from tinygrad import helpers - _orig = helpers.fetch_fw - def fetch_fw(path, name, sha256): - p = pathlib.Path(f"/lib/firmware/{path}/{name}.zst") - if p.is_file(): - blob = zstandard.ZstdDecompressor().stream_reader(p.read_bytes()).read() - if hashlib.sha256(blob).hexdigest() == sha256: - return blob - return _orig(path, name, sha256) - helpers.fetch_fw = fetch_fw -_patch_tinygrad_fetch_fw() - +from openpilot.selfdrive.modeld.helpers import dump_oob, load_oob, patch_tinygrad_fetch_fw +patch_tinygrad_fetch_fw() from tinygrad.tensor import Tensor -from tinygrad.helpers import Context from tinygrad.device import Device from tinygrad.engine.jit import TinyJit -NV12Frame = namedtuple("NV12Frame", ['width', 'height', 'stride', 'y_height', 'uv_height', 'size']) +def make_input_queues(input_shapes, device): + return {name: Tensor(np.zeros(shape, dtype=dtype.fmt), device=device).realize() for name, (shape, dtype) in input_shapes.items()} -def nv12_copy_size(stride: int, y_height: int, uv_height: int) -> int: - # Retain the padded Y and UV plane storage, but skip the trailing kernel/guard allocation. - return stride * (y_height + uv_height) - - -def warp_perspective_tinygrad(src_flat, M_inv, dst_shape, src_shape, stride_pad, border_fill_val=None): - w_dst, h_dst = dst_shape - h_src, w_src = src_shape - - x = Tensor.arange(w_dst).reshape(1, w_dst).expand(h_dst, w_dst).reshape(-1) - y = Tensor.arange(h_dst).reshape(h_dst, 1).expand(h_dst, w_dst).reshape(-1) - - # inline 3x3 matmul as elementwise to avoid reduce op (enables fusion with gather) - src_x = M_inv[0, 0] * x + M_inv[0, 1] * y + M_inv[0, 2] - src_y = M_inv[1, 0] * x + M_inv[1, 1] * y + M_inv[1, 2] - src_w = M_inv[2, 0] * x + M_inv[2, 1] * y + M_inv[2, 2] - - src_x = src_x / src_w - src_y = src_y / src_w - - x_round = Tensor.round(src_x) - y_round = Tensor.round(src_y) - x_nn_clipped = x_round.clip(0, w_src - 1).cast('int') - y_nn_clipped = y_round.clip(0, h_src - 1).cast('int') - idx = y_nn_clipped * (w_src + stride_pad) + x_nn_clipped - sampled = src_flat[idx] - - if border_fill_val is None: - return sampled - - in_bounds = ((x_round >= 0) & (x_round <= w_src - 1) & - (y_round >= 0) & (y_round <= h_src - 1)).cast(sampled.dtype) - return sampled * in_bounds + Tensor(border_fill_val, dtype=sampled.dtype) * (1 - in_bounds) - - -def frames_to_tensor(frames): - H = (frames.shape[0] * 2) // 3 - W = frames.shape[1] - in_img1 = Tensor.cat(frames[0:H:2, 0::2], - frames[1:H:2, 0::2], - frames[0:H:2, 1::2], - frames[1:H:2, 1::2], - frames[H:H+H//4].reshape((H//2, W//2)), - frames[H+H//4:H+H//2].reshape((H//2, W//2)), dim=0).reshape((6, H//2, W//2)) - return in_img1 - - -def make_frame_prepare(nv12: NV12Frame, model_w, model_h): - cam_w, cam_h, stride, y_height, uv_height, _ = nv12 - uv_offset = stride * y_height - stride_pad = stride - cam_w - - def frame_prepare_tinygrad(input_frame, M_inv): - # UV_SCALE @ M_inv @ UV_SCALE_INV simplifies to elementwise scaling - M_inv_uv = M_inv * Tensor([[1.0, 1.0, 0.5], [1.0, 1.0, 0.5], [2.0, 2.0, 1.0]], device=Device.DEFAULT) - # deinterleave NV12 UV plane (UVUV... -> separate U, V) - uv = input_frame[uv_offset:uv_offset + uv_height * stride].reshape(uv_height, stride) - with Context(SPLIT_REDUCEOP=0): - y = warp_perspective_tinygrad(input_frame[:cam_h*stride], - M_inv, (model_w, model_h), - (cam_h, cam_w), stride_pad).realize() - u = warp_perspective_tinygrad(uv[:cam_h//2, :cam_w:2].flatten(), - M_inv_uv, (model_w//2, model_h//2), - (cam_h//2, cam_w//2), 0).realize() - v = warp_perspective_tinygrad(uv[:cam_h//2, 1:cam_w:2].flatten(), - M_inv_uv, (model_w//2, model_h//2), - (cam_h//2, cam_w//2), 0).realize() - yuv = y.cat(u).cat(v).reshape((model_h * 3 // 2, model_w)) - tensor = frames_to_tensor(yuv) - return tensor - return frame_prepare_tinygrad - - -def get_npy_shapes(input_shapes, state_pairs): - shapes = {'tfm': (3, 3), 'big_tfm': (3, 3)} | { - name: shape for name, (shape, _) in input_shapes.items() if name not in state_pairs and name != 'new_img'} - return shapes, [math.prod(s) for s in shapes.values()] - - -def make_input_queues(input_shapes, state_pairs, device, frame_copy_size): - shapes, sizes = get_npy_shapes(input_shapes, state_pairs) - packed_npy_size = sum(sizes) * np.dtype(np.float32).itemsize - packed_input = np.zeros(packed_npy_size + 2 * frame_copy_size, dtype=np.uint8) - packed_npy_inputs = packed_input[:packed_npy_size].view(np.float32) - frames = packed_input[packed_npy_size:] - frame_views = {'img': frames[:frame_copy_size], 'big_img': frames[frame_copy_size:]} - npy = {k: v.reshape(s) for (k, s), v in zip(shapes.items(), np.split(packed_npy_inputs, np.cumsum(sizes[:-1])), strict=True)} - input_queues = {name: Tensor(np.zeros(shape, dtype=dtype.fmt), device=device).realize() - for name, (shape, dtype) in input_shapes.items() if name in state_pairs} - input_queues['packed_npy_inputs'] = Tensor(packed_input, device='NPY').realize() - return input_queues, npy, frame_views - - -def make_warp(nv12, model_w, model_h): - frame_prepare = make_frame_prepare(nv12, model_w, model_h) - - def warp(tfm, big_tfm, frame, big_frame): - tfm = tfm.to(Device.DEFAULT) - big_tfm = big_tfm.to(Device.DEFAULT) - frame = frame.to(Device.DEFAULT) - big_frame = big_frame.to(Device.DEFAULT) - Tensor.realize(tfm, big_tfm, frame, big_frame) - - warped_frame = frame_prepare(frame, tfm).unsqueeze(0) - warped_big_frame = frame_prepare(big_frame, big_tfm).unsqueeze(0) - return Tensor.cat(warped_frame, warped_big_frame) - - return warp - - -def make_run_model(warp, model_runner, input_shapes, state_pairs, frame_copy_size): - shapes, sizes = get_npy_shapes(input_shapes, state_pairs) - packed_npy_size = sum(sizes) * np.dtype(np.float32).itemsize - - def run_model(packed_npy_inputs, **state_inputs): - packed_input = packed_npy_inputs.to(Device.DEFAULT).realize() - packed_npy_inputs = packed_input[:packed_npy_size].bitcast('float32') - inputs = {name: t.reshape(s) for (name, s), t in zip(shapes.items(), packed_npy_inputs.split(sizes), strict=True)} - frame = packed_input[packed_npy_size:packed_npy_size + frame_copy_size] - big_frame = packed_input[packed_npy_size + frame_copy_size:] - inputs['new_img'] = warp(inputs.pop('tfm'), inputs.pop('big_tfm'), frame, big_frame) - inputs = {name: value.cast(input_shapes[name][1]) for name, value in inputs.items()} - outputs = {name: value.contiguous() for name, value in model_runner(inputs | state_inputs).items()} +def make_run_model(model_runner, state_pairs): + def run_model(**inputs): + outputs = {name: value.contiguous() for name, value in model_runner(inputs).items()} Tensor.realize(*outputs.values()) if state_pairs: - Tensor.realize(*(state_inputs[name].assign(outputs[next_name]) for name, next_name in state_pairs.items())) + Tensor.realize(*(inputs[name].assign(outputs[next_name]) for name, next_name in state_pairs.items())) return tuple(value for name, value in outputs.items() if name not in state_pairs.values()) return run_model -def compile_jit(jit, make_queues, benchmark_runs): +def compile_jit(jit, input_shapes, benchmark_runs): if benchmark_runs < 1: raise ValueError("benchmark_runs must be at least 1") SEED = 42 def random_inputs_run(fn, seed, n_runs, test_val=None, test_buffers=None, expect_match=True): - input_queues, npy, frame_views = make_queues(Device.DEFAULT) + input_queues = make_input_queues(input_shapes, Device.DEFAULT) rng = np.random.default_rng(seed) for i in range(n_runs): - for v in npy.values(): - v[:] = rng.standard_normal(v.shape).astype(v.dtype) - for v in frame_views.values(): - v[:] = rng.integers(0, 256, size=v.shape, dtype=np.uint8) + for value in input_queues.values(): + values = rng.standard_normal(value.shape) if np.issubdtype(np.dtype(value.dtype.fmt), np.floating) else rng.integers(0, 256, value.shape) + value.assign(Tensor(values.astype(value.dtype.fmt), device=Device.DEFAULT)).realize() Device.default.synchronize() st = time.perf_counter() outs = fn(**input_queues) @@ -192,8 +52,8 @@ def compile_jit(jit, make_queues, benchmark_runs): print(f" [{i+1}/{n_runs}] enqueue {(mt-st)*1e3:6.2f} ms -- total {(et-st)*1e3:6.2f} ms") if i == 0: - val = [np.copy(v.numpy()) for v in outs] - buffers = [np.copy(v.numpy().copy()) for v in input_queues.values()] + val = [v.numpy() for v in outs] + buffers = [v.numpy() for v in input_queues.values()] if test_val is not None: match = all(np.array_equal(a, b) for a, b in zip(val, test_val, strict=True)) @@ -212,15 +72,9 @@ def compile_jit(jit, make_queues, benchmark_runs): loaded_jit = load_oob(f) random_inputs_run(loaded_jit, SEED, benchmark_runs, test_val, test_buffers, expect_match=True) random_inputs_run(loaded_jit, SEED+1, benchmark_runs, test_val, test_buffers, expect_match=False) - # Keep the original so per-resolution JITs share model weight buffers in the final pickle. return jit -def _parse_size(s): - w, h = s.lower().split('x') - return int(w), int(h) - - def read_file_chunked_to_disk(path): from openpilot.common.file_chunker import open_file_chunked tmp_path = f'{path}.unchunked' @@ -232,12 +86,8 @@ def read_file_chunked_to_disk(path): if __name__ == "__main__": from tinygrad.nn.onnx import OnnxRunner - from openpilot.system.camerad.cameras.nv12_info import get_nv12_info from openpilot.selfdrive.modeld.get_model_metadata import make_metadata_dict p = argparse.ArgumentParser() - p.add_argument('--model-size', type=_parse_size, required=True, help='model input WxH') - p.add_argument('--camera-resolutions', type=_parse_size, nargs='+', required=True, - help='camera resolutions WxH (one or more)') p.add_argument('--onnx', required=True) p.add_argument('--output', required=True) p.add_argument('--benchmark-runs', type=int, default=1, @@ -245,7 +95,6 @@ if __name__ == "__main__": args = p.parse_args() model_path = read_file_chunked_to_disk(args.onnx) - model_w, model_h = args.model_size model_runner = OnnxRunner(model_path) input_shapes = {name: (spec.shape, spec.dtype) for name, spec in model_runner.graph_inputs.items()} @@ -255,17 +104,10 @@ if __name__ == "__main__": 'input_shapes': input_shapes, 'state_pairs': state_pairs, 'input_devices': {'model': Device.DEFAULT}, - 'run_model': {}, } - for cam_w, cam_h in args.camera_resolutions: - nv12 = NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) - frame_copy_size = nv12_copy_size(nv12.stride, nv12.y_height, nv12.uv_height) - make_model_queues = partial(make_input_queues, input_shapes, state_pairs, - frame_copy_size=frame_copy_size) - warp = make_warp(nv12, model_w, model_h) - run_model_jit = TinyJit(make_run_model(warp, model_runner, input_shapes, state_pairs, frame_copy_size), prune=True) - out['run_model'][(cam_w,cam_h)] = compile_jit(run_model_jit, make_model_queues, args.benchmark_runs) + run_model = make_run_model(model_runner, state_pairs) + out['run_model'] = compile_jit(TinyJit(run_model, prune=True), input_shapes, args.benchmark_runs) with open(args.output, "wb") as f: dump_oob(out, f) diff --git a/openpilot/selfdrive/modeld/compile_warp.py b/openpilot/selfdrive/modeld/compile_warp.py new file mode 100644 index 0000000000..1b2c085537 --- /dev/null +++ b/openpilot/selfdrive/modeld/compile_warp.py @@ -0,0 +1,150 @@ +import argparse +import pickle +import time +from collections import namedtuple + +from openpilot.selfdrive.modeld.helpers import patch_tinygrad_fetch_fw +patch_tinygrad_fetch_fw() + +from tinygrad.tensor import Tensor +from tinygrad.helpers import Context +from tinygrad.device import Device +from tinygrad.engine.jit import TinyJit + +from openpilot.system.camerad.cameras.nv12_info import get_nv12_info + + +NV12Frame = namedtuple("NV12Frame", ['width', 'height', 'stride', 'y_height', 'uv_height', 'size']) + + +def warp_perspective_tinygrad(src_flat, M_inv, dst_shape, src_shape, stride_pad, border_fill_val=None): + w_dst, h_dst = dst_shape + h_src, w_src = src_shape + + x = Tensor.arange(w_dst).reshape(1, w_dst).expand(h_dst, w_dst).reshape(-1) + y = Tensor.arange(h_dst).reshape(h_dst, 1).expand(h_dst, w_dst).reshape(-1) + + # inline 3x3 matmul as elementwise to avoid reduce op (enables fusion with gather) + src_x = M_inv[0, 0] * x + M_inv[0, 1] * y + M_inv[0, 2] + src_y = M_inv[1, 0] * x + M_inv[1, 1] * y + M_inv[1, 2] + src_w = M_inv[2, 0] * x + M_inv[2, 1] * y + M_inv[2, 2] + + src_x = src_x / src_w + src_y = src_y / src_w + + x_round = Tensor.round(src_x) + y_round = Tensor.round(src_y) + x_nn_clipped = x_round.clip(0, w_src - 1).cast('int') + y_nn_clipped = y_round.clip(0, h_src - 1).cast('int') + idx = y_nn_clipped * (w_src + stride_pad) + x_nn_clipped + sampled = src_flat[idx] + + if border_fill_val is None: + return sampled + + in_bounds = ((x_round >= 0) & (x_round <= w_src - 1) & + (y_round >= 0) & (y_round <= h_src - 1)).cast(sampled.dtype) + return sampled * in_bounds + Tensor(border_fill_val, dtype=sampled.dtype) * (1 - in_bounds) + + +def frames_to_tensor(frames): + H = (frames.shape[0] * 2) // 3 + W = frames.shape[1] + return Tensor.cat(frames[0:H:2, 0::2], + frames[1:H:2, 0::2], + frames[0:H:2, 1::2], + frames[1:H:2, 1::2], + frames[H:H+H//4].reshape((H//2, W//2)), + frames[H+H//4:H+H//2].reshape((H//2, W//2)), dim=0).reshape((6, H//2, W//2)) + + +def make_frame_prepare(nv12: NV12Frame, model_w, model_h, layout="yuv420", border_fill=None): + cam_w, cam_h, stride, y_height, uv_height, _ = nv12 + uv_offset = stride * y_height + stride_pad = stride - cam_w + + def frame_prepare_tinygrad(input_frame, M_inv): + if layout == "luma": + return warp_perspective_tinygrad(input_frame[:cam_h*stride], M_inv, + (model_w, model_h), (cam_h, cam_w), stride_pad, + border_fill_val=border_fill).reshape(-1, model_h * model_w) + # UV_SCALE @ M_inv @ UV_SCALE_INV simplifies to elementwise scaling + M_inv_uv = M_inv * Tensor([[1.0, 1.0, 0.5], [1.0, 1.0, 0.5], [2.0, 2.0, 1.0]], device=Device.DEFAULT) + # deinterleave NV12 UV plane (UVUV... -> separate U, V) + uv = input_frame[uv_offset:uv_offset + uv_height * stride].reshape(uv_height, stride) + with Context(SPLIT_REDUCEOP=0): + y = warp_perspective_tinygrad(input_frame[:cam_h*stride], + M_inv, (model_w, model_h), + (cam_h, cam_w), stride_pad, border_fill_val=border_fill).realize() + u = warp_perspective_tinygrad(uv[:cam_h//2, :cam_w:2].flatten(), + M_inv_uv, (model_w//2, model_h//2), + (cam_h//2, cam_w//2), 0, border_fill_val=border_fill).realize() + v = warp_perspective_tinygrad(uv[:cam_h//2, 1:cam_w:2].flatten(), + M_inv_uv, (model_w//2, model_h//2), + (cam_h//2, cam_w//2), 0, border_fill_val=border_fill).realize() + yuv = y.cat(u).cat(v).reshape((model_h * 3 // 2, model_w)) + return frames_to_tensor(yuv) + return frame_prepare_tinygrad + + +def make_warp(nv12, model_w, model_h, layout="yuv420", border_fill=None, frames=1): + frame_prepare = make_frame_prepare(nv12, model_w, model_h, layout, border_fill) + + def warp(input_frames, transforms): + input_frames = input_frames.to(Device.DEFAULT) + transforms = transforms.to(Device.DEFAULT) + Tensor.realize(input_frames, transforms) + if frames == 1: + return frame_prepare(input_frames, transforms) + return Tensor.stack(*(frame_prepare(input_frames[i], transforms[i]) for i in range(frames))) + + return warp + + +def _parse_size(s): + w, h = s.lower().split('x') + return int(w), int(h) + + +def compile_warp(nv12: NV12Frame, model_w, model_h, pkl_path, layout, border_fill=None, + frames=1, transform_device=None, frame_size=None): + print(f"Compiling {layout} warp for {nv12.width}x{nv12.height} -> {model_w}x{model_h}...") + + warp_jit = TinyJit(make_warp(nv12, model_w, model_h, layout, border_fill, frames), prune=True) + frame_size = nv12.size if frame_size is None else frame_size + frame_shape = (frame_size,) if frames == 1 else (frames, frame_size) + transform_shape = (3, 3) if frames == 1 else (frames, 3, 3) + + for i in range(10): + frame = Tensor.randint(*frame_shape, low=0, high=256, dtype='uint8').realize() + M_inv = Tensor(Tensor.randn(*transform_shape).mul(8).realize().numpy(), device=transform_device) + Device.default.synchronize() + st = time.perf_counter() + warp_jit(frame, M_inv).realize() + mt = time.perf_counter() + Device.default.synchronize() + et = time.perf_counter() + print(f" [{i+1}/10] enqueue {(mt-st)*1e3:6.2f} ms -- total {(et-st)*1e3:6.2f} ms") + + with open(pkl_path, "wb") as f: + pickle.dump(warp_jit, f) + print(f" Saved to {pkl_path}") + + +if __name__ == "__main__": + p = argparse.ArgumentParser() + p.add_argument('--camera-resolution', type=_parse_size, required=True, help='camera resolution WxH') + p.add_argument('--warp-to', type=_parse_size, required=True, help='output WxH') + p.add_argument('--layout', choices=['luma', 'yuv420'], required=True) + p.add_argument('--border-fill', type=int, help='fill value outside the frame; omit to clamp coordinates') + p.add_argument('--frames', type=int, default=1, help='number of frames to warp together') + p.add_argument('--transform-device', help='device holding the input transforms; default: compute device') + p.add_argument('--frame-size', type=int, help='input frame size in bytes; default: full NV12 allocation') + p.add_argument('--output', required=True) + args = p.parse_args() + + cam_w, cam_h = args.camera_resolution + nv12 = NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) + model_w, model_h = args.warp_to + compile_warp(nv12, model_w, model_h, args.output, args.layout, args.border_fill, + args.frames, args.transform_device, args.frame_size) diff --git a/openpilot/selfdrive/modeld/helpers.py b/openpilot/selfdrive/modeld/helpers.py index 0fdd1629ba..a5b64d1b25 100644 --- a/openpilot/selfdrive/modeld/helpers.py +++ b/openpilot/selfdrive/modeld/helpers.py @@ -13,6 +13,21 @@ MODELS_DIR = Path(__file__).resolve().parent / 'models' TG_INPUT_DEVICES_PATH = MODELS_DIR / 'tg_input_devices.json' +def patch_tinygrad_fetch_fw(): + import hashlib + import zstandard + from tinygrad import helpers + original_fetch_fw = helpers.fetch_fw + def fetch_fw(path, name, sha256): + p = Path(f"/lib/firmware/{path}/{name}.zst") + if p.is_file(): + blob = zstandard.ZstdDecompressor().stream_reader(p.read_bytes()).read() + if hashlib.sha256(blob).hexdigest() == sha256: + return blob + return original_fetch_fw(path, name, sha256) + helpers.fetch_fw = fetch_fw + + def get_tg_input_devices(process_name: str, chestnut: bool): with open(TG_INPUT_DEVICES_PATH) as f: return json.load(f)[process_name]['default' if not chestnut else 'chestnut'] diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 994fda8b35..fa9a61a8e8 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -4,7 +4,13 @@ import ctypes from functools import cached_property import os os.environ['GMMU'] = '0' # for chestnut fast loading, noop for qcom -from tinygrad.device import Device +from tinygrad.device import Buffer, Device +from tinygrad.dtype import DType, dtypes +from tinygrad.tensor import Tensor +from tinygrad.helpers import round_up +from tinygrad.uop.ops import UOp +import math +import pickle import threading import time import numpy as np @@ -26,11 +32,11 @@ from openpilot.common.transformations.model import get_warp_matrix from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper from openpilot.selfdrive.controls.lib.drive_helpers import get_accel_from_plan, should_stop, smooth_value, get_curvature_from_plan from openpilot.selfdrive.modeld.parse_model_outputs import Parser -from openpilot.selfdrive.modeld.compile_modeld import make_input_queues, nv12_copy_size +from openpilot.selfdrive.modeld.compile_modeld import make_input_queues from openpilot.selfdrive.modeld.fill_model_msg import fill_model_msg, fill_driving_model_data, fill_pose_msg, PublishState from openpilot.common.file_chunker import open_file_chunked from openpilot.selfdrive.modeld.constants import ModelConstants, Plan -from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled, modeld_pkl_path, load_oob +from openpilot.selfdrive.modeld.helpers import MODELS_DIR, chestnut_present, chestnut_compiled, modeld_pkl_path, load_oob SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') @@ -124,36 +130,59 @@ class FrameMeta: self.frame_id, self.timestamp_sof, self.timestamp_eof = vipc.frame_id, vipc.timestamp_sof, vipc.timestamp_eof +def input_view(buffer: Buffer, shape: tuple[int, ...], dtype: DType, offset: int) -> Tensor: + view = buffer.view(math.prod(shape), dtype, offset).ensure_allocated() + return Tensor(UOp.from_buffer(view)).reshape(shape) + + class ModelState: prev_desire: np.ndarray # for tracking the rising edge of the pulse def __init__(self, cam_w: int, cam_h: int, chestnut: bool): jits = load_oob(open_file_chunked(modeld_pkl_path(chestnut))) - input_devices = jits['input_devices'] - self.model_device = input_devices['model'] - metadata = jits['metadata'] + self.model_device = jits['input_devices']['model'] self.input_shapes = jits['input_shapes'] self.state_pairs = jits['state_pairs'] self.vision_input_names = ('img', 'big_img') - self.output_slices = metadata['output_slices'] + self.output_slices = jits['metadata']['output_slices'] self.prev_desire = np.zeros(ModelConstants.DESIRE_LEN, dtype=np.float32) self.chestnut = chestnut - self.frame_copy_size = nv12_copy_size(*get_nv12_info(cam_w, cam_h)[:3]) - self.input_queues, self.npy, self.frame_views = make_input_queues( - self.input_shapes, self.state_pairs, device=self.model_device, frame_copy_size=self.frame_copy_size) + stride, y_height, uv_height, _ = get_nv12_info(cam_w, cam_h) + self.frame_copy_size = stride * (y_height + uv_height) + self.pack_inputs() + with open(MODELS_DIR / f'{"big_" if chestnut else ""}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl', 'rb') as f: + self.run_warp = pickle.load(f) + self.run_model = jits['run_model'] self.parser = Parser() - self.run_model = jits['run_model'][(cam_w,cam_h)] + + def pack_inputs(self) -> None: + # Pack host inputs into one upload to reduce USB transfer overhead for the eGPU. + self.input_queues = make_input_queues({name: self.input_shapes[name] for name in self.state_pairs}, self.model_device) + shapes = {'tfm': (2, 3, 3)} | {name: shape for name, (shape, _) in self.input_shapes.items() + if name not in self.state_pairs and name != 'new_img'} + npy_size = sum(round_up(math.prod(shape) * 4, 128) for shape in shapes.values()) + self.packed_input = np.zeros(npy_size + 2 * self.frame_copy_size, dtype=np.uint8) + self.input_host = Tensor(self.packed_input, device='NPY')._buffer() + self.input_device = Tensor(self.packed_input, device=self.model_device)._buffer() + self.npy = {} + offset = 0 + for name, shape in shapes.items(): + self.npy[name] = np.ndarray(shape, dtype=np.float32, buffer=self.packed_input, offset=offset) + self.input_queues[name] = input_view(self.input_device, shape, dtypes.float32, offset) + offset += round_up(self.npy[name].nbytes, 128) + self.frames = self.packed_input[npy_size:].reshape(2, self.frame_copy_size) + self.warp_inputs = (input_view(self.input_device, self.frames.shape, dtypes.uint8, npy_size), self.input_queues.pop('tfm')) def slice_outputs(self, model_outputs: np.ndarray, output_slices: dict[str, slice]) -> dict[str, np.ndarray]: - parsed_model_outputs = {k: model_outputs[np.newaxis, v] for k,v in output_slices.items()} - return parsed_model_outputs + return {k: model_outputs[np.newaxis, v] for k,v in output_slices.items()} def run(self, bufs: dict[str, VisionBuf], transforms: dict[str, np.ndarray], inputs: dict[str, np.ndarray], after_enqueue: Callable[[], None] | None = None) -> dict[str, np.ndarray]: - for key, buf in bufs.items(): - np.copyto(self.frame_views[key], np.frombuffer(buf.data, dtype=np.uint8, count=self.frame_copy_size)) + for i, key in enumerate(self.vision_input_names): + np.copyto(self.frames[i], np.frombuffer(bufs[key].data, dtype=np.uint8, count=self.frame_copy_size)) + self.npy['tfm'][i] = transforms[key] # Model decides when action is completed, so desire input is just a pulse triggered on rising edge inputs['desire_pulse'][0] = 0 @@ -161,9 +190,9 @@ class ModelState: self.prev_desire[:] = inputs['desire_pulse'] self.npy['traffic_convention'][:] = inputs['traffic_convention'] self.npy['action_t'][:] = inputs['action_t'] - self.npy['tfm'][:,:] = transforms['img'][:,:] - self.npy['big_tfm'][:,:] = transforms['big_img'][:,:] + self.input_device.copy_from(self.input_host) + self.input_queues['new_img'] = self.run_warp(*self.warp_inputs) outs, = self.run_model(**self.input_queues) if after_enqueue is not None: after_enqueue() @@ -181,8 +210,9 @@ class ModelState: eye = np.eye(3, dtype=np.float32) dims = {'desire_pulse': ModelConstants.DESIRE_LEN, 'traffic_convention': 2, 'action_t': 2} self.run(dummy_frames, dict.fromkeys(self.vision_input_names, eye), {k: np.zeros(v, dtype=np.float32) for k, v in dims.items()}) - self.input_queues, self.npy, self.frame_views = make_input_queues( - self.input_shapes, self.state_pairs, device=self.model_device, frame_copy_size=self.frame_copy_size) + self.packed_input[:] = 0 + for key in self.state_pairs: + self.input_queues[key].assign(0).realize() self.prev_desire[:] = 0 From b751b04cdad7dde9c738ede3cab79fa673d15541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Tue, 15 Sep 2026 11:21:47 -0700 Subject: [PATCH 119/122] Use tinygrad ONNX and warp compilers for driving and DM (#38922) * modeld: use tinygrad ONNX and warp compilers for driving and DM * Bump tinygrad with current openpilot model compile tests * modeld: prioritize tinygrad compiler imports in build environment * Use compiler branch based on openpilot's pinned tinygrad * modeld: invoke tinygrad compilers without firmware patch --- .gitmodules | 3 +- openpilot/selfdrive/modeld/SConscript | 112 ++++--------- openpilot/selfdrive/modeld/compile_modeld.py | 117 -------------- openpilot/selfdrive/modeld/compile_warp.py | 150 ------------------ .../selfdrive/modeld/dmonitoringmodeld.py | 22 +-- .../selfdrive/modeld/get_model_metadata.py | 55 ------- openpilot/selfdrive/modeld/helpers.py | 38 ----- openpilot/selfdrive/modeld/modeld.py | 4 +- tinygrad_repo | 2 +- 9 files changed, 50 insertions(+), 453 deletions(-) delete mode 100755 openpilot/selfdrive/modeld/compile_modeld.py delete mode 100644 openpilot/selfdrive/modeld/compile_warp.py delete mode 100755 openpilot/selfdrive/modeld/get_model_metadata.py diff --git a/.gitmodules b/.gitmodules index ad6530de9a..7e37596b0a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -15,4 +15,5 @@ url = ../../commaai/teleoprtc [submodule "tinygrad"] path = tinygrad_repo - url = https://github.com/tinygrad/tinygrad.git + url = https://github.com/commaai/tinygrad.git + branch = openpilot-modeld-test diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index 6af7f09092..6ccf711c4c 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -1,18 +1,18 @@ import glob -import json import os import time from SCons.Script import Action, Value from openpilot.common.file_chunker import chunk_file, get_chunk_targets, get_existing_chunks from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE, DM_INPUT_SIZE -from openpilot.selfdrive.modeld.helpers import TG_INPUT_DEVICES_PATH, chestnut_present, modeld_pkl_path +from openpilot.selfdrive.modeld.helpers import chestnut_present, modeld_pkl_path from openpilot.system.camerad.cameras.nv12_info import get_nv12_info Import('env', 'arch') chunker_file = File("#openpilot/common/file_chunker.py") lenv = env.Clone() +lenv.PrependENVPath('PYTHONPATH', Dir('#tinygrad_repo').abspath) tinygrad_root = env.Dir("#").abspath tinygrad_files = ["#"+x for x in glob.glob(env.Dir("#tinygrad_repo").relpath + "/**", recursive=True, root_dir=tinygrad_root) @@ -26,62 +26,27 @@ def estimate_pickle_max_size(onnx_size): camera_configs = [(c.width, c.height) for c in (_ar_ox_fisheye, _os_fisheye)] if arch == 'comma_arm64': - tg_backend = 'QCOM' - tg_flags = f'DEV={tg_backend} IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1' + tg_flags = 'DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1' else: - tg_backend = 'METAL' if arch == 'Darwin' else 'CPU' # JIT=2 disables graph batching, which produces incorrect outputs after buffers change. tg_flags = 'DEV=METAL JIT=2' if arch == 'Darwin' else 'DEV=CPU:LLVM' -tg_devices = { # which device to put jit inputs to at runtime - 'openpilot.selfdrive.modeld.dmonitoringmodeld': { - 'default': {'DEV': tg_backend} - }, -} - CHESTNUT = chestnut_present() if CHESTNUT: chestnut_tg_flags = 'DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32' # the USB+AMD GPU takes an exclusive flock; serialize all targets that touch it chestnut_lock = File("models/.chestnut.lock").abspath -def write_tg_devices(target, source, env): - with open(str(target[0]), "w") as f: - json.dump(tg_devices, f) - f.write("\n") - -tg_devices_node = lenv.Command( - str(TG_INPUT_DEVICES_PATH), - [Value(tg_devices)], - write_tg_devices, -) - # tinygrad calls brew which needs a $HOME in the env mac_brew_string = f'HOME={os.path.expanduser("~")}' if arch == 'Darwin' else '' -modeld_dir = Dir("#openpilot/selfdrive/modeld").abspath -compile_modeld_script = [ - File(f"{modeld_dir}/compile_modeld.py"), - File(f"{modeld_dir}/get_model_metadata.py"), - File(f"{modeld_dir}/helpers.py"), - File("#openpilot/common/hardware/hw.py"), -] -compile_warp_script = [File(f"{modeld_dir}/compile_warp.py"), File(f"{modeld_dir}/helpers.py"), - File("#openpilot/system/camerad/cameras/nv12_info.py")] -model_w, model_h = MEDMODEL_INPUT_SIZE +warp_deps = [File("#openpilot/system/camerad/cameras/nv12_info.py")] +compiler = 'python3 -m examples.openpilot' +# CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it. +taskset = 'taskset -c 7 ' if arch == 'comma_arm64' else '' -for chestnut in [False, True] if CHESTNUT else [False]: - target_pkl_path = File(modeld_pkl_path(chestnut)).abspath - file_prefix, cmd_flags = ('big_', chestnut_tg_flags) if chestnut else ('', tg_flags) - driving_onnx_deps = get_existing_chunks(File(f"models/{file_prefix}driving_supercombo.onnx").abspath) - # CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it. - taskset = 'taskset -c 7 ' if arch == 'comma_arm64' else '' - cmd = (f'{cmd_flags} {mac_brew_string} {taskset}python3 {modeld_dir}/compile_modeld.py ' - f'--onnx {File(f"models/{file_prefix}driving_supercombo.onnx").abspath} ' - f'--output {target_pkl_path}') - onnx_sizes_sum = sum(os.path.getsize(f) for f in driving_onnx_deps) - chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum)) - def do_compile(target, source, env, command=cmd, pkl=target_pkl_path, chunks=chunk_targets): +def chestnut_action(command, pkl=None, chunks=()): + def do_compile(target, source, env): from openpilot.system.hardware.chestnut.flash import link_up # chestnut can enumerate before its PCIe link is up due to varying 12V power behavior across cars for _ in range(10): @@ -95,58 +60,49 @@ for chestnut in [False, True] if CHESTNUT else [False]: return ret if chunks: chunk_file(pkl, chunks) + return Action(do_compile, " [CHESTNUT] $TARGET") + +def compile_model(onnx_path, pkl_path, flags, chestnut=False): + onnx_path, target_pkl_path = File(onnx_path).abspath, File(pkl_path).abspath + onnx_deps = get_existing_chunks(onnx_path) + cmd = (f'{flags} {mac_brew_string} {taskset}{compiler}.compile_onnx ' + f'--onnx {onnx_path} --output {target_pkl_path}') + onnx_sizes_sum = sum(os.path.getsize(f) for f in onnx_deps) + chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum)) def do_chunk(target, source, env, pkl=target_pkl_path, chunks=chunk_targets): chunk_file(pkl, chunks) - actions = Action(do_compile, " [CHESTNUT] $TARGET") if chestnut else [cmd, Action(do_chunk, " [CHUNK] $TARGET")] + actions = chestnut_action(cmd, target_pkl_path, chunk_targets) if chestnut else [cmd, Action(do_chunk, " [CHUNK] $TARGET")] node = lenv.Command( chunk_targets, - tinygrad_files + compile_modeld_script + driving_onnx_deps + [Value(cmd), Value(chunk_targets), chunker_file], + tinygrad_files + onnx_deps + [Value(cmd), Value(chunk_targets), chunker_file], actions, ) if chestnut: lenv.SideEffect(chestnut_lock, node) +compile_model('models/dmonitoring_model.onnx', 'models/dmonitoring_model_tinygrad.pkl', tg_flags) + +model_w, model_h = MEDMODEL_INPUT_SIZE +for chestnut in [False, True] if CHESTNUT else [False]: + file_prefix, cmd_flags = ('big_', chestnut_tg_flags) if chestnut else ('', tg_flags) + compile_model(f'models/{file_prefix}driving_supercombo.onnx', modeld_pkl_path(chestnut), cmd_flags, chestnut) for cam_w, cam_h in camera_configs: warp_pkl_path = File(f"models/{file_prefix}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath stride, y_height, uv_height, _ = get_nv12_info(cam_w, cam_h) - cmd = (f'{cmd_flags} {mac_brew_string} {taskset}python3 {modeld_dir}/compile_warp.py ' + cmd = (f'{cmd_flags} {mac_brew_string} {taskset}{compiler}.compile_warp ' f'--camera-resolution {cam_w}x{cam_h} --warp-to {model_w}x{model_h} --layout yuv420 ' - f'--frames 2 --frame-size {stride * (y_height + uv_height)} ' + f'--frames 2 --stride {stride} --uv-offset {stride * y_height} --frame-size {stride * (y_height + uv_height)} ' f'--output {warp_pkl_path}') - def do_compile_warp(target, source, env, command=cmd): - return do_compile(target, source, env, command=command, chunks=()) - action = Action(do_compile_warp, " [CHESTNUT] $TARGET") if chestnut else cmd - node = lenv.Command(warp_pkl_path, tinygrad_files + compile_warp_script + [Value(cmd)], action) + action = chestnut_action(cmd) if chestnut else cmd + node = lenv.Command(warp_pkl_path, tinygrad_files + warp_deps + [Value(cmd)], action) if chestnut: lenv.SideEffect(chestnut_lock, node) -# get model metadata -fn = File(f"models/dmonitoring_model").abspath -script_files = [File(Dir("#openpilot/selfdrive/modeld").File("get_model_metadata.py").abspath)] -cmd = f'{tg_flags} {mac_brew_string} python3 {Dir("#openpilot/selfdrive/modeld").abspath}/get_model_metadata.py {fn}.onnx' -lenv.Command(fn + "_metadata.pkl", [fn + ".onnx"] + tinygrad_files + script_files + [tg_devices_node], cmd) - dm_w, dm_h = DM_INPUT_SIZE for cam_w, cam_h in camera_configs: dm_pkl_path = File(f"models/dm_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath - cmd = (f'{tg_flags} {mac_brew_string} python3 {modeld_dir}/compile_warp.py ' + stride, y_height, _, frame_size = get_nv12_info(cam_w, cam_h) + cmd = (f'{tg_flags} {mac_brew_string} {compiler}.compile_warp ' f'--camera-resolution {cam_w}x{cam_h} --warp-to {dm_w}x{dm_h} --layout luma --border-fill 16 --transform-device NPY ' - f'--output {dm_pkl_path}') - lenv.Command(dm_pkl_path, tinygrad_files + compile_warp_script + [tg_devices_node], cmd) - -def tg_compile(flags, model_name): - pythonpath_string = 'PYTHONPATH="${PYTHONPATH}:' + env.Dir("#tinygrad_repo").abspath + '"' - fn = File(f"models/{model_name}").abspath - pkl = fn + "_tinygrad.pkl" - onnx_path = fn + ".onnx" - chunk_targets = get_chunk_targets(pkl, estimate_pickle_max_size(os.path.getsize(onnx_path))) - def do_chunk(target, source, env): - chunk_file(pkl, chunk_targets) - return lenv.Command( - chunk_targets, - [onnx_path] + tinygrad_files + [Value(chunk_targets), chunker_file, tg_devices_node], - [f'{pythonpath_string} {flags} python3 {Dir("#tinygrad_repo").abspath}/examples/openpilot/compile3.py {fn}.onnx {pkl}', - Action(do_chunk, " [CHUNK] $TARGET")], - ) - -tg_compile(tg_flags, 'dmonitoring_model') + f'--stride {stride} --uv-offset {stride * y_height} --frame-size {frame_size} --output {dm_pkl_path}') + lenv.Command(dm_pkl_path, tinygrad_files + warp_deps + [Value(cmd)], cmd) diff --git a/openpilot/selfdrive/modeld/compile_modeld.py b/openpilot/selfdrive/modeld/compile_modeld.py deleted file mode 100755 index 463e697519..0000000000 --- a/openpilot/selfdrive/modeld/compile_modeld.py +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import atexit -import os -import tempfile -import time -import shutil - -import numpy as np - -from openpilot.selfdrive.modeld.helpers import dump_oob, load_oob, patch_tinygrad_fetch_fw -patch_tinygrad_fetch_fw() - -from tinygrad.tensor import Tensor -from tinygrad.device import Device -from tinygrad.engine.jit import TinyJit - - -def make_input_queues(input_shapes, device): - return {name: Tensor(np.zeros(shape, dtype=dtype.fmt), device=device).realize() for name, (shape, dtype) in input_shapes.items()} - - -def make_run_model(model_runner, state_pairs): - def run_model(**inputs): - outputs = {name: value.contiguous() for name, value in model_runner(inputs).items()} - Tensor.realize(*outputs.values()) - if state_pairs: - Tensor.realize(*(inputs[name].assign(outputs[next_name]) for name, next_name in state_pairs.items())) - return tuple(value for name, value in outputs.items() if name not in state_pairs.values()) - return run_model - - -def compile_jit(jit, input_shapes, benchmark_runs): - if benchmark_runs < 1: - raise ValueError("benchmark_runs must be at least 1") - - SEED = 42 - def random_inputs_run(fn, seed, n_runs, test_val=None, test_buffers=None, expect_match=True): - input_queues = make_input_queues(input_shapes, Device.DEFAULT) - rng = np.random.default_rng(seed) - - for i in range(n_runs): - for value in input_queues.values(): - values = rng.standard_normal(value.shape) if np.issubdtype(np.dtype(value.dtype.fmt), np.floating) else rng.integers(0, 256, value.shape) - value.assign(Tensor(values.astype(value.dtype.fmt), device=Device.DEFAULT)).realize() - Device.default.synchronize() - st = time.perf_counter() - outs = fn(**input_queues) - mt = time.perf_counter() - Device.default.synchronize() - et = time.perf_counter() - print(f" [{i+1}/{n_runs}] enqueue {(mt-st)*1e3:6.2f} ms -- total {(et-st)*1e3:6.2f} ms") - - if i == 0: - val = [v.numpy() for v in outs] - buffers = [v.numpy() for v in input_queues.values()] - - if test_val is not None: - match = all(np.array_equal(a, b) for a, b in zip(val, test_val, strict=True)) - assert match == expect_match, f"outputs {'differ from' if expect_match else 'match'} baseline (seed={seed})" - if test_buffers is not None: - match = all(np.array_equal(a, b) for a, b in zip(buffers, test_buffers, strict=True)) - assert match == expect_match, f"buffers {'differ from' if expect_match else 'match'} baseline (seed={seed})" - return val, buffers - - print('capture + replay') - test_val, test_buffers = random_inputs_run(jit, SEED, 3) - print(f'pickle round trip ({benchmark_runs} runs per seed)') - with tempfile.TemporaryFile(dir=".") as f: - dump_oob(jit, f) - f.seek(0) - loaded_jit = load_oob(f) - random_inputs_run(loaded_jit, SEED, benchmark_runs, test_val, test_buffers, expect_match=True) - random_inputs_run(loaded_jit, SEED+1, benchmark_runs, test_val, test_buffers, expect_match=False) - return jit - - -def read_file_chunked_to_disk(path): - from openpilot.common.file_chunker import open_file_chunked - tmp_path = f'{path}.unchunked' - with open(tmp_path, 'wb') as f, open_file_chunked(path) as src: - shutil.copyfileobj(src, f) - atexit.register(lambda: os.path.exists(tmp_path) and os.remove(tmp_path)) - return tmp_path - - -if __name__ == "__main__": - from tinygrad.nn.onnx import OnnxRunner - from openpilot.selfdrive.modeld.get_model_metadata import make_metadata_dict - p = argparse.ArgumentParser() - p.add_argument('--onnx', required=True) - p.add_argument('--output', required=True) - p.add_argument('--benchmark-runs', type=int, default=1, - help='timed loaded-JIT runs for each correctness seed') - args = p.parse_args() - - model_path = read_file_chunked_to_disk(args.onnx) - - model_runner = OnnxRunner(model_path) - input_shapes = {name: (spec.shape, spec.dtype) for name, spec in model_runner.graph_inputs.items()} - state_pairs = {name: f'next_{name}' for name in input_shapes if f'next_{name}' in model_runner.graph_outputs} - out = { - 'metadata': make_metadata_dict(model_path), - 'input_shapes': input_shapes, - 'state_pairs': state_pairs, - 'input_devices': {'model': Device.DEFAULT}, - } - - run_model = make_run_model(model_runner, state_pairs) - out['run_model'] = compile_jit(TinyJit(run_model, prune=True), input_shapes, args.benchmark_runs) - - with open(args.output, "wb") as f: - dump_oob(out, f) - with open(args.output, "rb") as f: - load_oob(f) - assert not f.read(1), "unexpected model buffer data" - print(f"Saved JITs to {args.output} ({os.path.getsize(args.output) / 1e6:.2f} MB)") diff --git a/openpilot/selfdrive/modeld/compile_warp.py b/openpilot/selfdrive/modeld/compile_warp.py deleted file mode 100644 index 1b2c085537..0000000000 --- a/openpilot/selfdrive/modeld/compile_warp.py +++ /dev/null @@ -1,150 +0,0 @@ -import argparse -import pickle -import time -from collections import namedtuple - -from openpilot.selfdrive.modeld.helpers import patch_tinygrad_fetch_fw -patch_tinygrad_fetch_fw() - -from tinygrad.tensor import Tensor -from tinygrad.helpers import Context -from tinygrad.device import Device -from tinygrad.engine.jit import TinyJit - -from openpilot.system.camerad.cameras.nv12_info import get_nv12_info - - -NV12Frame = namedtuple("NV12Frame", ['width', 'height', 'stride', 'y_height', 'uv_height', 'size']) - - -def warp_perspective_tinygrad(src_flat, M_inv, dst_shape, src_shape, stride_pad, border_fill_val=None): - w_dst, h_dst = dst_shape - h_src, w_src = src_shape - - x = Tensor.arange(w_dst).reshape(1, w_dst).expand(h_dst, w_dst).reshape(-1) - y = Tensor.arange(h_dst).reshape(h_dst, 1).expand(h_dst, w_dst).reshape(-1) - - # inline 3x3 matmul as elementwise to avoid reduce op (enables fusion with gather) - src_x = M_inv[0, 0] * x + M_inv[0, 1] * y + M_inv[0, 2] - src_y = M_inv[1, 0] * x + M_inv[1, 1] * y + M_inv[1, 2] - src_w = M_inv[2, 0] * x + M_inv[2, 1] * y + M_inv[2, 2] - - src_x = src_x / src_w - src_y = src_y / src_w - - x_round = Tensor.round(src_x) - y_round = Tensor.round(src_y) - x_nn_clipped = x_round.clip(0, w_src - 1).cast('int') - y_nn_clipped = y_round.clip(0, h_src - 1).cast('int') - idx = y_nn_clipped * (w_src + stride_pad) + x_nn_clipped - sampled = src_flat[idx] - - if border_fill_val is None: - return sampled - - in_bounds = ((x_round >= 0) & (x_round <= w_src - 1) & - (y_round >= 0) & (y_round <= h_src - 1)).cast(sampled.dtype) - return sampled * in_bounds + Tensor(border_fill_val, dtype=sampled.dtype) * (1 - in_bounds) - - -def frames_to_tensor(frames): - H = (frames.shape[0] * 2) // 3 - W = frames.shape[1] - return Tensor.cat(frames[0:H:2, 0::2], - frames[1:H:2, 0::2], - frames[0:H:2, 1::2], - frames[1:H:2, 1::2], - frames[H:H+H//4].reshape((H//2, W//2)), - frames[H+H//4:H+H//2].reshape((H//2, W//2)), dim=0).reshape((6, H//2, W//2)) - - -def make_frame_prepare(nv12: NV12Frame, model_w, model_h, layout="yuv420", border_fill=None): - cam_w, cam_h, stride, y_height, uv_height, _ = nv12 - uv_offset = stride * y_height - stride_pad = stride - cam_w - - def frame_prepare_tinygrad(input_frame, M_inv): - if layout == "luma": - return warp_perspective_tinygrad(input_frame[:cam_h*stride], M_inv, - (model_w, model_h), (cam_h, cam_w), stride_pad, - border_fill_val=border_fill).reshape(-1, model_h * model_w) - # UV_SCALE @ M_inv @ UV_SCALE_INV simplifies to elementwise scaling - M_inv_uv = M_inv * Tensor([[1.0, 1.0, 0.5], [1.0, 1.0, 0.5], [2.0, 2.0, 1.0]], device=Device.DEFAULT) - # deinterleave NV12 UV plane (UVUV... -> separate U, V) - uv = input_frame[uv_offset:uv_offset + uv_height * stride].reshape(uv_height, stride) - with Context(SPLIT_REDUCEOP=0): - y = warp_perspective_tinygrad(input_frame[:cam_h*stride], - M_inv, (model_w, model_h), - (cam_h, cam_w), stride_pad, border_fill_val=border_fill).realize() - u = warp_perspective_tinygrad(uv[:cam_h//2, :cam_w:2].flatten(), - M_inv_uv, (model_w//2, model_h//2), - (cam_h//2, cam_w//2), 0, border_fill_val=border_fill).realize() - v = warp_perspective_tinygrad(uv[:cam_h//2, 1:cam_w:2].flatten(), - M_inv_uv, (model_w//2, model_h//2), - (cam_h//2, cam_w//2), 0, border_fill_val=border_fill).realize() - yuv = y.cat(u).cat(v).reshape((model_h * 3 // 2, model_w)) - return frames_to_tensor(yuv) - return frame_prepare_tinygrad - - -def make_warp(nv12, model_w, model_h, layout="yuv420", border_fill=None, frames=1): - frame_prepare = make_frame_prepare(nv12, model_w, model_h, layout, border_fill) - - def warp(input_frames, transforms): - input_frames = input_frames.to(Device.DEFAULT) - transforms = transforms.to(Device.DEFAULT) - Tensor.realize(input_frames, transforms) - if frames == 1: - return frame_prepare(input_frames, transforms) - return Tensor.stack(*(frame_prepare(input_frames[i], transforms[i]) for i in range(frames))) - - return warp - - -def _parse_size(s): - w, h = s.lower().split('x') - return int(w), int(h) - - -def compile_warp(nv12: NV12Frame, model_w, model_h, pkl_path, layout, border_fill=None, - frames=1, transform_device=None, frame_size=None): - print(f"Compiling {layout} warp for {nv12.width}x{nv12.height} -> {model_w}x{model_h}...") - - warp_jit = TinyJit(make_warp(nv12, model_w, model_h, layout, border_fill, frames), prune=True) - frame_size = nv12.size if frame_size is None else frame_size - frame_shape = (frame_size,) if frames == 1 else (frames, frame_size) - transform_shape = (3, 3) if frames == 1 else (frames, 3, 3) - - for i in range(10): - frame = Tensor.randint(*frame_shape, low=0, high=256, dtype='uint8').realize() - M_inv = Tensor(Tensor.randn(*transform_shape).mul(8).realize().numpy(), device=transform_device) - Device.default.synchronize() - st = time.perf_counter() - warp_jit(frame, M_inv).realize() - mt = time.perf_counter() - Device.default.synchronize() - et = time.perf_counter() - print(f" [{i+1}/10] enqueue {(mt-st)*1e3:6.2f} ms -- total {(et-st)*1e3:6.2f} ms") - - with open(pkl_path, "wb") as f: - pickle.dump(warp_jit, f) - print(f" Saved to {pkl_path}") - - -if __name__ == "__main__": - p = argparse.ArgumentParser() - p.add_argument('--camera-resolution', type=_parse_size, required=True, help='camera resolution WxH') - p.add_argument('--warp-to', type=_parse_size, required=True, help='output WxH') - p.add_argument('--layout', choices=['luma', 'yuv420'], required=True) - p.add_argument('--border-fill', type=int, help='fill value outside the frame; omit to clamp coordinates') - p.add_argument('--frames', type=int, default=1, help='number of frames to warp together') - p.add_argument('--transform-device', help='device holding the input transforms; default: compute device') - p.add_argument('--frame-size', type=int, help='input frame size in bytes; default: full NV12 allocation') - p.add_argument('--output', required=True) - args = p.parse_args() - - cam_w, cam_h = args.camera_resolution - nv12 = NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) - model_w, model_h = args.warp_to - compile_warp(nv12, model_w, model_h, args.output, args.layout, args.border_fill, - args.frames, args.transform_device, args.frame_size) diff --git a/openpilot/selfdrive/modeld/dmonitoringmodeld.py b/openpilot/selfdrive/modeld/dmonitoringmodeld.py index 4010725b89..4d84aa0cd6 100755 --- a/openpilot/selfdrive/modeld/dmonitoringmodeld.py +++ b/openpilot/selfdrive/modeld/dmonitoringmodeld.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 import os -from openpilot.selfdrive.modeld.helpers import MODELS_DIR, get_tg_input_devices +from openpilot.selfdrive.modeld.helpers import MODELS_DIR, load_oob from tinygrad.tensor import Tensor import time import pickle @@ -18,10 +18,8 @@ from openpilot.system.camerad.cameras.nv12_info import get_nv12_info from openpilot.common.file_chunker import open_file_chunked from openpilot.selfdrive.modeld.parse_model_outputs import sigmoid, safe_exp -PROCESS_NAME = "openpilot.selfdrive.modeld.dmonitoringmodeld" SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') MODEL_PKL_PATH = MODELS_DIR / 'dmonitoring_model_tinygrad.pkl' -METADATA_PATH = MODELS_DIR / 'dmonitoring_model_metadata.pkl' class ModelState: @@ -29,11 +27,10 @@ class ModelState: output: np.ndarray def __init__(self, cam_w: int, cam_h: int): - self.DEV = get_tg_input_devices(PROCESS_NAME, chestnut=False)['DEV'] - with open(METADATA_PATH, 'rb') as f: - model_metadata = pickle.load(f) - self.input_shapes = model_metadata['input_shapes'] - self.output_slices = model_metadata['output_slices'] + jits = load_oob(open_file_chunked(MODEL_PKL_PATH)) + self.DEV = jits['input_devices']['model'] + self.input_shapes = jits['metadata']['input_shapes'] + self.output_slices = jits['metadata']['output_slices'] self.numpy_inputs = { 'calib': np.zeros(self.input_shapes['calib'], dtype=np.float32), @@ -42,9 +39,10 @@ class ModelState: self.warp_inputs_np = {'transform': np.zeros((3,3), dtype=np.float32)} self.warp_inputs = {k: Tensor(v, device='NPY') for k,v in self.warp_inputs_np.items()} self.frame_buf_params = get_nv12_info(cam_w, cam_h) - self.tensor_inputs = {k: Tensor(v, device='NPY').realize() for k,v in self.numpy_inputs.items()} + self.tensor_inputs = {k: Tensor(v, device=self.DEV).realize() for k,v in self.numpy_inputs.items()} + self.calib_host = Tensor(self.numpy_inputs['calib'], device='NPY')._buffer() self._blob_cache : dict[int, Tensor] = {} - self.model_run = pickle.load(open_file_chunked(str(MODEL_PKL_PATH))) + self.model_run = jits['run_model'] with open(MODELS_DIR / f'dm_warp_{cam_w}x{cam_h}_tinygrad.pkl', "rb") as f: self.image_warp = pickle.load(f) @@ -52,6 +50,7 @@ class ModelState: self.numpy_inputs['calib'][0,:] = calib t1 = time.perf_counter() + self.tensor_inputs['calib']._buffer().copy_from(self.calib_host) ptr = np.frombuffer(buf.data, dtype=np.uint8).ctypes.data # There is a ringbuffer of imgs, just cache tensors pointing to all of them @@ -61,7 +60,8 @@ class ModelState: self.warp_inputs_np['transform'][:] = transform[:] self.tensor_inputs['input_img'] = self.image_warp(self._blob_cache[ptr], self.warp_inputs['transform']) - output = self.model_run(**self.tensor_inputs).numpy().flatten() + output, = self.model_run(**self.tensor_inputs) + output = output.numpy().astype(np.float32).reshape(-1) t2 = time.perf_counter() return output, t2 - t1 diff --git a/openpilot/selfdrive/modeld/get_model_metadata.py b/openpilot/selfdrive/modeld/get_model_metadata.py deleted file mode 100755 index e4c173957a..0000000000 --- a/openpilot/selfdrive/modeld/get_model_metadata.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env python3 -import sys -import pathlib -import codecs -import pickle -from typing import Any - -from tinygrad.nn.onnx import OnnxPBParser - - -class MetadataOnnxPBParser(OnnxPBParser): - def _parse_ModelProto(self) -> dict: - obj: dict[str, Any] = {"graph": {"input": [], "output": []}, "metadata_props": []} - for fid, wire_type in self._parse_message(self.reader.len): - match fid: - case 7: - obj["graph"] = self._parse_GraphProto() - case 14: - obj["metadata_props"].append(self._parse_StringStringEntryProto()) - case _: - self.reader.skip_field(wire_type) - return obj - - -def get_name_and_shape(value_info: dict[str, Any]) -> tuple[str, tuple[int, ...]]: - shape = tuple(int(dim) if isinstance(dim, int) else 0 for dim in value_info["parsed_type"].shape) - name = value_info["name"] - return name, shape - - -def get_metadata_value_by_name(model: dict[str, Any], name: str) -> str | Any: - for prop in model["metadata_props"]: - if prop["key"] == name: - return prop["value"] - return None - - -def make_metadata_dict(model_path): - model = MetadataOnnxPBParser(model_path).parse() - output_slices = get_metadata_value_by_name(model, 'output_slices') - assert output_slices is not None, 'output_slices not found in metadata' - return { - 'model_checkpoint': get_metadata_value_by_name(model, 'model_checkpoint'), - 'output_slices': pickle.loads(codecs.decode(output_slices.encode(), "base64")), - 'input_shapes': dict(get_name_and_shape(x) for x in model["graph"]["input"]), - 'output_shapes': dict(get_name_and_shape(x) for x in model["graph"]["output"]), - } - - -if __name__ == "__main__": - model_path = pathlib.Path(sys.argv[1]) - metadata_path = model_path.parent / (model_path.stem + '_metadata.pkl') - with open(metadata_path, 'wb') as f: - pickle.dump(make_metadata_dict(model_path), f) - print(f'saved metadata to {metadata_path}') diff --git a/openpilot/selfdrive/modeld/helpers.py b/openpilot/selfdrive/modeld/helpers.py index a5b64d1b25..307cb7c163 100644 --- a/openpilot/selfdrive/modeld/helpers.py +++ b/openpilot/selfdrive/modeld/helpers.py @@ -1,56 +1,18 @@ import io -import json import pickle -import shutil import struct -import tempfile from pathlib import Path from openpilot.common.file_chunker import get_manifest_path from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, USB_DEVICES_PATH, is_chestnut_usb_id MODELS_DIR = Path(__file__).resolve().parent / 'models' -TG_INPUT_DEVICES_PATH = MODELS_DIR / 'tg_input_devices.json' -def patch_tinygrad_fetch_fw(): - import hashlib - import zstandard - from tinygrad import helpers - original_fetch_fw = helpers.fetch_fw - def fetch_fw(path, name, sha256): - p = Path(f"/lib/firmware/{path}/{name}.zst") - if p.is_file(): - blob = zstandard.ZstdDecompressor().stream_reader(p.read_bytes()).read() - if hashlib.sha256(blob).hexdigest() == sha256: - return blob - return original_fetch_fw(path, name, sha256) - helpers.fetch_fw = fetch_fw - - -def get_tg_input_devices(process_name: str, chestnut: bool): - with open(TG_INPUT_DEVICES_PATH) as f: - return json.load(f)[process_name]['default' if not chestnut else 'chestnut'] - def modeld_pkl_path(chestnut: bool): prefix = 'big_' if chestnut else '' return MODELS_DIR / f'{prefix}driving_tinygrad.pkl' -def dump_oob(obj, f): - with tempfile.TemporaryFile(dir=".") as tmp: - def buffer_callback(pb: pickle.PickleBuffer): - m = pb.raw() - tmp.write(struct.pack(' None: # Pack host inputs into one upload to reduce USB transfer overhead for the eGPU. - self.input_queues = make_input_queues({name: self.input_shapes[name] for name in self.state_pairs}, self.model_device) + self.input_queues = {name: Tensor(np.zeros(shape, dtype=dtype.fmt), device=self.model_device).realize() + for name, (shape, dtype) in self.input_shapes.items() if name in self.state_pairs} shapes = {'tfm': (2, 3, 3)} | {name: shape for name, (shape, _) in self.input_shapes.items() if name not in self.state_pairs and name != 'new_img'} npy_size = sum(round_up(math.prod(shape) * 4, 128) for shape in shapes.values()) diff --git a/tinygrad_repo b/tinygrad_repo index f6fc4e3f2c..953a7f36cf 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit f6fc4e3f2c3db5fae1e19cbfbc3ad9fc579a12ae +Subproject commit 953a7f36cfda50db17ee94505143474061c12e9d From f15544b48263a8ce91f1ebdb13787b0f9efd7710 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Tue, 15 Sep 2026 13:20:49 -0700 Subject: [PATCH 120/122] rm pyserial from updater bundle (#38925) rm pyserial dependency from updater package --- openpilot/common/hardware/comma/updater | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpilot/common/hardware/comma/updater b/openpilot/common/hardware/comma/updater index 44b82d0c54..35227d2d79 100755 --- a/openpilot/common/hardware/comma/updater +++ b/openpilot/common/hardware/comma/updater @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3a94ab8395f20d20a9d5a2a2bacca0694f072df8421cf13adca6250d28065bdc -size 24709205 +oid sha256:6a7adb302d378dda7b1788a841b89e4f905c872550a70a76650dcde977b4ece0 +size 24709209 From 81ae1a2e2dbd7147544732acca32c4db9cf10b0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Tue, 15 Sep 2026 18:01:25 -0700 Subject: [PATCH 121/122] Use tinygrad generic ONNX compiler artifacts (#38926) * Use tinygrad generic ONNX compiler artifacts * Keep compiler temporary model buffers off device tmpfs * Keep recurrent model state updates on the GPU * Use compiled output buffers and swap recurrent state inputs * Note when ONNX chunk reassembly can be removed * Bump tinygrad to rebased compilers and AMD queue fix * Keep recurrent buffers fixed and use faster USB argument updates * Bump tinygrad for faster model startup --- .gitmodules | 2 +- openpilot/selfdrive/modeld/SConscript | 35 ++++++++++++------- .../selfdrive/modeld/dmonitoringmodeld.py | 16 +++++---- openpilot/selfdrive/modeld/modeld.py | 27 ++++++++------ tinygrad_repo | 2 +- 5 files changed, 50 insertions(+), 32 deletions(-) diff --git a/.gitmodules b/.gitmodules index 7e37596b0a..15ab89d49c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -16,4 +16,4 @@ [submodule "tinygrad"] path = tinygrad_repo url = https://github.com/commaai/tinygrad.git - branch = openpilot-modeld-test + branch = model-warp-compile diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index 6ccf711c4c..c42e9f2335 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -1,8 +1,10 @@ import glob import os +import shutil +import tempfile import time from SCons.Script import Action, Value -from openpilot.common.file_chunker import chunk_file, get_chunk_targets, get_existing_chunks +from openpilot.common.file_chunker import chunk_file, get_chunk_targets, get_existing_chunks, open_file_chunked from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE, DM_INPUT_SIZE from openpilot.selfdrive.modeld.helpers import chestnut_present, modeld_pkl_path @@ -41,7 +43,7 @@ if CHESTNUT: mac_brew_string = f'HOME={os.path.expanduser("~")}' if arch == 'Darwin' else '' warp_deps = [File("#openpilot/system/camerad/cameras/nv12_info.py")] -compiler = 'python3 -m examples.openpilot' +compiler = Dir('#tinygrad_repo/examples/openpilot').abspath # CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it. taskset = 'taskset -c 7 ' if arch == 'comma_arm64' else '' @@ -65,13 +67,22 @@ def chestnut_action(command, pkl=None, chunks=()): def compile_model(onnx_path, pkl_path, flags, chestnut=False): onnx_path, target_pkl_path = File(onnx_path).abspath, File(pkl_path).abspath onnx_deps = get_existing_chunks(onnx_path) - cmd = (f'{flags} {mac_brew_string} {taskset}{compiler}.compile_onnx ' - f'--onnx {onnx_path} --output {target_pkl_path}') + cmd = (f'{flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_onnx.py" ' + f'"{{onnx}}" "{target_pkl_path}" --device-input "*" --out-of-band --benchmark-runs 1') + def do_compile(target, source, env): + if os.path.isfile(onnx_path): + return env.Execute(cmd.format(onnx=onnx_path)) + # TODO: Remove ONNX chunk reassembly once models are precompiled. + with tempfile.NamedTemporaryFile(dir=os.path.dirname(onnx_path), suffix='.onnx') as tmp, open_file_chunked(onnx_path) as src: + shutil.copyfileobj(src, tmp) + tmp.flush() + return env.Execute(cmd.format(onnx=tmp.name)) + compile_action = Action(do_compile, " [ONNX] $TARGET") onnx_sizes_sum = sum(os.path.getsize(f) for f in onnx_deps) chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum)) def do_chunk(target, source, env, pkl=target_pkl_path, chunks=chunk_targets): chunk_file(pkl, chunks) - actions = chestnut_action(cmd, target_pkl_path, chunk_targets) if chestnut else [cmd, Action(do_chunk, " [CHUNK] $TARGET")] + actions = chestnut_action(compile_action, target_pkl_path, chunk_targets) if chestnut else [compile_action, Action(do_chunk, " [CHUNK] $TARGET")] node = lenv.Command( chunk_targets, tinygrad_files + onnx_deps + [Value(cmd), Value(chunk_targets), chunker_file], @@ -89,9 +100,9 @@ for chestnut in [False, True] if CHESTNUT else [False]: for cam_w, cam_h in camera_configs: warp_pkl_path = File(f"models/{file_prefix}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath stride, y_height, uv_height, _ = get_nv12_info(cam_w, cam_h) - cmd = (f'{cmd_flags} {mac_brew_string} {taskset}{compiler}.compile_warp ' - f'--camera-resolution {cam_w}x{cam_h} --warp-to {model_w}x{model_h} --layout yuv420 ' - f'--frames 2 --stride {stride} --uv-offset {stride * y_height} --frame-size {stride * (y_height + uv_height)} ' + cmd = (f'{cmd_flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_warp.py" ' + f'--frame {cam_w},{cam_h},{stride},{y_height},{uv_height},{stride * (y_height + uv_height)} ' + f'--warp-to {model_w}x{model_h} --layout yuv420 --frames 2 ' f'--output {warp_pkl_path}') action = chestnut_action(cmd) if chestnut else cmd node = lenv.Command(warp_pkl_path, tinygrad_files + warp_deps + [Value(cmd)], action) @@ -101,8 +112,8 @@ for chestnut in [False, True] if CHESTNUT else [False]: dm_w, dm_h = DM_INPUT_SIZE for cam_w, cam_h in camera_configs: dm_pkl_path = File(f"models/dm_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath - stride, y_height, _, frame_size = get_nv12_info(cam_w, cam_h) - cmd = (f'{tg_flags} {mac_brew_string} {compiler}.compile_warp ' - f'--camera-resolution {cam_w}x{cam_h} --warp-to {dm_w}x{dm_h} --layout luma --border-fill 16 --transform-device NPY ' - f'--stride {stride} --uv-offset {stride * y_height} --frame-size {frame_size} --output {dm_pkl_path}') + stride, y_height, uv_height, frame_size = get_nv12_info(cam_w, cam_h) + cmd = (f'{tg_flags} {mac_brew_string} python3 "{compiler}/compile_warp.py" ' + f'--frame {cam_w},{cam_h},{stride},{y_height},{uv_height},{frame_size} --warp-to {dm_w}x{dm_h} ' + f'--layout luma --border-fill 16 --transform-device NPY --output {dm_pkl_path}') lenv.Command(dm_pkl_path, tinygrad_files + warp_deps + [Value(cmd)], cmd) diff --git a/openpilot/selfdrive/modeld/dmonitoringmodeld.py b/openpilot/selfdrive/modeld/dmonitoringmodeld.py index 4d84aa0cd6..06d0d2e5e7 100755 --- a/openpilot/selfdrive/modeld/dmonitoringmodeld.py +++ b/openpilot/selfdrive/modeld/dmonitoringmodeld.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import os +import base64 from openpilot.selfdrive.modeld.helpers import MODELS_DIR, load_oob from tinygrad.tensor import Tensor import time @@ -28,9 +29,9 @@ class ModelState: def __init__(self, cam_w: int, cam_h: int): jits = load_oob(open_file_chunked(MODEL_PKL_PATH)) - self.DEV = jits['input_devices']['model'] + self.DEV = jits['input_specs']['input_img'][2] self.input_shapes = jits['metadata']['input_shapes'] - self.output_slices = jits['metadata']['output_slices'] + self.output_slices = pickle.loads(base64.b64decode(jits['metadata']['metadata']['output_slices'])) self.numpy_inputs = { 'calib': np.zeros(self.input_shapes['calib'], dtype=np.float32), @@ -42,9 +43,10 @@ class ModelState: self.tensor_inputs = {k: Tensor(v, device=self.DEV).realize() for k,v in self.numpy_inputs.items()} self.calib_host = Tensor(self.numpy_inputs['calib'], device='NPY')._buffer() self._blob_cache : dict[int, Tensor] = {} - self.model_run = jits['run_model'] + self.model_run = jits['run'] + self.outputs = {name: Tensor(np.zeros(shape, dtype=dtype), device=device).realize() for name, (shape, dtype, device) in jits['output_specs'].items()} with open(MODELS_DIR / f'dm_warp_{cam_w}x{cam_h}_tinygrad.pkl', "rb") as f: - self.image_warp = pickle.load(f) + self.image_warp = pickle.load(f)['run'] def run(self, buf: VisionBuf, calib: np.ndarray, transform: np.ndarray) -> tuple[np.ndarray, float]: self.numpy_inputs['calib'][0,:] = calib @@ -58,10 +60,10 @@ class ModelState: self._blob_cache[ptr] = Tensor.from_blob(ptr, (self.frame_buf_params[3],), dtype='uint8', device=self.DEV) self.warp_inputs_np['transform'][:] = transform[:] - self.tensor_inputs['input_img'] = self.image_warp(self._blob_cache[ptr], self.warp_inputs['transform']) + self.tensor_inputs['input_img'] = self.image_warp(input_frame=self._blob_cache[ptr], M_inv=self.warp_inputs['transform']) - output, = self.model_run(**self.tensor_inputs) - output = output.numpy().astype(np.float32).reshape(-1) + self.model_run(output_buffers=self.outputs, **self.tensor_inputs) + output = self.outputs['outputs'].numpy().astype(np.float32).reshape(-1) t2 = time.perf_counter() return output, t2 - t1 diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index db0b4d83b1..42187a04f3 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 from collections.abc import Callable +import base64 import ctypes from functools import cached_property import os @@ -139,11 +140,11 @@ class ModelState: def __init__(self, cam_w: int, cam_h: int, chestnut: bool): jits = load_oob(open_file_chunked(modeld_pkl_path(chestnut))) - self.model_device = jits['input_devices']['model'] - self.input_shapes = jits['input_shapes'] - self.state_pairs = jits['state_pairs'] + self.model_device = jits['input_specs']['new_img'][2] + self.input_shapes = {name: (shape, np.dtype(dtype)) for name, (shape, dtype, _) in jits['input_specs'].items()} + self.state_pairs = {name: f'next_{name}' for name in self.input_shapes if f'next_{name}' in jits['metadata']['output_shapes']} self.vision_input_names = ('img', 'big_img') - self.output_slices = jits['metadata']['output_slices'] + self.output_slices = pickle.loads(base64.b64decode(jits['metadata']['metadata']['output_slices'])) self.prev_desire = np.zeros(ModelConstants.DESIRE_LEN, dtype=np.float32) self.chestnut = chestnut @@ -152,13 +153,17 @@ class ModelState: self.frame_copy_size = stride * (y_height + uv_height) self.pack_inputs() with open(MODELS_DIR / f'{"big_" if chestnut else ""}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl', 'rb') as f: - self.run_warp = pickle.load(f) - self.run_model = jits['run_model'] + self.run_warp = pickle.load(f)['run'] + self.run_model = jits['run'] + self.outputs = {name: Tensor(np.zeros(shape, dtype=dtype), device=device).realize() for name, (shape, dtype, device) in jits['output_specs'].items()} + for name, next_name in self.state_pairs.items(): + state = self.input_queues[name] + self.outputs[next_name] = input_view(state._buffer(), state.shape, state.dtype, 0) self.parser = Parser() def pack_inputs(self) -> None: # Pack host inputs into one upload to reduce USB transfer overhead for the eGPU. - self.input_queues = {name: Tensor(np.zeros(shape, dtype=dtype.fmt), device=self.model_device).realize() + self.input_queues = {name: Tensor(np.zeros(shape, dtype=dtype), device=self.model_device).realize() for name, (shape, dtype) in self.input_shapes.items() if name in self.state_pairs} shapes = {'tfm': (2, 3, 3)} | {name: shape for name, (shape, _) in self.input_shapes.items() if name not in self.state_pairs and name != 'new_img'} @@ -173,7 +178,7 @@ class ModelState: self.input_queues[name] = input_view(self.input_device, shape, dtypes.float32, offset) offset += round_up(self.npy[name].nbytes, 128) self.frames = self.packed_input[npy_size:].reshape(2, self.frame_copy_size) - self.warp_inputs = (input_view(self.input_device, self.frames.shape, dtypes.uint8, npy_size), self.input_queues.pop('tfm')) + self.warp_inputs = {'input_frame': input_view(self.input_device, self.frames.shape, dtypes.uint8, npy_size), 'M_inv': self.input_queues.pop('tfm')} def slice_outputs(self, model_outputs: np.ndarray, output_slices: dict[str, slice]) -> dict[str, np.ndarray]: return {k: model_outputs[np.newaxis, v] for k,v in output_slices.items()} @@ -192,11 +197,11 @@ class ModelState: self.npy['action_t'][:] = inputs['action_t'] self.input_device.copy_from(self.input_host) - self.input_queues['new_img'] = self.run_warp(*self.warp_inputs) - outs, = self.run_model(**self.input_queues) + self.input_queues['new_img'] = self.run_warp(**self.warp_inputs) + self.run_model(output_buffers=self.outputs, **self.input_queues) if after_enqueue is not None: after_enqueue() - model_output = outs.numpy()[0] + model_output = self.outputs['outputs'].numpy()[0] if self.chestnut and not np.all(np.isfinite(model_output)): raise RuntimeError("model output not finite") outputs_dict = self.parser.parse_outputs(self.slice_outputs(model_output, self.output_slices)) diff --git a/tinygrad_repo b/tinygrad_repo index 953a7f36cf..d5e17c935d 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit 953a7f36cfda50db17ee94505143474061c12e9d +Subproject commit d5e17c935daf11f6318e45aade9528f71b8fbdcc From 6080cc6168023229437b0ff06cf35bead00a5d0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Wed, 16 Sep 2026 08:13:45 -0700 Subject: [PATCH 122/122] Use a precompiled eGPU driving model (#38930) * Ship precompiled eGPU model and camera warps Compile f78ed37d-afad-4dbc-8050-40ea885eedde/12864 through xx/ml_tools/openpilot_compile using the pinned tinygrad version. * Precompile the existing master driving model Use the unchanged master ONNX (SHA-256 6fee5937923c74848df4a63f6239eb6331c6274dd4bdb7a5d6ec0388a8b543d5) instead of updating the trained model. * Compile camera warps on device * Remove obsolete ONNX chunking and big model build check * Chunk model artifacts only during release packaging * Require model and camera warps for Chestnut readiness * Recompile precompiled CPU helpers for the runtime host * Ship the eGPU model with an ARM submission helper * Exempt model pickles from the build product size limit --- .gitattributes | 1 + .github/workflows/docs.yaml | 2 +- .github/workflows/release.yaml | 2 +- .github/workflows/repo-maintenance.yaml | 2 +- .github/workflows/tests.yaml | 2 +- .gitignore | 1 + SConstruct | 2 + openpilot/common/file_chunker.py | 14 +---- openpilot/selfdrive/modeld/SConscript | 55 +++++-------------- openpilot/selfdrive/modeld/helpers.py | 4 +- .../modeld/models/big_driving_supercombo.onnx | 3 - .../modeld/models/big_driving_tinygrad.pkl | 3 + openpilot/selfdrive/test/chestnut.sh | 3 - openpilot/selfdrive/test/setup_device_ci.sh | 5 +- tools/release/build_release.sh | 8 ++- tools/release/build_stripped.sh | 2 +- tools/release/release_files.py | 2 +- 17 files changed, 39 insertions(+), 72 deletions(-) delete mode 100644 openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx create mode 100644 openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl diff --git a/.gitattributes b/.gitattributes index 2f8f3ece32..ef658d3055 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,7 @@ # to move existing files into LFS: # git add --renormalize . *.onnx filter=lfs diff=lfs merge=lfs -text +openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl filter=lfs diff=lfs merge=lfs -text *.svg filter=lfs diff=lfs merge=lfs -text *.png filter=lfs diff=lfs merge=lfs -text *.gif filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 7997b86b7e..88c071475d 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -18,7 +18,7 @@ concurrency: env: GIT_CONFIG_COUNT: 1 GIT_CONFIG_KEY_0: lfs.fetchexclude - GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx + GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl jobs: docs: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a5a91a482b..76948d1057 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -7,7 +7,7 @@ on: env: GIT_CONFIG_COUNT: 1 GIT_CONFIG_KEY_0: lfs.fetchexclude - GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx + GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl jobs: build_masterci: diff --git a/.github/workflows/repo-maintenance.yaml b/.github/workflows/repo-maintenance.yaml index ef4a2795c2..7feb77ff46 100644 --- a/.github/workflows/repo-maintenance.yaml +++ b/.github/workflows/repo-maintenance.yaml @@ -11,7 +11,7 @@ env: PYTHONPATH: ${{ github.workspace }} GIT_CONFIG_COUNT: 1 GIT_CONFIG_KEY_0: lfs.fetchexclude - GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx + GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl jobs: package_updates: diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index b8b1ace97a..ff23cf5c01 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -22,7 +22,7 @@ env: PYTHONPATH: ${{ github.workspace }} GIT_CONFIG_COUNT: 1 GIT_CONFIG_KEY_0: lfs.fetchexclude - GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx + GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl jobs: build_release: diff --git a/.gitignore b/.gitignore index 54f9f176b7..ff67a1cab8 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ st[0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z] *.stats *.pkl *.pkl* +!openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl config.json compile_commands.json compare_runtime*.html diff --git a/SConstruct b/SConstruct index c6d758b318..d524a20e0f 100644 --- a/SConstruct +++ b/SConstruct @@ -342,6 +342,8 @@ AddPostAction(BUILD_TARGETS or [Dir('.')], prune_cache_dir) def check_build_product_size(target, source, env): limit = 50 * 1024 * 1024 # GitHub max size for t in target: + if str(t).endswith('.pkl'): # chunked during release packaging + continue if hasattr(t, 'isfile') and t.isfile() and (size := os.path.getsize(t.abspath)) > limit: raise SCons.Errors.UserError(f"{t} is {size / (1024 * 1024):.1f} MiB, exceeding the {limit / (1024 * 1024):.1f} MiB limit") if not GetOption('extras'): diff --git a/openpilot/common/file_chunker.py b/openpilot/common/file_chunker.py index 5bf30de9ab..28a7e1212e 100755 --- a/openpilot/common/file_chunker.py +++ b/openpilot/common/file_chunker.py @@ -32,14 +32,6 @@ def chunk_file(path, targets): Path(manifest_path).write_text(str(len(chunk_paths))) os.remove(path) -def get_existing_chunks(path): - if os.path.isfile(path): - return [path] - if os.path.isfile(manifest := get_manifest_path(path)): - num_chunks = int(Path(manifest).read_text().strip()) - return _chunk_paths(path, num_chunks) - raise FileNotFoundError(path) - class ChunkStream(io.RawIOBase): def __init__(self, paths): self._paths = iter(paths) @@ -67,11 +59,11 @@ class ChunkStream(io.RawIOBase): def open_file_chunked(path): manifest_path = get_manifest_path(path) - if os.path.isfile(manifest_path): + if os.path.isfile(path): + paths = [path] + elif os.path.isfile(manifest_path): num_chunks = int(Path(manifest_path).read_text().strip()) paths = [get_chunk_name(path, i, num_chunks) for i in range(num_chunks)] - elif os.path.isfile(path): - paths = [path] else: raise FileNotFoundError(path) return io.BufferedReader(ChunkStream(paths)) diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index c42e9f2335..2aac90fe63 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -1,18 +1,14 @@ import glob import os -import shutil -import tempfile import time from SCons.Script import Action, Value -from openpilot.common.file_chunker import chunk_file, get_chunk_targets, get_existing_chunks, open_file_chunked from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE, DM_INPUT_SIZE -from openpilot.selfdrive.modeld.helpers import chestnut_present, modeld_pkl_path +from openpilot.selfdrive.modeld.helpers import chestnut_present from openpilot.system.camerad.cameras.nv12_info import get_nv12_info Import('env', 'arch') -chunker_file = File("#openpilot/common/file_chunker.py") lenv = env.Clone() lenv.PrependENVPath('PYTHONPATH', Dir('#tinygrad_repo').abspath) @@ -20,11 +16,6 @@ tinygrad_root = env.Dir("#").abspath tinygrad_files = ["#"+x for x in glob.glob(env.Dir("#tinygrad_repo").relpath + "/**", recursive=True, root_dir=tinygrad_root) if 'pycache' not in x and os.path.isfile(os.path.join(tinygrad_root, x))] -def estimate_pickle_max_size(onnx_size): - # QCOM programs for models with spatial recurrent features can approach 2x - # the ONNX size. Overestimating only adds an empty trailing chunk. - return 2.0 * onnx_size + 10 * 1024 * 1024 - camera_configs = [(c.width, c.height) for c in (_ar_ox_fisheye, _os_fisheye)] if arch == 'comma_arm64': @@ -47,7 +38,7 @@ compiler = Dir('#tinygrad_repo/examples/openpilot').abspath # CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it. taskset = 'taskset -c 7 ' if arch == 'comma_arm64' else '' -def chestnut_action(command, pkl=None, chunks=()): +def chestnut_action(command): def do_compile(target, source, env): from openpilot.system.hardware.chestnut.flash import link_up # chestnut can enumerate before its PCIe link is up due to varying 12V power behavior across cars @@ -56,47 +47,27 @@ def chestnut_action(command, pkl=None, chunks=()): break time.sleep(1) else: - print("Chestnut not ready, skipping big model build") + print("Chestnut not ready, skipping warp build") return - if ret := env.Execute(command): - return ret - if chunks: - chunk_file(pkl, chunks) + return env.Execute(command) return Action(do_compile, " [CHESTNUT] $TARGET") -def compile_model(onnx_path, pkl_path, flags, chestnut=False): +def compile_model(onnx_path, pkl_path): onnx_path, target_pkl_path = File(onnx_path).abspath, File(pkl_path).abspath - onnx_deps = get_existing_chunks(onnx_path) - cmd = (f'{flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_onnx.py" ' - f'"{{onnx}}" "{target_pkl_path}" --device-input "*" --out-of-band --benchmark-runs 1') - def do_compile(target, source, env): - if os.path.isfile(onnx_path): - return env.Execute(cmd.format(onnx=onnx_path)) - # TODO: Remove ONNX chunk reassembly once models are precompiled. - with tempfile.NamedTemporaryFile(dir=os.path.dirname(onnx_path), suffix='.onnx') as tmp, open_file_chunked(onnx_path) as src: - shutil.copyfileobj(src, tmp) - tmp.flush() - return env.Execute(cmd.format(onnx=tmp.name)) - compile_action = Action(do_compile, " [ONNX] $TARGET") - onnx_sizes_sum = sum(os.path.getsize(f) for f in onnx_deps) - chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum)) - def do_chunk(target, source, env, pkl=target_pkl_path, chunks=chunk_targets): - chunk_file(pkl, chunks) - actions = chestnut_action(compile_action, target_pkl_path, chunk_targets) if chestnut else [compile_action, Action(do_chunk, " [CHUNK] $TARGET")] - node = lenv.Command( - chunk_targets, - tinygrad_files + onnx_deps + [Value(cmd), Value(chunk_targets), chunker_file], - actions, + cmd = (f'{tg_flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_onnx.py" ' + f'"{onnx_path}" "{target_pkl_path}" --device-input "*" --out-of-band --benchmark-runs 1') + lenv.Command( + target_pkl_path, + tinygrad_files + [onnx_path, Value(cmd)], + Action(cmd, " [ONNX] $TARGET"), ) - if chestnut: - lenv.SideEffect(chestnut_lock, node) -compile_model('models/dmonitoring_model.onnx', 'models/dmonitoring_model_tinygrad.pkl', tg_flags) +compile_model('models/dmonitoring_model.onnx', 'models/dmonitoring_model_tinygrad.pkl') +compile_model('models/driving_supercombo.onnx', 'models/driving_tinygrad.pkl') model_w, model_h = MEDMODEL_INPUT_SIZE for chestnut in [False, True] if CHESTNUT else [False]: file_prefix, cmd_flags = ('big_', chestnut_tg_flags) if chestnut else ('', tg_flags) - compile_model(f'models/{file_prefix}driving_supercombo.onnx', modeld_pkl_path(chestnut), cmd_flags, chestnut) for cam_w, cam_h in camera_configs: warp_pkl_path = File(f"models/{file_prefix}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath stride, y_height, uv_height, _ = get_nv12_info(cam_w, cam_h) diff --git a/openpilot/selfdrive/modeld/helpers.py b/openpilot/selfdrive/modeld/helpers.py index 307cb7c163..5e7f413c26 100644 --- a/openpilot/selfdrive/modeld/helpers.py +++ b/openpilot/selfdrive/modeld/helpers.py @@ -35,4 +35,6 @@ def chestnut_present() -> bool: return False def chestnut_compiled() -> bool: - return Path(get_manifest_path(modeld_pkl_path(chestnut=True))).is_file() + path = modeld_pkl_path(chestnut=True) + return (path.is_file() or Path(get_manifest_path(path)).is_file()) and all( + (MODELS_DIR / f'big_driving_warp_{size}_tinygrad.pkl').is_file() for size in ('1344x760', '1928x1208')) diff --git a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx b/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx deleted file mode 100644 index 3646adc744..0000000000 --- a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6fee5937923c74848df4a63f6239eb6331c6274dd4bdb7a5d6ec0388a8b543d5 -size 766018462 diff --git a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl new file mode 100644 index 0000000000..9a99ee2633 --- /dev/null +++ b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:76cc0a9bc3af7a318889483dcbe126337f8d338f5abcbe664a8988c9b18b6639 +size 776634338 diff --git a/openpilot/selfdrive/test/chestnut.sh b/openpilot/selfdrive/test/chestnut.sh index fdb1967429..6fc645ee69 100755 --- a/openpilot/selfdrive/test/chestnut.sh +++ b/openpilot/selfdrive/test/chestnut.sh @@ -3,7 +3,4 @@ set -e sudo python3 openpilot/system/hardware/chestnut/flash.py -TARGET=openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl.chunkmanifest -rm -f "$TARGET" SCONSFLAGS="-j4" ./openpilot/system/manager/build.py -test -s "$TARGET" diff --git a/openpilot/selfdrive/test/setup_device_ci.sh b/openpilot/selfdrive/test/setup_device_ci.sh index a1dd88dcf4..e3ee1fc3db 100755 --- a/openpilot/selfdrive/test/setup_device_ci.sh +++ b/openpilot/selfdrive/test/setup_device_ci.sh @@ -64,9 +64,8 @@ pull_lfs() { return fi - # The big driving model is not used on these devices yet. Keep its pointer in - # the worktree, but don't download or copy the 1.8 GB LFS object. - LFS_EXCLUDE="openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx" + # Keep the precompiled big model as a pointer on devices without Chestnut. + LFS_EXCLUDE="openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl" git config --local lfs.fetchexclude "$LFS_EXCLUDE" git lfs pull --exclude="$LFS_EXCLUDE" diff --git a/tools/release/build_release.sh b/tools/release/build_release.sh index 6dcf99957f..42417d7ace 100755 --- a/tools/release/build_release.sh +++ b/tools/release/build_release.sh @@ -49,9 +49,6 @@ for policy in /sys/devices/system/cpu/cpufreq/policy*; do done scons -if [ -n "$INCLUDE_BIG_MODEL" ]; then - test -f openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl.chunkmanifest -fi if [ -z "$PANDA_DEBUG_BUILD" ]; then # release panda fw @@ -61,6 +58,11 @@ else scons panda/ fi +find openpilot/selfdrive/modeld/models -name '*.pkl' -size +95M -exec ./openpilot/common/file_chunker.py {} \; +if [ -n "$INCLUDE_BIG_MODEL" ]; then + test -f openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl.chunkmanifest +fi + # Ensure no submodules in release if test "$(git submodule--helper list | wc -l)" -gt "0"; then echo "submodules found:" diff --git a/tools/release/build_stripped.sh b/tools/release/build_stripped.sh index 239add2518..7b9fae4c98 100755 --- a/tools/release/build_stripped.sh +++ b/tools/release/build_stripped.sh @@ -39,7 +39,7 @@ cd "$SOURCE_DIR" cd "$TARGET_DIR" rm -rf .git/modules/ -find openpilot/selfdrive/modeld/models -name '*.onnx' -size +95M -exec ./openpilot/common/file_chunker.py {} \; +find openpilot/selfdrive/modeld/models -name '*.pkl' -size +95M -exec ./openpilot/common/file_chunker.py {} \; # include source commit hash and build date in commit GIT_HASH=$(git --git-dir="$SOURCE_DIR/.git" rev-parse HEAD) diff --git a/tools/release/release_files.py b/tools/release/release_files.py index dd42125337..dd963eaced 100755 --- a/tools/release/release_files.py +++ b/tools/release/release_files.py @@ -32,7 +32,7 @@ if __name__ == "__main__": continue rf = os.fsdecode(tracked_file) - if not os.getenv("INCLUDE_BIG_MODEL") and rf.startswith("openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx"): + if not os.getenv("INCLUDE_BIG_MODEL") and rf.startswith("openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl"): continue blacklisted = any(re.search(p, rf) for p in blacklist) whitelisted = any(re.search(p, rf) for p in whitelist)