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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* cabana ui: toolbar menu arrow spacing, only open the editor on editable cells

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* op: add cabana2 to run the imgui cabana

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* cabana: cabana2 launcher script builds _cabana_ui before running it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* cabana ui: only float the dock panels into their own OS windows

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* cabana ui: open the video pane at the camera's natural aspect ratio

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* cabana ui: make the signal rows a quarter taller so the sparklines read better

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* cabana ui: give the docked panels a minimum width, the dividers ran over content

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* replay: capture the downloader's stderr so the progress lines reach the handler

file_downloader.py writes PROGRESS:<cur>:<total> 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* cabana ui: fix spelling

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* cabana ui: darker grid lines between table cells

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* cabana ui: the gap between the video and the charts matches the side padding

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* cabana ui: plus-square icon for the new chart button, like the remove all button

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* cabana ui: --no-cache turns off the local route file cache, same as replay

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* cabana ui: Esc leaves full screen on all platforms

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* cabana ui: macapp moves into ui/util

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* cabana ui: setMacAppName lives in ui/util.cc

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <trey@Treys-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey Moen
2026-09-02 08:06:41 -07:00
committed by GitHub
parent 50fb67f718
commit 06d76bdb24
87 changed files with 12176 additions and 113 deletions
+1
View File
@@ -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
+55 -24
View File
@@ -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 <cstddef>\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 <cstddef>\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)
+22 -7
View File
@@ -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[@]}"
-1
View File
@@ -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;
+2 -2
View File
@@ -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;
+9 -2
View File
@@ -141,8 +141,15 @@ std::vector<std::string> 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<DBCFile *> DBCManager::nonEmptyDBCFiles() {
std::vector<DBCFile *> files;
for (auto f : allDBCFiles()) {
if (!f->isEmpty()) files.push_back(f);
}
return files;
}
DBCFile *DBCManager::findDBCFile(const uint8_t source) {
+1
View File
@@ -39,6 +39,7 @@ public:
std::vector<std::string> signalNames();
inline int dbcCount() { return allDBCFiles().size(); }
int nonEmptyDBCCount();
std::vector<DBCFile *> nonEmptyDBCFiles();
const SourceSet sources(const DBCFile *dbc_file) const;
DBCFile *findDBCFile(const uint8_t source);
-53
View File
@@ -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`
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -345,7 +345,7 @@ bool MessageListModel::filterAndSort() {
void MessageListModel::msgsReceived(const std::set<MessageId> *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;
}
+3 -2
View File
@@ -468,7 +468,6 @@ void writeSetting(json11::Json::object &settings_json, const char *key, const st
template <class Store, class SettingOperation>
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
+4
View File
@@ -1,6 +1,7 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include "tools/cabana/core/observable.h"
@@ -17,6 +18,9 @@ public:
std::vector<uint8_t> window_state;
std::vector<uint8_t> message_header_state;
// UI layout state (dock layout, window geometry, table state), owned by the imgui frontend
std::string ui_state;
Observable<> changed;
};
+3 -9
View File
@@ -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();
-1
View File
@@ -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;
@@ -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<const CanEvent *> &events) {
msg_events[{.source = e->src, .address = e->address}].push_back(e);
}
insertEvents(events, msg_events);
}
void AbstractStream::insertEvents(const std::vector<const CanEvent *> &events, const MessageEventsMap &msg_events) {
if (!events.empty()) {
for (const auto &[id, new_e] : msg_events) {
if (!new_e.empty()) {
@@ -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<const CanEvent *> &events);
void insertEvents(const std::vector<const CanEvent *> &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();
+1 -3
View File
@@ -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<capnp::word> 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);
@@ -37,7 +37,6 @@ private:
std::mutex lock;
std::thread stream_thread, update_thread;
std::atomic<bool> update_pending_ = false;
std::atomic<int> fps_ = 10;
std::vector<const CanEvent *> received_events_;
std::chrono::system_clock::time_point begin_date_time;
@@ -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<const CanEvent *> 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<cereal::Event>();
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<LogReader> 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;
}
@@ -1,4 +1,5 @@
#include <cmath>
#include <cstdlib>
#include <ctime>
#include <filesystem>
@@ -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<uint8_t> fromHex(const std::string &hex) {
std::vector<uint8_t> 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() {
@@ -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
+212
View File
@@ -0,0 +1,212 @@
#include "tools/cabana/ui/app.h"
#include <atomic>
#include <cstdio>
#include <stdexcept>
#include <utility>
#include "imgui.h"
#include "imgui_impl_glfw.h"
#include "imgui_impl_opengl3.h"
#include "imgui_impl_opengl3_loader.h"
#include "implot.h"
#include <GLFW/glfw3.h>
#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<bool> g_signal_exit{false};
std::vector<KeyEvent> 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<KeyEvent> takeKeyEvents() {
return std::exchange(g_key_events, {});
}
int run(std::unique_ptr<AbstractStream> 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;
}
}
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <functional>
#include <memory>
#include <string>
#include <vector>
#include "tools/cabana/streams/abstractstream.h"
// builds a stream off the main thread; nullptr means the load failed
using StreamLoader = std::function<std::unique_ptr<AbstractStream>()>;
// 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<AbstractStream> 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<KeyEvent> takeKeyEvents();
+790
View File
@@ -0,0 +1,790 @@
#define IMGUI_DEFINE_MATH_OPERATORS // ImVec2 arithmetic, must precede imgui.h
#include "tools/cabana/ui/chart/chart.h"
#include <algorithm>
#include <cfloat>
#include <cmath>
#include <cstdio>
#include <limits>
#include <random>
#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<double, double> &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<bool(const SigItem &s)> 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<SignalSelector>("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<int>(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<int>(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<int>(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<int>(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<const CanEvent *> &events,
std::vector<ImPlotPoint> &vals, std::vector<ImPlotPoint> &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<ImPlotPoint> 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::PointIter, ChartView::PointIter> ChartView::visibleRange(const std::vector<ImPlotPoint> &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<double>::max();
double max = std::numeric_limits<double>::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<double>::max();
s.max = std::numeric_limits<double>::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<double>::max()) min = 0;
if (max == std::numeric_limits<double>::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<double, double, int> 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::SigItem> ChartView::takeExtraSignals() {
std::vector<SigItem> 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<TipLine> 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<double>::max() ? "--" : utils::toString(s.min);
std::string max = s.max == std::numeric_limits<double>::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<int>(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<int> sat(35, 99);
std::uniform_int_distribution<int> 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;
}
+142
View File
@@ -0,0 +1,142 @@
#pragma once
#include <functional>
#include <string>
#include <tuple>
#include <utility>
#include <vector>
#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<ImPlotPoint> vals;
std::vector<ImPlotPoint> step_vals;
ImPlotPoint track_pt{};
SegmentTree segment_tree;
double min = 0;
double max = 0;
};
ChartView(const std::pair<double, double> &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<bool(const SigItem &)> predicate);
void takeSignalsFrom(ChartView *source);
// every signal but the first, with its original color, for a split into one chart per signal
std::vector<SigItem> takeExtraSignals();
void adoptSignal(SigItem s);
void setDropHighlight(bool highlight) { can_drop_ = highlight; }
const std::vector<SigItem> &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<ImPlotPoint>::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<const CanEvent *> &events,
std::vector<ImPlotPoint> &vals, std::vector<ImPlotPoint> &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<double, double, int> 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<PointIter, PointIter> visibleRange(const std::vector<ImPlotPoint> &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<ImRect> 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<SigItem> sigs_;
double cur_sec_ = 0;
SeriesType series_type_ = SeriesType::Line;
bool can_drop_ = false;
double tooltip_x_ = -1;
ChartsWidget *charts_widget_;
Connections connections_;
};
@@ -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 <algorithm>
#include <cmath>
#include <cstdio>
#include <future>
#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<MessageId> *, 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"(
<b>Chart View</b><br />
<b>Click</b>: Click to seek to a corresponding time.<br />
<b>Drag</b>: Zoom into the chart.<br />
<b>Shift + Drag</b>: Scrub through the chart to view values.<br />
<b>Right Mouse</b>: Open the context menu.<br />
)";
}
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<ChartView *>(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<std::future<void>> 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<float>(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<ToolbarItem> 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<ChartView>(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<std::string> ChartsWidget::serializeChartIds() const {
std::vector<std::string> 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<std::string> &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<int>(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<SignalSelector>("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<SignalSelector> dlg, ChartView *owner, std::function<void(SignalSelector &)> 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<ChartView *> 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;
}
@@ -0,0 +1,168 @@
#pragma once
#include <functional>
#include <memory>
#include <optional>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
#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<std::string> serializeChartIds() const;
void restoreChartsFromIds(const std::vector<std::string> &chart_ids);
std::string whatsThis() const;
void setColumnCount(int n);
void removeAll();
void setIsDocked(bool dock);
Observable<> toggleChartsDocking;
Observable<> seriesChanged;
Observable<double> 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<ChartView *> &currentCharts() { 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<SignalSelector> dlg, ChartView *owner, std::function<void(SignalSelector &)> 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<std::unique_ptr<ChartView>> charts_;
std::unordered_map<int, std::vector<ChartView *>> 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<double, double> 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<std::unique_ptr<ChartView>> deleted_charts_; // freed at the start of the next draw()
std::unique_ptr<SignalSelector> signal_selector_;
ChartView *signal_selector_owner_ = nullptr;
std::function<void(SignalSelector &)> signal_selector_accepted_;
Connections connections_;
friend class ChartView;
friend class ChartsContainer;
};
class ZoomCommand : public UndoCommand {
public:
ZoomCommand(std::pair<double, double> range) : range(range) {
prev_range = can->timeRange();
}
void undo() override { can->setTimeRange(prev_range); }
void redo() override { can->setTimeRange(range); }
std::optional<std::pair<double, double>> prev_range, range;
};
@@ -0,0 +1,155 @@
#include "tools/cabana/ui/chart/signalselector.h"
#include <algorithm>
#include <cfloat>
#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<ListItem> &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);
}
}
}
@@ -0,0 +1,46 @@
#pragma once
#include <string>
#include <vector>
#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<ListItem> &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<ListItem> &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<ComboItem> msgs_combo_;
int msgs_combo_index_ = -1;
std::string msgs_combo_filter_;
std::vector<ListItem> available_list_;
std::vector<ListItem> selected_list_;
int available_row_ = -1;
int selected_row_ = -1;
bool accepted_ = false;
bool open_ = false;
bool show_ = false;
};
@@ -0,0 +1,177 @@
#include "tools/cabana/ui/chart/sparkline.h"
#include <algorithm>
#include <cmath>
#include <limits>
#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<double>::max();
max_val = std::numeric_limits<double>::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<ImVec2> 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();
}
@@ -0,0 +1,34 @@
#pragma once
#include <vector>
#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<Point> points_;
std::vector<ImVec2> 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;
};
@@ -0,0 +1,69 @@
#define IMGUI_DEFINE_MATH_OPERATORS // ImVec2 arithmetic, must precede imgui.h
#include "tools/cabana/ui/chart/tiplabel.h"
#include <algorithm>
#include <cfloat>
#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<TipLine> &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);
}
@@ -0,0 +1,35 @@
#pragma once
#include <string>
#include <vector>
#include "imgui.h"
#include "imgui_internal.h"
// one line of the tip: [square] name <b>value</b> (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<TipLine> &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<TipLine> text_;
ImVec2 pos_;
ImVec2 size_;
bool visible_ = false;
};
@@ -0,0 +1,222 @@
#include "tools/cabana/ui/dialogs/filedialog.h"
#include <algorithm>
#include <cctype>
#include <system_error>
#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<unsigned char>(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<unsigned char>(a[i])) && isdigit(static_cast<unsigned char>(b[j]))) {
size_t ie = i, je = j;
while (ie < a.size() && isdigit(static_cast<unsigned char>(a[ie]))) ++ie;
while (je < b.size() && isdigit(static_cast<unsigned char>(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<unsigned char>(a[i])), cb = tolower(static_cast<unsigned char>(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<fs::directory_entry> 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<int>(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
@@ -0,0 +1,19 @@
#pragma once
#include <filesystem>
#include <functional>
#include <string>
#include <vector>
// file browser. One dialog at a time; the callback gets an empty path on cancel.
namespace FileDialog {
using Callback = std::function<void(const std::string &path)>;
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
@@ -0,0 +1,87 @@
#include "tools/cabana/ui/dialogs/messagebox.h"
#include <deque>
#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<void(bool)> on_result;
};
std::deque<Box> g_queue;
bool g_show_details = false;
PopupOwner g_owner;
void push(Box box) { g_queue.push_back(std::move(box)); }
std::function<void(bool)> wrap(std::function<void()> 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<void()> 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<void()> 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<void(bool)> 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
@@ -0,0 +1,19 @@
#pragma once
#include <functional>
#include <string>
// 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<void()> on_close = nullptr);
void warning(const std::string &title, const std::string &text, const std::string &detailed_text = "",
std::function<void()> on_close = nullptr);
// Ok | Cancel; on_result(true) for Ok
void question(const std::string &title, const std::string &text, std::function<void(bool ok)> on_result);
void draw(); // once per frame, at the top popup level
} // namespace MessageBox
@@ -0,0 +1,124 @@
#include "tools/cabana/ui/dialogs/routesdialog.h"
#include <utility>
#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<void(bool, const std::string &)> on_done) {
on_done_ = std::move(on_done);
open_ = true;
popup_.reset();
s_ = State{};
alive_ = std::make_shared<bool>(true);
routes::fetchDevices([this, alive = std::weak_ptr<bool>(alive_)](std::vector<routes::DeviceInfo> 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<routes::DeviceInfo> &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<bool>(alive_), request_id](std::vector<routes::RouteInfo> 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<routes::RouteInfo> &list, bool success) {
if (success) {
for (const auto &route : list) {
const int mins = static_cast<int>((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<int>(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);
}
@@ -0,0 +1,45 @@
#pragma once
#include <functional>
#include <memory>
#include <string>
#include <vector>
#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<void(bool accepted, const std::string &route)> on_done);
void draw();
private:
void setDeviceList(const std::vector<routes::DeviceInfo> &devices, bool success, int error_code);
void setRouteList(const std::vector<routes::RouteInfo> &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<std::string> devices;
int device_index = 0;
int period_index = 0;
std::vector<RouteItem> 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<void(bool, const std::string &)> on_done_;
// created by open() and reset by finish(); guards main-thread callbacks from detached worker threads
std::shared_ptr<bool> alive_;
};
@@ -0,0 +1,108 @@
#include "tools/cabana/ui/dialogs/settingsdialog.h"
#include <algorithm>
#include <utility>
#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();
}
@@ -0,0 +1,24 @@
#pragma once
#include <string>
#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_;
};
@@ -0,0 +1,322 @@
#include "tools/cabana/ui/dialogs/streamselector.h"
#include <algorithm>
#include <filesystem>
#include <fstream>
#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<AbstractStream> 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<ReplayStream>();
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<PandaStream *>(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<int>(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<int>(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<int>(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<int>(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<AbstractStream> OpenPandaWidget::open() {
try {
return std::make_unique<PandaStream>(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<AbstractStream> OpenDeviceWidget::open() {
std::string ip = ip_address_.empty() ? "127.0.0.1" : ip_address_;
bool msgq = mode_ == 0;
return std::make_unique<DeviceStream>(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<AbstractStream> OpenSocketCanWidget::open() {
try {
return std::make_unique<SocketCanStream>(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<OpenReplayWidget>());
widgets_.push_back(std::make_unique<OpenPandaWidget>());
#ifdef __linux__
if (SocketCanStream::available()) {
widgets_.push_back(std::make_unique<OpenSocketCanWidget>());
}
#endif
widgets_.push_back(std::make_unique<OpenDeviceWidget>());
}
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<AbstractStream> 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_);
}
}
@@ -0,0 +1,106 @@
#pragma once
#include <functional>
#include <memory>
#include <string>
#include <vector>
#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<AbstractStream> 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<AbstractStream> 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<bool> alive_ = std::make_shared<bool>(true);
};
class OpenPandaWidget : public AbstractOpenStreamWidget {
public:
OpenPandaWidget();
const char *title() const override { return "Panda"; }
void draw() override;
std::unique_ptr<AbstractStream> open() override;
bool openEnabled() const override { return !already_connected_; }
private:
void refreshSerials();
void buildConfigForm();
bool already_connected_ = false;
std::vector<std::string> serials_;
int serial_index_ = 0;
bool has_panda_ = false;
bool has_fd_ = false;
std::vector<int> 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<AbstractStream> 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<AbstractStream> open() override;
private:
void refreshDevices();
std::vector<std::string> devices_;
int device_index_ = 0;
SocketCanStreamConfig config = {};
};
#endif
class StreamSelector {
public:
using Callback = std::function<void(std::unique_ptr<AbstractStream> 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<std::unique_ptr<AbstractOpenStreamWidget>> widgets_;
Callback on_done_;
};
+196
View File
@@ -0,0 +1,196 @@
#include "tools/cabana/ui/helpoverlay.h"
#include <algorithm>
#include <cctype>
#include <cfloat>
#include <cstdio>
#include <cstring>
#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<std::vector<HelpRun>> parseHelpHtml(const std::string &raw) {
std::vector<std::vector<HelpRun>> lines(1);
HelpRun style;
std::vector<HelpRun> 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<const char *, const char *> entities[] = {{"&nbsp;", " "}};
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, "&#9632;") == 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<unsigned char>(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;
}
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <string>
#include <utility>
#include <vector>
#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: <b>, <br />, <span style="color:..;background-color:..">, &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<std::pair<std::string, ImRect>> texts_;
bool visible_ = false;
int opened_frame_ = -1;
};
+40
View File
@@ -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
+138
View File
@@ -0,0 +1,138 @@
#include "tools/cabana/ui/inistate.h"
#include <algorithm>
#include <cstdio>
#include <cstring>
#include "imgui.h"
#include "imgui_internal.h"
#include <GLFW/glfw3.h>
#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
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <string>
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
+202
View File
@@ -0,0 +1,202 @@
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <memory>
#include <optional>
#include <string>
#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 <malloc.h>
#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 <serial> read can messages from panda with given serial\n"
#ifdef __linux__
" --socketcan <device> read can messages from given SocketCAN device\n"
#endif
" --zmq <ip-address> read can messages from zmq at the specified ip-address\n"
" --data_dir <dir> local directory with routes\n"
" --no-vipc do not output video\n"
" --no-cache turn off the local route file cache\n"
" --dbc <file> 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<int> 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<AbstractStream> stream;
StreamLoader stream_loader;
if (args.msgq) {
stream = std::make_unique<DeviceStream>();
} else if (!args.zmq.empty()) {
stream = std::make_unique<DeviceStream>(args.zmq);
} else if (args.panda) {
try {
stream = std::make_unique<PandaStream>(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<SocketCanStream>(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<AbstractStream> {
auto replay_stream = std::make_unique<ReplayStream>();
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);
}
+929
View File
@@ -0,0 +1,929 @@
#include "tools/cabana/ui/mainwin.h"
#include <algorithm>
#include <cassert>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <string>
#include <vector>
#include "imgui.h"
#include "imgui_internal.h"
#include <GLFW/glfw3.h>
#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<AbstractStream> 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<char>(json_file), std::istreambuf_iterator<char>()};
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<MessagesWidget>();
widget_connections_.push_back(messages_widget_->msgSelectionChanged.connect([this](const MessageId &id) { center_widget_.setMessage(id); }));
charts_widget_ = std::make_unique<ChartsWidget>();
center_widget_.setChartsWidget(charts_widget_.get());
video_widget_ = std::make_unique<VideoWidget>();
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<AbstractStream> stream, const std::string &dbc_file) {
if (stream) {
openStream(std::move(stream), dbc_file);
} else if (!stream_) {
openStream(std::make_unique<DummyStream>());
}
});
}
// 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<AbstractStream> 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<DummyStream>());
});
});
}
void MainWindow::closeStream() {
openStream(std::make_unique<DummyStream>());
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<void()> 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<AbstractStream> stream, const std::string &dbc_file) {
releaseStream();
startStream(std::move(stream), dbc_file);
}
void MainWindow::startStream(std::unique_ptr<AbstractStream> 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<void()> then) {
const std::vector<DBCFile *> files = dbc()->nonEmptyDBCFiles();
auto next = std::make_shared<std::function<void(size_t)>>();
*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<void()> then) {
saveFiles(false, std::move(then));
}
void MainWindow::saveAs(std::function<void()> then) {
saveFiles(true, std::move(then));
}
void MainWindow::closeFile(SourceSet s, std::function<void()> 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<void()> 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<void()> 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<int>(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<void()> 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<FindSimilarBitsDlg>();
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<FindSignalDlg>();
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, &center);
ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.4f, &right, &center);
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);
}
}
+138
View File
@@ -0,0 +1,138 @@
#pragma once
#include <cstdint>
#include <functional>
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
#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<AbstractStream> 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<void()> then = {});
void selectAndOpenStream();
void openStream(std::unique_ptr<AbstractStream> 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<void()> then = {});
void saveAs(std::function<void()> then = {});
void saveToClipboard();
private:
bool hasStream() const { return dynamic_cast<const DummyStream *>(can) == nullptr; }
void releaseStream();
void startStream(std::unique_ptr<AbstractStream> stream, const std::string &dbc_file);
void loadStartupStream(const std::string &dbc_file);
void remindSaveChanges(std::function<void()> then);
void closeFile(SourceSet s, std::function<void()> then);
void closeFile(DBCFile *dbc_file);
void saveFiles(bool as, std::function<void()> then);
void saveFile(DBCFile *dbc_file, std::function<void()> then = {});
void saveFileAs(DBCFile *dbc_file, std::function<void()> 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<void()> 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<AbstractStream> startup_stream_; // opened on the first frame
StreamLoader startup_loader_; // run on a worker after the first frame
std::unique_ptr<AbstractStream> stream_; // `can` points here, or at dummy_ when no stream is open
DummyStream dummy_;
std::unique_ptr<MessagesWidget> messages_widget_;
CenterWidget center_widget_;
std::unique_ptr<VideoWidget> video_widget_;
std::unique_ptr<ChartsWidget> charts_widget_;
StreamSelector stream_selector_;
SettingsDialog settings_dialog_;
HelpOverlay help_overlay_;
std::unordered_map<std::string, std::string> fingerprint_to_dbc_;
std::vector<std::string> 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<std::unique_ptr<ToolDialog>> 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<std::function<void()>> next_frame_;
Connections connections_;
Connections stream_connections_;
Connections widget_connections_;
};
+171
View File
@@ -0,0 +1,171 @@
#include "tools/cabana/ui/qtstate.h"
#include <algorithm>
#include <map>
namespace qtstate {
namespace {
// big-endian QDataStream reader; any read past the end latches the failed state
class Cursor {
public:
explicit Cursor(const std::vector<uint8_t> &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<uint8_t> &data_;
size_t pos_ = 0;
bool ok_ = true;
};
} // namespace
std::optional<QtGeometry> parseQtGeometry(const std::vector<uint8_t> &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<QtSplitter> parseQtSplitter(const std::vector<uint8_t> &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<QtHeaderState> parseQtHeaderState(const std::vector<uint8_t> &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<int> 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<uint8_t> 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<int, int> 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<int> 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
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <cstdint>
#include <optional>
#include <vector>
// 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<QtGeometry> parseQtGeometry(const std::vector<uint8_t> &data);
std::optional<QtSplitter> parseQtSplitter(const std::vector<uint8_t> &data);
std::optional<QtHeaderState> parseQtHeaderState(const std::vector<uint8_t> &data);
} // namespace qtstate
+280
View File
@@ -0,0 +1,280 @@
#include "tools/cabana/ui/app.h"
#include <algorithm>
#include <cmath>
#include <filesystem>
#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(); }
+81
View File
@@ -0,0 +1,81 @@
#pragma once
#include <algorithm>
#include <condition_variable>
#include <functional>
#include <future>
#include <memory>
#include <mutex>
#include <queue>
#include <thread>
#include <vector>
// 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<void> run(std::function<void()> fn) {
auto task = std::make_shared<std::packaged_task<void()>>(std::move(fn));
std::future<void> 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<void()> 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<std::thread> threads_;
std::queue<std::function<void()>> 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<void(size_t begin, size_t end)> &fn) {
const size_t chunks = std::clamp<size_t>(std::thread::hardware_concurrency(), 2, 4) + 1;
const size_t chunk = (n + chunks - 1) / chunks;
if (chunk == 0) return;
std::vector<std::future<void>> 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();
}
@@ -0,0 +1,325 @@
#include "tools/cabana/ui/tools/findsignal.h"
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <mutex>
#include <set>
#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<bool(double)> &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<uint64_t>::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<int>(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<bool(double)> 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<unsigned short> 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<uint32_t> 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<uint64_t>::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();
}
}
@@ -0,0 +1,59 @@
#pragma once
#include <algorithm>
#include <functional>
#include <future>
#include <limits>
#include <string>
#include <vector>
#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<std::string> values;
};
void search(const std::function<bool(double)> &cmp);
void reset();
void undo();
std::vector<SearchSignal> filtered_signals;
std::vector<SearchSignal> initial_signals;
std::vector<std::vector<SearchSignal>> histories;
uint64_t last_time = std::numeric_limits<uint64_t>::max();
};
class FindSignalDlg : public ToolDialog {
public:
FindSignalDlg();
~FindSignalDlg() override;
bool draw() override;
Observable<const MessageId &> 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<void> search_future_; // search_ is off limits while it is valid
bool searching_ = false;
};
@@ -0,0 +1,178 @@
#include "tools/cabana/ui/tools/findsimilarbits.h"
#include <algorithm>
#include <unordered_map>
#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::Mismatch> 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<uint32_t, std::vector<uint32_t>> mismatches;
std::unordered_map<uint32_t, uint32_t> 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<Mismatch> 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;
}
@@ -0,0 +1,40 @@
#pragma once
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
#include "tools/cabana/dbc/dbcmanager.h"
#include "tools/cabana/ui/tools/tooldialog.h"
class FindSimilarBitsDlg : public ToolDialog {
public:
FindSimilarBitsDlg();
bool draw() override;
Observable<const MessageId &> openMessage;
private:
struct Mismatch {
uint32_t address, byte_idx, bit_idx, mismatches, total;
float perc;
};
std::vector<Mismatch> 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<Mismatch> table_; // rows, replaced by find()
bool table_has_columns_ = false;
std::vector<int> bus_items_; // the source and the find bus combos
int src_bus_ = 0, find_bus_ = 0;
std::vector<std::pair<std::string, uint32_t>> msg_items_; // (name, address) of the source bus
std::vector<std::string> msg_names_;
int msg_index_ = 0;
int equal_ = 0;
int byte_idx_ = 0, bit_idx_ = 0;
int min_msgs_ = 100;
};
@@ -0,0 +1,50 @@
#include "tools/cabana/ui/tools/routeinfo.h"
#include <algorithm>
#include <string>
#include "imgui.h"
#include "tools/cabana/streams/replaystream.h"
#include "tools/cabana/ui/util.h"
RouteInfoDlg::RouteInfoDlg() {
replay_ = dynamic_cast<ReplayStream *>(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();
}
@@ -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
};
@@ -0,0 +1,52 @@
#pragma once
#include <cstdio>
#include <string>
#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;
};
+466
View File
@@ -0,0 +1,466 @@
#include "tools/cabana/ui/util.h"
#include <algorithm>
#include <cctype>
#include <cfloat>
#include <string>
#include <vector>
#include "imgui.h"
#include "imgui_internal.h"
#ifdef __APPLE__
#include <CoreFoundation/CoreFoundation.h>
#include <GLFW/glfw3.h>
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<InputContext *>(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<std::string> &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<ToolbarItem> &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<ToolbarItem> &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<ToolbarItem> &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;
}
+201
View File
@@ -0,0 +1,201 @@
#pragma once
#include <functional>
#include <string>
#include <vector>
#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<std::string> &items);
// numeric items (bus ids, bus speeds) are formatted as they are drawn
template <typename T>
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<void()> draw;
std::string menu_label;
std::function<void()> 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<ToolbarItem> &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<ToolbarItem> &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();
@@ -0,0 +1,548 @@
#include "tools/cabana/ui/widgets/binaryview.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <iterator>
#include <utility>
#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<int>((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"(
<b>Binary View</b><br/>
<span style="color:gray">Shortcuts</span><br />
Delete Signal:
<span style="background-color:lightGray;color:gray">&nbsp;x&nbsp;</span>,
<span style="background-color:lightGray;color:gray">&nbsp;Backspace&nbsp;</span>,
<span style="background-color:lightGray;color:gray">&nbsp;Delete&nbsp;</span><br />
Change endianness: <span style="background-color:lightGray;color:gray">&nbsp;e&nbsp; </span><br />
Change signedness: <span style="background-color:lightGray;color:gray">&nbsp;s&nbsp;</span><br />
Open chart:
<span style="background-color:lightGray;color:gray">&nbsp;c&nbsp;</span>,
<span style="background-color:lightGray;color:gray">&nbsp;p&nbsp;</span>,
<span style="background-color:lightGray;color:gray">&nbsp;g&nbsp;</span>
)";
}
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<float>(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<BinaryIndex> 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<const cabana::Signal *> BinaryView::getOverlappingSignals() const {
std::set<const cabana::Signal *> 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<int, int, bool> 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<int>((pos.x - grid_pos_.x - VERTICAL_HEADER_WIDTH) / column_width_);
int row = static_cast<int>((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<float>(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<uint8_t>(alpha);
setCell(i, j, bit_val, color);
}
setCell(i, HEX_COLUMN, binary[i], last_msg.colors[i]);
}
}
const std::vector<std::array<uint32_t, 8>> &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<uint32_t, 8>{});
auto [first, last] = can->eventsInRange(msg_id_, time_range);
if (std::distance(first, last) <= 1) return bit_flip_tracker_.flip_counts;
std::vector<uint8_t> 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<int>(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<ImRect> 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<ImRect> 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();
}
}
@@ -0,0 +1,98 @@
#pragma once
#include <array>
#include <optional>
#include <set>
#include <string>
#include <tuple>
#include <vector>
#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<const cabana::Signal *> 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<const cabana::Signal *> signalClicked;
Observable<const cabana::Signal *> signalHovered;
Observable<const cabana::Signal *, cabana::Signal &> editSignal;
Observable<const MessageId &, const cabana::Signal *, bool, bool> 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<const cabana::Signal *> 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<std::array<uint32_t, 8>> &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<int, int, bool> 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<Cell> cells_;
int row_count_ = 0;
bool heatmap_live_mode_ = true;
struct BitFlipTracker {
std::optional<std::pair<double, double>> time_range;
std::vector<std::array<uint32_t, 8>> 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<BinaryIndex> 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_;
};
@@ -0,0 +1,176 @@
#include "tools/cabana/ui/widgets/cameraview.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <GLFW/glfw3.h>
#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<VisionIpcClient> 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;
}
@@ -0,0 +1,88 @@
#pragma once
#include <atomic>
#include <cstdint>
#include <memory>
#include <mutex>
#include <set>
#include <string>
#include <thread>
#include <utility>
#include <vector>
#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<uint8_t> 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<std::set<VisionStreamType>> 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<VisionStreamType> active_stream_type_;
std::atomic<VisionStreamType> requested_stream_type_;
std::thread vipc_thread_;
std::atomic<bool> vipc_exit_ = false;
std::mutex frame_lock_;
std::shared_ptr<bool> alive_ = std::make_shared<bool>(true);
};
@@ -0,0 +1,439 @@
#include "tools/cabana/ui/widgets/detailwidget.h"
#include <algorithm>
#include <cctype>
#include <cfloat>
#include <cstdio>
#include <utility>
#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<BinaryView>();
signal_view_ = std::make_unique<SignalView>(charts);
history_log_ = std::make_unique<LogsWidget>();
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<MessageId> *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<std::pair<double, double>> &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<std::string, std::vector<std::string>> DetailWidget::serializeMessageIds() const {
std::vector<std::string> 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<std::string>& 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<std::string> 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<MessageId> *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<EditMessageDialog>(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<std::pair<std::string, ImRect>> DetailWidget::helpRects() const {
std::vector<std::pair<std::string, ImRect>> 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<DetailWidget>(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();
}
@@ -0,0 +1,112 @@
#pragma once
#include <memory>
#include <set>
#include <string>
#include <utility>
#include <vector>
#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<std::string, std::vector<std::string>> serializeMessageIds() const;
void restoreTabs(const std::string &active_msg_id, const std::vector<std::string> &msg_ids);
std::vector<std::pair<std::string, ImRect>> 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<MessageId> *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<LogsWidget> history_log_;
std::unique_ptr<BinaryView> binary_view_;
std::unique_ptr<SignalView> signal_view_;
ChartsWidget *charts_;
std::unique_ptr<EditMessageDialog> 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<DetailWidget> detail_widget;
ChartsWidget *charts_ = nullptr;
};
@@ -0,0 +1,307 @@
#include "tools/cabana/ui/widgets/historylog.h"
#include <algorithm>
#include <cstdio>
#include <iterator>
#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<bool(double, double)> 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<Message>::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<Message> msgs;
std::vector<double> 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<uint8_t> 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<bool(double, double)> cmp = nullptr;
switch (comp_box_) {
case 0: cmp = std::greater<double>{}; break;
case 1: cmp = std::equal_to<double>{}; break;
case 2: cmp = [](double l, double r) { return l != r; }; break;
case 3: cmp = std::less<double>{}; 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<ImVec2> 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);
}
@@ -0,0 +1,59 @@
#pragma once
#include <deque>
#include <functional>
#include <string>
#include <vector>
#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<double> sig_values;
std::vector<uint8_t> data;
std::vector<CabanaColor> 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<bool(double, double)> cmp);
void load(bool clear);
bool canFetchMore() const;
void fetch(std::deque<Message>::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<cabana::Signal *> sigs_;
std::deque<Message> messages_;
CanData hex_colors_;
bool hex_mode_ = false;
int filter_sig_idx_ = -1;
double filter_value_ = 0;
std::function<bool(double, double)> 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_;
};
@@ -0,0 +1,60 @@
#include "tools/cabana/ui/widgets/messagebytes.h"
#include <algorithm>
#include <cmath>
#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<uint8_t> &bytes, const std::vector<CabanaColor> *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();
}
@@ -0,0 +1,20 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#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<uint8_t> &bytes, const std::vector<CabanaColor> *colors,
bool selected, bool inactive, bool multiple_lines);
@@ -0,0 +1,512 @@
#include "tools/cabana/ui/widgets/messageswidget.h"
#include <algorithm>
#include <cctype>
#include <cfloat>
#include <charconv>
#include <cmath>
#include <cstdio>
#include <limits>
#include <numeric>
#include <utility>
#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<unsigned int>::min();
unsigned int max = std::numeric_limits<unsigned int>::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<MessageId> *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<int, std::string> &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<Item> &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<MessageId> 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<Item> 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<MessageId> *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"(
<b>Message View</b><br/>
<span style="color:gray">Byte color</span><br />
<span style="color:gray;">&#9632; </span> constant changing<br />
<span style="color:blue;">&#9632; </span> increasing<br />
<span style="color:red;">&#9632; </span> decreasing<br />
<span style="color:gray">Shortcuts</span><br />
Horizontal Scrolling: <span style="background-color:lightGray;color:gray">&nbsp;shift+wheel&nbsp;</span>
)";
}
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<size_t, size_t>(),
[](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<int>(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<int, std::string> 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();
}
@@ -0,0 +1,93 @@
#pragma once
#include <array>
#include <cstdint>
#include <map>
#include <optional>
#include <set>
#include <string>
#include <utility>
#include <vector>
#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<int, std::string> &filters);
void showInactiveMessages(bool show);
bool filterAndSort();
std::vector<Item> items;
bool show_inactive_messages = true;
Observable<> changed;
private:
void msgsReceived(const std::set<MessageId> *new_msgs, bool has_new_ids);
void dbcModified();
void sortItems(std::vector<Item> &list);
bool match(const Item &item);
std::map<int, std::string> filters_;
std::set<MessageId> 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<const MessageId &> 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<MessageId> 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<std::string, MessageList::COLUMN_COUNT> filters_;
std::array<bool, MessageList::COLUMN_COUNT> hidden_ = {}; // mirror of the table's enabled columns, refreshed every frame
std::array<int, MessageList::COLUMN_COUNT> display_order_; // visual index -> logical column
std::vector<std::pair<int, bool>> 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_;
};
@@ -0,0 +1,88 @@
#include "tools/cabana/ui/widgets/scrollabletabbar.h"
#include <algorithm>
#include <cmath>
#include <vector>
#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<ScrollableTabBar> 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;
}
}
}
@@ -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();
@@ -0,0 +1,975 @@
#include "tools/cabana/ui/widgets/signalview.h"
#include <algorithm>
#include <cfloat>
#include <cmath>
#include <cstdio>
#include <future>
#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<float>(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<std::pair<std::string, int>> 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<const char *> 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", &current, 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<ValueDescriptionDlg>(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<MessageId> *msgs, bool) { updateState(msgs); }));
}
std::string SignalView::whatsThis() const {
return R"(
<b>Signal view</b><br />
)";
}
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<int>(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<MessageId> *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<float>(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<std::future<void>> 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;
}
@@ -0,0 +1,201 @@
#pragma once
#include <algorithm>
#include <functional>
#include <memory>
#include <set>
#include <string>
#include <utility>
#include <vector>
#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<Item *> 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<Item> 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<std::pair<std::string, std::string>> 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<const cabana::Signal *> highlight;
Observable<const MessageId &, const cabana::Signal *, bool, bool> 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<MessageId> *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<void()> 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<void()> 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<ValueDescriptionDlg> desc_dlg_;
const cabana::Signal *desc_sig_ = nullptr;
SignalModel model_;
Connections connections_;
};
@@ -0,0 +1,92 @@
#include "tools/cabana/ui/widgets/tabbar.h"
#include <algorithm>
#include <utility>
#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);
}
@@ -0,0 +1,45 @@
#pragma once
#include <string>
#include <vector>
#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<int> currentChanged;
Observable<int> tabCloseRequested;
Observable<int> 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<Tab> 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;
};
@@ -0,0 +1,612 @@
#include "tools/cabana/ui/widgets/videowidget.h"
#include <algorithm>
#include <cfloat>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <functional>
#include <iterator>
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavutil/pixfmt.h>
}
#include <capnp/serialize.h>
#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<ReplayStream *>(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<double, double> 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<MessageId> *, bool) { msgs_received_ = true; }));
}
std::string VideoWidget::whatsThis() const {
// one <br /> separated line per legend row, with the same entries and colors
return "<b>Video</b><br />\n"
"<span style=\"color:gray\">Timeline color</span><br />\n" +
colorName(timeline_colors[(int)TimelineType::None]) + " Disengaged&nbsp;&nbsp;&nbsp;" +
colorName(timeline_colors[(int)TimelineType::Engaged]) + " Engaged<br />\n" +
colorName(timeline_colors[(int)TimelineType::UserBookmark]) + " User Flag&nbsp;&nbsp;&nbsp;" +
colorName(timeline_colors[(int)TimelineType::AlertInfo]) + " Info<br />\n" +
colorName(timeline_colors[(int)TimelineType::AlertWarning]) + " Warning&nbsp;&nbsp;&nbsp;" +
colorName(timeline_colors[(int)TimelineType::AlertCritical]) + " Critical<br />\n"
"<span style=\"color:gray\">Shortcuts</span><br />\n"
"Pause/Resume: <span style=\"background-color:lightGray;color:gray\">&nbsp;space&nbsp;</span>";
}
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<ToolbarItem> 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<TabBar>();
camera_tab_->setAutoHide(true);
cam_widget_ = std::make_unique<StreamCameraView>("camerad", VISION_STREAM_NARROW_ROAD);
slider_ = std::make_unique<Slider>();
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<VisionStreamType> 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<ReplayStream *>(can)->qLogLoaded.connect([this](std::shared_ptr<LogReader> 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<VisionStreamType> 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<double>(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<RouteInfoDlg>());
}
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<LogReader> qlog) {
auto thumbnails = std::make_shared<std::map<uint64_t, RgbImage>>();
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<cereal::Event>().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<int>(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;
}
}
@@ -0,0 +1,121 @@
#pragma once
#include <algorithm>
#include <future>
#include <map>
#include <memory>
#include <set>
#include <string>
#include <utility>
#include <vector>
#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<LogReader> qlog); // decodes the thumbnails on the thread pool
private:
struct PendingThumbnails {
std::future<void> done;
std::shared_ptr<std::map<uint64_t, RgbImage>> 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<uint64_t, RgbImage> big_thumbnails_;
GlTexture big_thumbnail_texture_; // the currently shown thumbnail
std::vector<PendingThumbnails> 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<VisionStreamType> streams);
void showRouteInfo();
std::unique_ptr<StreamCameraView> 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> slider_;
std::unique_ptr<TabBar> camera_tab_;
std::vector<std::unique_ptr<RouteInfoDlg>> route_info_dlgs_;
Connections connections_; // last: disconnected before the widgets its handlers dereference are destroyed
};
+76
View File
@@ -1,7 +1,11 @@
#pragma once
#include <algorithm>
#include <array>
#include <cctype>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <string>
#include <vector>
@@ -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<std::string> split(const std::string &s, char sep) {
std::vector<std::string> 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<char[3], 256> 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<uint8_t> &dat, char separator = '\0') {
static const char digits[] = "0123456789ABCDEF";
std::string hex;
+9
View File
@@ -5,6 +5,7 @@
#include <cmath>
#include <filesystem>
#include <functional>
#include <memory>
#include <string>
#include <thread>
#include <vector>
@@ -90,6 +91,14 @@ std::string bootstrapSvg(const std::string &id); // empty if unknown
template <typename T>
std::vector<uint8_t> 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 <typename F>
auto guarded(const std::shared_ptr<bool> &alive, F fn) {
return [alive = std::weak_ptr<bool>(alive), fn = std::move(fn)](auto &&...args) {
if (!alive.expired()) fn(std::forward<decltype(args)>(args)...);
};
}
}
// Watches SIGINT/SIGTERM via a self-pipe and a dedicated waiter thread.
+1 -1
View File
@@ -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"