diff --git a/CMakeLists.txt b/CMakeLists.txt
index 9d5accf..43d9a39 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -2,38 +2,25 @@ CMAKE_MINIMUM_REQUIRED(VERSION 3.10)
PROJECT(blot VERSION 0.0.7 LANGUAGES C CXX)
set(CMAKE_EXPORT_COMPILE_COMMANDS YES)
-#set(CMAKE_BUILD_TYPE Release)
-#set(CMAKE_BUILD_TYPE Debug)
-#set(CMAKE_BUILD_TYPE RelWithDebInfo)
SET(CMAKE_INCLUDE_CURRENT_DIR ON)
SET(CMAKE_MODULE_PATH ${PROJECT_SOURCE_DIR}/cmake)
-#FIND_PACKAGE(libev REQUIRED)
-FIND_PACKAGE(PkgConfig)
-
-PKG_CHECK_MODULES(GLIB2 REQUIRED glib-2.0>=2.44)
-#PKG_CHECK_MODULES(GIO REQUIRED gio-2.0>=2.44)
-#PKG_CHECK_MODULES(GIOUNIX REQUIRED gio-unix-2.0>=2.44)
-
-LINK_DIRECTORIES(
- ${GLIB2_LIBRARY_DIRS}
- ${GIO_LIBRARY_DIRS}
- ${GIOUNIX_LIBRARY_DIRS}
-)
if (NOT DEFINED CMAKE_C_STANDARD)
- SET(CMAKE_C_STANDARD 99) # use C99 if one is not set by user
+ SET(CMAKE_C_STANDARD 99)
endif()
if (NOT DEFINED CMAKE_CXX_STANDARD)
- SET(CMAKE_CXX_STANDARD 20) # used only in test harness
+ SET(CMAKE_CXX_STANDARD 20)
endif()
add_definitions(-D_GNU_SOURCE)
-if(CMAKE_COMPILER_IS_GNUCC)
- add_definitions(-Wall -Werror -Wno-variadic-macros -Wno-dangling-pointer)
-endif(CMAKE_COMPILER_IS_GNUCC)
+if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
+ add_compile_options(-Wall -Werror -Wno-variadic-macros -Wno-dangling-pointer)
+elseif(CMAKE_CXX_COMPILER_ID STREQUAL "Clang")
+ add_compile_options(-Wall -Werror -Wno-variadic-macros)
+endif()
if(CMAKE_BUILD_TYPE MATCHES Debug)
message(STATUS "ASAN enabled for debug build")
@@ -49,14 +36,14 @@ include(GetGitRevisionDescription)
get_git_head_revision(GIT_REFSPEC GIT_SHA1)
git_describe(GIT_REVISION)
-add_definitions(-D"BLOT_GIT_REFSPEC=${GIT_REFSPEC}")
-add_definitions(-D"BLOT_GIT_REVISION=${GIT_REVISION}")
-add_definitions(-D"BLOT_GIT_SHA1=${GIT_SHA1}")
+add_definitions(-DBLOT_GIT_REFSPEC="${GIT_REFSPEC}")
+add_definitions(-DBLOT_GIT_REVISION="${GIT_REVISION}")
+add_definitions(-DBLOT_GIT_SHA1="${GIT_SHA1}")
INCLUDE_DIRECTORIES(${CMAKE_SOURCE_DIR}/include)
add_subdirectory(lib)
-#add_subdirectory(cli)
+add_subdirectory(cli)
add_subdirectory(examples/c)
add_subdirectory(examples/cpp)
diff --git a/README.md b/README.md
index 118ec5d..aad7c71 100644
--- a/README.md
+++ b/README.md
@@ -1,29 +1,31 @@
# blot
-Blot is a plotting library written in C (with a C++ wrapper), that plots data onto a string buffer.
+Blot is a plotting library written in C, that plots data onto a string buffer.
That's right, there are no images, just text -- see examples below.
+There is a C++ wrapper provided, as well as a CLI tool.
+
Site: [bartman.github.io/blot](https://bartman.github.io/blot/)
GitHub: [github.com/bartman/blot](https://github.com/bartman/blot/)
-Copyright © 2021 Bart Trojanowski
+Copyright © 2021-2025 Bart Trojanowski
Licensed under LGPL v2.1, or any later version.
## Noteworthy features
- * plots to the console as text (by calling `puts()`)
+ * plots to the console as text (by calling `puts()`/`printf()`)
* very very fast (compared to python alternatives)
* very very memory usage friendly
* can plot multiple datasets on one canvas
* uses familiar figure based API (similar to existing python plotting frameworks)
* supports braille plotting (like [plotille](https://github.com/tammoippen/plotille))
- * 256 colour support
* data arrays can be provided in various types (`int16`, `int32`, `int64`, `double`, or `float`)
- * there is a C++ wrapper, for convenience
+ * there is a C++ wrapper, with limited features, for convenience
+ * there is a CLI tool that can plot from files or using output of commands
## Prerequisites
@@ -46,10 +48,160 @@ You can build debug (with `ASAN`) using
make TYPE=Debug
-## Examples
+Run `make help` for a full list.
+
+## blot CLI
+
+The easiest say to use `blot` is to use the CLI, which is able to read from files
+or launch programs, then plot the numbers it finds.
+
+See the online help for a full list of features...
+
+ ❯ blot --help
+
+```sh
+SYNOPSIS
+ blot [-h] [-V] [-v] [--debug] [--timing] [-i ]
+ blot [-A|-U|-B] ((scatter|line|bar) ([-R ] | [-F ] | [-P ] | [-X ] | [-W ]) [-p ] [-r ] [-c ] [-i ])...
+
+OPTIONS
+ -h, --help This help
+ -V, --version Version
+ -v, --verbose Enable verbose output
+ --debug Enable debug output
+ --timing Show timing statitiscs
+ -i, --interval Display interval in seconds
+
+ Output:
+ -A, --ascii ASCII output
+ -U, --unicode Unicode output
+ -B, --braille Braille output
+
+ Plot type:
+ scatter Add a scatter plot
+ line Add a line/curve plot
+ bar Add a bar plot
+
+ Plot data source:
+ -R, --read Read file to the end, each line is a record
+ -F, --follow Read file waiting for more, each line is a record
+ -P, --poll Read file at interval, each read is one record
+ -X, --exec Run command, each line is a record
+ -W, --watch Run command at interval, each read is one record
+
+ Data source parsing:
+ -p, --position
+ Find numbers in input line, pick 1 or 2 positions for X and Y values
+
+ -r, --regex Regex to match numbers from input line
+
+ Plot modifiers:
+ -c, --color Set plot color (1..255)
+ -i, --interval Set sampling interval in seconds
+
+EXAMPE
+
+ blot --braille \
+ line --color 10 --read x_y1_values -p 1,2 \
+ scatter --color 11 --read x_y2_values -p 1,2
+
+ blot --braille \
+ scatter --color 11 --read y_values \
+ line --color 10 --exec 'seq 1 100'
+
+ blot --braille \
+ line --poll /proc/loadavg --position 1 \
+ line --poll /proc/loadavg --position 2 \
+ line --poll /proc/loadavg --position 3
+```
+
+
+First, pick a plotting mode, there are 3 choices: `scatter`, `line`, and `bar`.
+Multiple plots can be overlayed.
+
+Each plot needs to get data from a file or process, and there are 5 options:
+- `--read ` to read lines data from a file, stop at the end.
+- `--follow ` to read lines data from a file, but wait for more data (`Ctrl-C` to stop).
+- `--poll ` to poll a file (like `/sys` or `/proc` files), at some interval, and plot the results.
+- `--exec ` to run a program that will give us data to plot on successive lines.
+- `--watch ` to run a program that outputs one number per execution, which will be accumulate and plotted.
+
+Let's look at some examples.
+
+### plot numbers from a file (read file mode)
+
+Let's say we have a file that contains some X,Y pairs...
+```
+0 10
+1 25
+2 0
+3 40
+4 55
+```
+
+We can plot this using...
+
+```sh
+❯ blot bar --read mydata --position 1,2
+```
+The values 1 and 2 are positions in each line where `blot` will find the X,Y coordinates.
-`blot` is being used in other projects as a library, but it comes with some
-examples.
+
+
+### plot numbers from a log file (follow file mode)
+
+Let's say that we have a log file, with some magnitude values.
+Let's just make something up with a script...
+```sh
+#!/usr/bin/env bash
+while true ; do
+ echo $RANDOM
+ sleep 1
+done > mydata
+```
+While the above is running, we can plot the data being generated...
+```sh
+❯ blot line --follow mydata
+```
+The file contains only one number per line, and `blot` will use the line number as the Y coordinate.
+
+
+Plot will update continuously, until `Ctrl-C` is used to stop.
+
+### plot the system load (poll file mode)
+
+Recall that `/proc/loadavg` has 3 load values (1,5,15 minutes). We can plot them...
+
+```sh
+❯ blot line --poll /proc/loadavg --position 1 --interval 0.5 \
+ line --poll /proc/loadavg --position 2 \
+ line --poll /proc/loadavg --position 3
+```
+
+
+### read X,Y from command line (exec mode)
+
+Here is a cool way to test your random number generator, generate two consecutive numbers and plot them against one another.
+
+```sh
+❯ blot scatter --exec 'for ((x=0;x<10000000;x++)) ; do echo $RANDOM $RANDOM ; done' --position 1,2
+```
+
+
+### plot power draw of a GPU (watch mode)
+
+The Nvidia graphics tools provide a system management interface CLI, named `nvidia-smi`.
+If we wanted to plot the average power draw, then we could do this...
+
+```sh
+blot --timing line --watch 'nvidia-smi --id=0 -q | grep -m1 "Average Power Draw"' --interval 0.1
+```
+
+
+## Source code examples
+
+`blot` is being used in other projects as a library, and it comes with many
+examples for the C and C++ usage.
Generated from [simple.c](examples/c/c-simple.c) (see also [simple.cpp](examples/cpp/cpp-simple.cpp) for C++ wrapper usage)
@@ -63,12 +215,15 @@ Generated from [trig.c](examples/c/c-trig.c) (see also [trig.cpp](examples/cpp/c

+
+
## Missing features
* different plotting modes like histograms (currently only plots line/scatter/bar)
* improve axis line and numbering (currently not very accurate)
* add axis labels and minor ticks (configurable)
* draw origin lines and minor tick lines (configurable)
+ * better 256 colour support
### Ideas:
diff --git a/cli/CMakeLists.txt b/cli/CMakeLists.txt
new file mode 100644
index 0000000..c1fab3f
--- /dev/null
+++ b/cli/CMakeLists.txt
@@ -0,0 +1,38 @@
+INCLUDE(FetchContent)
+FetchContent_Declare(
+ spdlog
+ GIT_REPOSITORY https://github.com/gabime/spdlog.git
+ GIT_TAG v1.14.1
+)
+FetchContent_Declare(
+ clipp
+ GIT_REPOSITORY https://github.com/muellan/clipp.git
+ GIT_TAG v1.2.3
+)
+FetchContent_Declare(
+ fmt
+ GIT_REPOSITORY https://github.com/fmtlib/fmt.git
+ GIT_TAG 11.2.0
+)
+FetchContent_MakeAvailable(spdlog clipp fmt)
+
+ADD_EXECUTABLE(blot
+ main.cpp
+ config.cpp
+ reader.cpp
+)
+
+TARGET_COMPILE_DEFINITIONS(blot PRIVATE
+ FMT_HEADER_ONLY
+)
+
+TARGET_INCLUDE_DIRECTORIES(blot PRIVATE
+ ${clipp_SOURCE_DIR}/include
+)
+
+TARGET_LINK_LIBRARIES(blot PRIVATE
+ blot_a
+ -lm
+ spdlog::spdlog
+ fmt::fmt
+)
diff --git a/cli/config.cpp b/cli/config.cpp
new file mode 100644
index 0000000..cc24475
--- /dev/null
+++ b/cli/config.cpp
@@ -0,0 +1,364 @@
+#include "config.hpp"
+
+#include
+#include
+#include
+#include
+
+#include "blot.hpp"
+#include "spdlog/spdlog.h"
+#include "fmt/format.h"
+
+#pragma GCC diagnostic push
+#pragma GCC diagnostic ignored "-Wunused-but-set-variable"
+#include "clipp.h"
+#pragma GCC diagnostic pop
+
+#pragma GCC diagnostic ignored "-Wunused-variable"
+
+Config::Config(int argc, char *argv[])
+: m_self(basename(argv[0]))
+{
+ bool show_help{}, show_version{};
+ auto cli_head = (
+ clipp::option("-h", "--help").set(show_help).doc("This help"),
+ clipp::option("-V", "--version").set(show_version).doc("Version"),
+ clipp::option("-v", "--verbose").call([]{
+ spdlog::set_level(spdlog::level::debug);
+ }).doc("Enable verbose output"),
+ clipp::option("--debug").call([]{
+ spdlog::set_level(spdlog::level::trace);
+ }).doc("Enable debug output"),
+ clipp::option("--timing").set(m_show_timing).doc("Show timing statitiscs"),
+ (clipp::option("-i", "--interval") & clipp::value("sec")
+ .call([&](const char *txt) { m_display_interval = txt; }))
+ .doc("Display interval in seconds")
+ );
+
+ auto cli_output = "Output:" % (
+ clipp::option("-A", "--ascii").set(m_output_type,ASCII).doc("ASCII output") |
+ clipp::option("-U", "--unicode").set(m_output_type,UNICODE).doc("Unicode output") |
+ clipp::option("-B", "--braille").set(m_output_type,BRAILLE).doc("Braille output")
+ );
+
+ std::vector wrong;
+ auto cli_wrong = clipp::any_other(wrong);
+
+ auto start_input = [&](blot_plot_type plot_type) {
+ blot_color color = m_inputs.empty()
+ ? m_first_color
+ : m_inputs.back().plot_color() + 1;
+ m_inputs.push_back(Input{plot_type, color});
+ };
+
+ auto cli = (
+ cli_head | (
+ cli_output,
+ clipp::repeatable(
+ /* select a plot type */
+
+ "Plot type:" % (
+
+ clipp::command("scatter")
+ .call([&]() { start_input(BLOT_SCATTER); })
+ .doc("Add a scatter plot") |
+ clipp::command("line")
+ .call([&]() { start_input(BLOT_LINE); })
+ .doc("Add a line/curve plot") |
+ clipp::command("bar")
+ .call([&]() { start_input(BLOT_BAR); })
+ .doc("Add a bar plot")
+ ),
+
+ "Plot data source:" % one_of(
+ /* source data from file or command */
+
+ (clipp::option("-R", "--read") & clipp::value("file")
+ .call([&](const char *f) { m_inputs.back().set_source(Input::READ, f); }))
+ .doc("Read file to the end, each line is a record"),
+ (clipp::option("-F", "--follow") & clipp::value("file")
+ .call([&](const char *f) { m_inputs.back().set_source(Input::FOLLOW, f); }))
+ .doc("Read file waiting for more, each line is a record"),
+ (clipp::option("-P", "--poll") & clipp::value("file")
+ .call([&](const char *f) { m_inputs.back().set_source(Input::POLL, f); }))
+ .doc("Read file at interval, each read is one record"),
+ (clipp::option("-X", "--exec") & clipp::value("cmd")
+ .call([&](const char *x) { m_inputs.back().set_source(Input::EXEC, x); }))
+ .doc("Run command, each line is a record"),
+ (clipp::option("-W", "--watch") & clipp::value("cmd")
+ .call([&](const char *x) { m_inputs.back().set_source(Input::WATCH, x); }))
+ .doc("Run command at interval, each read is one record")
+ ),
+
+ "Data source parsing:" % (
+ /* how to extract values from lines */
+
+ (clipp::option("-p", "--position") & clipp::value("y-pos|x-pos,y-pos")
+ .call([&](const char *txt) { m_inputs.back().set_position(txt); }))
+ .doc("Find numbers in input line, pick 1 or 2 positions for X and Y values"),
+ (clipp::option("-r", "--regex") & clipp::value("regex")
+ .call([&](const char *txt) { m_inputs.back().set_regex(txt); }))
+ .doc("Regex to match numbers from input line")
+
+ ),
+
+ "Plot modifiers:" % (
+ /* augment this plots characteristics */
+
+ (clipp::option("-c", "--color") & clipp::value("color")
+ .call([&](const char *txt) { m_inputs.back().set_color(txt); }))
+ .doc("Set plot color (1..255)"),
+ (clipp::option("-i", "--interval") & clipp::value("sec")
+ .call([&](const char *txt) { m_inputs.back().set_interval(txt); }))
+ .doc("Set sampling interval in seconds")
+ )
+
+ ),
+ cli_wrong
+ )
+ );
+
+ if(!parse(argc, argv, cli) || wrong.size()) {
+ spdlog::error("Failed to parse options.");
+ for (const auto &w : wrong) {
+ spdlog::error("Unexpected: {}", w);
+ }
+ spdlog::error("Usage:\n{}", clipp::usage_lines(cli, m_self).str());
+ std::exit(1);
+ }
+
+ if (show_version) {
+ std::cout << "refspec: " << BLOT_GIT_REFSPEC << '\n'
+ << "revision: " << BLOT_GIT_REVISION << '\n'
+ << "sha1: " << BLOT_GIT_SHA1 << std::endl;
+ std::exit(0);
+ }
+
+ if (show_help) {
+ Blot::Dimensions term;
+ unsigned doc_start = std::min(32u, term.cols/2);
+ auto fmt = clipp::doc_formatting{}
+ .indent_size(4)
+ .first_column(4)
+ .doc_column(doc_start)
+ .last_column(term.cols);
+ std::cout << clipp::make_man_page(cli, m_self, fmt)
+ .append_section("EXAMPLES",
+ "\n"
+ " blot --braille \\\n"
+ " line --color 10 --read x_y1_values -p 1,2 \\\n"
+ " scatter --color 11 --read x_y2_values -p 1,2\n"
+ "\n"
+ " blot --braille \\\n"
+ " scatter --color 11 --read y_values \\\n"
+ " line --color 10 --exec 'seq 1 100'\n"
+ "\n"
+ " blot --braille \\\n"
+ " line --poll /proc/loadavg --position 1 \\\n"
+ " line --poll /proc/loadavg --position 2 \\\n"
+ " line --poll /proc/loadavg --position 3\n"
+ )
+ << std::endl;
+ std::exit(0);
+ }
+
+ if (m_inputs.empty()) {
+ spdlog::error("no plots defined");
+ std::exit(1);
+ }
+
+ bool with_interval{}, no_interval{};
+ double prev_interval = 1;
+ for (auto &input : m_inputs) {
+ if (input.needs_interval()) {
+ if (!input.interval()) {
+ input.set_interval(prev_interval);
+ } else {
+ prev_interval = input.interval();
+ }
+ }
+ if (!input) {
+ spdlog::error("incomplete plot definition");
+ std::exit(1);
+ }
+ with_interval |= !!(input.interval());
+ no_interval |= !(input.interval());
+ }
+ if (with_interval && no_interval) {
+ spdlog::error("cannot mix interval and non-interval sources");
+ std::exit(1);
+ }
+ m_using_input_interval = with_interval;
+}
+
+void Input::set_source (Input::Source source, const std::string &details)
+{
+ if (m_source != NONE || m_details.size()) {
+ spdlog::error("Input source being set twice, m_source={}/{}, m_details='{}'",
+ int(m_source), source_name(), m_details);
+ std::exit(1);
+ }
+ m_source = source;
+ m_details = details;
+}
+
+void Input::set_position (const std::string &txt)
+{
+ /* ranges and views are fun, but not all standard libraries support it */
+#if defined(__cpp_lib_ranges) && __cpp_lib_ranges >= 201911L && defined(__GLIBCXX__) && __GLIBCXX__ >= 20230601
+ auto result = std::views::split(txt, ',')
+ | std::views::transform([](auto&& sr) {
+ std::string_view sv{sr.begin(), sr.end()};
+
+ unsigned number;
+ auto [_,ec] = std::from_chars(sv.begin(), sv.end(), number);
+ if (ec != std::errc{}) {
+ spdlog::error("failed to parse position from '{}': {}",
+ sv, std::make_error_code(ec).message());
+ std::exit(1);
+ }
+ return number;
+ });
+
+ std::vector positions(result.begin(), result.end());
+
+ switch (positions.size()) {
+ case 1:
+ m_extract.set(positions[0]);
+ break;
+ case 2:
+ m_extract.set(std::pair{positions[0], positions[1]});
+ break;
+ default:
+ spdlog::error("unexpected count ({}) of positions found in '{}'",
+ positions.size(), txt);
+ std::exit(1);
+ }
+#else
+ std::vector positions;
+ std::string_view sv = txt;
+ size_t start = 0;
+
+ while (true) {
+ size_t comma_pos = sv.find(',', start);
+ std::string_view part = (comma_pos == std::string_view::npos)
+ ? sv.substr(start)
+ : sv.substr(start, comma_pos - start);
+
+ unsigned number;
+ auto [ptr, ec] = std::from_chars(part.begin(), part.end(), number);
+ if (ec != std::errc{}) {
+ spdlog::error("failed to parse position from '{}': {}",
+ part, std::make_error_code(ec).message());
+ std::exit(1);
+ }
+ positions.push_back(number);
+
+ if (comma_pos == std::string_view::npos) {
+ break;
+ }
+ start = comma_pos + 1;
+ }
+
+ switch (positions.size()) {
+ case 1:
+ m_extract.set(positions[0]);
+ break;
+ case 2:
+ m_extract.set(std::pair{positions[0], positions[1]});
+ break;
+ default:
+ spdlog::error("unexpected count ({}) of positions found in '{}'",
+ positions.size(), txt);
+ std::exit(1);
+ }
+
+#endif
+}
+
+void Input::set_regex (const std::string &txt)
+{
+ m_extract.set(std::regex(txt));
+}
+
+void Input::set_color (const std::string &txt)
+{
+ unsigned number;
+ auto [_,ec] = std::from_chars(txt.data(), txt.data()+txt.size(), number);
+ if (ec != std::errc{}) {
+ spdlog::error("failed to parse color from '{}': {}",
+ txt, std::make_error_code(ec).message());
+ std::exit(1);
+ }
+ if (number < 1 || number > 255) {
+ spdlog::error("color {} is not valid", txt);
+ std::exit(1);
+ }
+ m_plot_color = number;
+}
+
+void Input::set_interval (double interval)
+{
+ if (needs_interval() && !interval) {
+ spdlog::warn("{} {} plot cannot have zero interval",
+ blot_plot_type_to_string(m_plot_type), source_name());
+ std::exit(1);
+ }
+ m_interval = interval;
+}
+
+void Input::set_interval (const std::string &txt)
+{
+ auto [_,ec] = std::from_chars(txt.data(), txt.data()+txt.size(), m_interval);
+ if (ec != std::errc{}) {
+ spdlog::error("failed to parse interval from '{}': {}",
+ txt, std::make_error_code(ec).message());
+ std::exit(1);
+ }
+}
+
+Input::operator bool() const
+{
+ switch (m_plot_type) {
+ case BLOT_SCATTER:
+ case BLOT_LINE:
+ case BLOT_BAR:
+ break;
+ default:
+ spdlog::warn("invalid plot type: {}", (int)m_plot_type);
+ return false;
+ }
+ switch (m_source) {
+ case READ:
+ case FOLLOW:
+ case EXEC:
+ case POLL:
+ case WATCH:
+ break;
+ case NONE:
+ spdlog::warn("{} plot does not defined an input (file or command)",
+ blot_plot_type_to_string(m_plot_type));
+ return false;
+ default:
+ spdlog::warn("{} plot has invalid type: {}",
+ blot_plot_type_to_string(m_plot_type), (int)m_source);
+ return false;
+ }
+ if (needs_interval() && !m_interval) {
+ spdlog::warn("{} plot has zero {} interval",
+ blot_plot_type_to_string(m_plot_type), source_name());
+ return false;
+ } else if (!needs_interval() && m_interval) {
+ spdlog::warn("{} plot has non-zero {} interval (%f)",
+ blot_plot_type_to_string(m_plot_type), source_name(), m_interval);
+ return false;
+ }
+ if (!std::any_of(m_details.begin(), m_details.end(), [](auto c){ return !std::isspace(c); })) {
+ spdlog::warn("{} plot has empty {} argument '{}'",
+ blot_plot_type_to_string(m_plot_type), source_name(), m_details);
+ return false;
+ }
+
+ return true;
+}
+
diff --git a/cli/config.hpp b/cli/config.hpp
new file mode 100644
index 0000000..eb24c18
--- /dev/null
+++ b/cli/config.hpp
@@ -0,0 +1,113 @@
+#pragma once
+
+#include
+#include
+#include
+
+#include "extract.hpp"
+
+#include "blot.hpp"
+#include "spdlog/spdlog.h"
+
+class Input final {
+public:
+ enum Source {
+ NONE, // not yet initialized
+ READ, // read from a file, each line is an entry
+ FOLLOW, // like READ, but wait for more data
+ POLL, // read a file repetitively at interval, each read is an entry
+ EXEC, // run program, read from stdout, each line is an entry
+ WATCH // run program repetitively at interval, each run is one entry
+ };
+
+protected:
+ blot_plot_type m_plot_type{BLOT_LINE};
+ Source m_source{NONE};
+ std::string m_details{};
+ Extract m_extract;
+ blot_color m_plot_color;
+ double m_interval{0};
+
+public:
+ explicit Input(blot_plot_type plot_type, blot_color color)
+ : m_plot_type(plot_type), m_plot_color(color) {}
+ ~Input() {}
+
+ blot_plot_type plot_type() const { return m_plot_type; }
+ std::string plot_name() const {
+ return blot_plot_type_to_string(m_plot_type);
+ }
+
+ Source source() const { return m_source; }
+ std::string source_name() const {
+ switch (m_source) {
+ case READ: return "read";
+ case FOLLOW: return "follow";
+ case EXEC: return "exec";
+ case WATCH: return "watch";
+ default: return {};
+ }
+ }
+
+ bool needs_interval() const {
+ switch (m_source) {
+ case POLL:
+ case WATCH:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ const char * details() const { return m_details.c_str(); }
+ const Extract& extract() const { return m_extract; }
+ blot_color plot_color() const { return m_plot_color; }
+ double interval() const { return m_interval; }
+
+ /* validate if the configuration looks sane */
+ operator bool() const;
+
+ void set_source (Input::Source source, const std::string &details);
+ void set_position (const std::string &txt);
+ void set_regex (const std::string &txt);
+ void set_color (const std::string &txt);
+ void set_interval (double interval);
+ void set_interval (const std::string &txt);
+
+
+};
+
+class Config final {
+protected:
+ const char *m_self{};
+ enum output_type { ASCII, UNICODE, BRAILLE } m_output_type;
+ const static blot_color m_first_color{9};
+ std::vector m_inputs;
+ bool m_display_interval{1};
+ bool m_using_input_interval{};
+ bool m_show_timing{};
+
+public:
+ explicit Config(int argc, char *argv[]);
+ ~Config() {}
+
+ std::string output_type_name() const {
+ switch (m_output_type) {
+ case ASCII: return "ASCII";
+ case UNICODE: return "Unicode";
+ case BRAILLE: return "Braille";
+ default: return {};
+ }
+ }
+
+ size_t inputs() const { return m_inputs.size(); }
+
+ const Input& input(size_t n) const { return m_inputs.at(n); }
+ Input& input(size_t n) { return m_inputs.at(n); }
+
+ bool using_input_interval() const { return m_using_input_interval; }
+ double display_interval() const { return m_display_interval; }
+ bool show_timing() const { return m_show_timing; }
+};
+
+
diff --git a/cli/extract.hpp b/cli/extract.hpp
new file mode 100644
index 0000000..cf30869
--- /dev/null
+++ b/cli/extract.hpp
@@ -0,0 +1,195 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+
+#include "blot.hpp"
+#include "spdlog/spdlog.h"
+#include "fmt/format.h"
+
+class Extract {
+public:
+ template
+ struct ParseResult {
+ X x;
+ Y y;
+ };
+
+protected:
+ using Var = std::variant, std::regex>;
+ Var m_var;
+
+ template
+ static std::pair __parse(const char *text, const char *end) {
+ T value;
+ auto [ptr,ec] = std::from_chars(text, end, value);
+ if (ec != std::errc{}) {
+ spdlog::error("failed to parse number({}) from '{}': {}",
+ typeid(T).name(), text,
+ std::make_error_code(ec).message());
+ std::exit(1);
+ }
+ return {value, ptr};
+ }
+
+ // advance to fist cluster of number digits
+ static const char * __find_start(const char *text, const char *end) {
+ while (text < end && !(std::isdigit(*text) || *text == '.'))
+ text ++;
+ return text;
+ }
+
+ // skip over the next cluster of number digits
+ static const char * __skip_over(const char *text, const char *end) {
+ while (text < end && (std::isdigit(*text) || *text == '.'))
+ text ++;
+ return text;
+ }
+
+ template
+ static std::optional> parse_y_position(size_t line, const char *text, unsigned y_position) {
+ spdlog::trace("line={} text='{}' x_position={} y_position",
+ line, text, y_position);
+
+ const char *end = text+std::strlen(text);
+ text = __find_start(text, end);
+ auto pos = 1u ;
+
+ while (text < end && pos < y_position) {
+ text = __skip_over(text, end);
+ text = __find_start(text, end);
+ pos ++;
+ }
+
+ if (!y_position || y_position == pos) {
+ auto [yvalue, _] = __parse(text, end);
+
+ return ParseResult{X(line), yvalue};
+ }
+
+ return {};
+ }
+
+ template
+ static std::optional> parse_xy_position(const char *text, unsigned x_position, unsigned y_position) {
+
+ spdlog::trace("text='{}' x_position={} y_position={}",
+ text, x_position, y_position);
+
+ const char *end = text+std::strlen(text);
+ text = __find_start(text, end);
+ auto pos = 1u;
+
+ auto first_position = std::min(x_position, y_position);
+ auto last_position = std::max(x_position, y_position);
+
+ while (text < end && pos < first_position) {
+ text = __skip_over(text, end);
+ text = __find_start(text, end);
+ pos ++;
+ }
+
+ X xvalue{};
+ Y yvalue{};
+ unsigned have = 0;
+
+ while (text < end && pos <= last_position) {
+
+ const char *next = nullptr;
+
+ if (pos == x_position) {
+ auto [val, ptr] = __parse(text, end);
+ have ++;
+ xvalue = val;
+ next = ptr;
+ }
+
+ if (pos == y_position) {
+ auto [val, ptr] = __parse(text, end);
+ have ++;
+ yvalue = val;
+ next = ptr;
+ }
+
+ if (have == 2)
+ return ParseResult{xvalue, yvalue};
+
+ if (!next)
+ next = __skip_over(text, end);
+
+ text = __find_start(next, end);
+ pos ++;
+ }
+
+ return {};
+ }
+
+ template
+ static std::optional> parse_regex(size_t line, const char *text, const std::regex &re) {
+
+ std::string str(text);
+
+ spdlog::trace("line={} text='{}'", line, text);
+
+ std::smatch matches;
+ if (!std::regex_search(str, matches, re))
+ return {};
+
+ spdlog::trace(" matches={}", matches.size());
+
+ switch (matches.size()) {
+ case 0:
+ return {};
+ case 1: {
+ const char *start = text + matches.position(1);
+ const char *end = start + matches.length(1);
+ auto [value, _] = __parse(start, end);
+
+ return ParseResult{X(line), value};
+ }
+ default: {
+ const char *xstart = text + matches.position(1);
+ const char *xend = xstart + matches.length(1);
+ auto [xvalue, _1] = __parse(xstart, xend);
+
+ const char *ystart = text + matches.position(1);
+ const char *yend = ystart + matches.length(1);
+ auto [yvalue, _2] = __parse(ystart, yend);
+
+ return ParseResult{xvalue, yvalue};
+ }
+ }
+ }
+
+
+
+public:
+ void set(const auto &thing) {
+ m_var = thing;
+ }
+
+ template
+ std::optional> parse(size_t line, const char *text) const {
+
+ if (std::holds_alternative(m_var)) {
+ auto &re = std::get(m_var);
+ return parse_regex(line, text, re);
+ }
+
+ if (std::holds_alternative>(m_var)) {
+ auto [x,y] = std::get>(m_var);
+ return parse_xy_position(text, x, y);
+ }
+
+ if (std::holds_alternative(m_var)) {
+ auto y = std::get(m_var);
+ return parse_y_position(line, text, y);
+ }
+
+ BLOT_THROW(EINVAL,"internal error");
+ }
+};
+
diff --git a/cli/main.cpp b/cli/main.cpp
new file mode 100644
index 0000000..26b5295
--- /dev/null
+++ b/cli/main.cpp
@@ -0,0 +1,131 @@
+#include
+#include
+#include
+#include
+
+#include "config.hpp"
+#include "reader.hpp"
+#include "plotter.hpp"
+
+#include "spdlog/spdlog.h"
+#include "fmt/format.h"
+
+static bool signaled = false;
+static void sighandler(int sig)
+{
+ signaled = true;
+}
+
+int main(int argc, char *argv[])
+{
+ Config config(argc, argv);
+
+ spdlog::debug("output_type = {}", config.output_type_name());
+
+ std::vector> readers;
+
+ signal(SIGINT, sighandler);
+
+ auto keep_going = [&]{
+ return !signaled
+ && std::any_of(readers.begin(), readers.end(),
+ [](auto &reader)->bool {
+ return *reader;
+ });
+ };
+
+ for (size_t i=0; i plotter(config);
+
+ double next_display = 0;
+
+ while(keep_going()) {
+
+ for (size_t i=0; iidle())
+ continue;
+
+ auto line = reader->line();
+ if (!line.has_value())
+ continue;
+
+ spdlog::trace("{}:{}: {}", input.details(), line->number, line->text);
+
+ auto result = input.extract().parse(line->number,
+ line->text.data());
+ if (result.has_value()) {
+ plotter.add(i, result->x, result->y);
+ } else {
+ spdlog::error("failed to parse value from source {} line {} '{}'",
+ i, line->number, line->text);
+ std::exit(1);
+ }
+ }
+
+ if (signaled)
+ return 1;
+
+ bool do_show_plot = false;
+ double sleep_after_seconds = 0;
+
+ /* display periodically */
+
+ if (double interval = config.display_interval()) {
+ double now = blot_double_time();
+ if (now > next_display) {
+ next_display = now + interval;
+ do_show_plot = true;
+ }
+ }
+
+ /* wait for the next time we have data */
+
+ #if 0
+ /* Debian 12 has an older standard library that cannot do this */
+ auto idle_view = readers | std::views::transform([](const auto& reader) { return reader->idle(); });
+ double idle = std::ranges::min(idle_view);
+ #else
+ double idle = 0;
+ for (size_t i=0; iidle();
+ idle = i ? std::min(idle, reader_idle) : reader_idle;
+ }
+ #endif
+
+ if (idle > 0) {
+ do_show_plot = true;
+ sleep_after_seconds = idle;
+ }
+
+ if (do_show_plot && plotter.have_data())
+ plotter.plot();
+
+ if (double useconds = sleep_after_seconds * 1000000) {
+
+ // unfortunately std::this_thread::sleep_for() cannot be used,
+ // as it will complete the sleep even if SIGINT is raised.
+
+ usleep(useconds);
+ }
+ }
+
+ plotter.plot();
+ std::puts("");
+}
diff --git a/cli/plotter.hpp b/cli/plotter.hpp
new file mode 100644
index 0000000..08ed3f2
--- /dev/null
+++ b/cli/plotter.hpp
@@ -0,0 +1,116 @@
+#pragma once
+
+#include "blot.hpp"
+#include "config.hpp"
+
+#include "blot.hpp"
+#include "spdlog/spdlog.h"
+
+template
+class Plotter final {
+protected:
+ struct Data {
+ std::vector m_xs;
+ std::vector m_ys;
+ };
+ std::vector m_data;
+ size_t m_count{};
+
+ const Config &m_config;
+ size_t m_max_layers{};
+ size_t m_data_history{};
+
+ struct {
+ size_t count{};
+ double init{};
+ double add{};
+ double render{};
+ double print{};
+ double total{};
+ } m_stats;
+
+
+public:
+ explicit Plotter(const Config &config)
+ : m_config(config), m_max_layers(m_config.inputs())
+ {
+ m_data.resize(m_max_layers);
+ }
+
+ void add(size_t layer, X x, Y y) {
+ m_data[layer].m_xs.push_back(x);
+ m_data[layer].m_ys.push_back(y);
+ m_count ++;
+ }
+
+ bool have_data() const { return m_count > 0; }
+
+ void plot() {
+
+ setlocale(LC_CTYPE, "");
+
+ bool timing = m_config.show_timing();
+
+ double t_start = timing ? blot_double_time() : 0;
+
+ Blot::Figure fig;
+ fig.set_axis_color(8);
+
+ #if 0
+ Blot::Dimensions term;
+ fig.set_screen_size(term.cols, term.rows/2);
+ #endif
+
+ double t_init = timing ? blot_double_time() : 0;
+
+ for (size_t i=0; i
+#include
+#include
+
+#include // for popen, pclose
+#include // for read, fileno
+#include // for fcntl, O_NONBLOCK
+#include // for errno
+#include // for strerror
+
+#include "spdlog/spdlog.h"
+
+namespace fs = std::filesystem;
+
+// used by Input::READ and Input::FOLLOW
+class FileReader : public Reader {
+
+ fs::path m_path;
+ bool m_follow{};
+ std::ifstream m_stream;
+ size_t m_line_number{};
+
+public:
+ FileReader(const std::string &details, bool follow = false)
+ : m_path(details), m_follow(follow)
+ {
+ if (!fs::exists(m_path)) {
+ spdlog::error("{}: does not exist", m_path.string());
+ std::exit(1);
+ }
+ if (!fs::is_character_file(m_path) && !fs::is_fifo(m_path) && !fs::is_regular_file(m_path)) {
+ spdlog::error("{}: is not a file/fifo/chardev", m_path.string());
+ std::exit(1);
+ }
+ m_stream = std::ifstream(m_path);
+ if (!m_stream.is_open()) {
+ spdlog::error("{}: failed to open", m_path.string());
+ std::exit(1);
+ }
+ }
+ ~FileReader() override {}
+
+ bool fail() const override { return m_stream.fail(); }
+ bool eof() const override { return m_stream.eof(); }
+ double idle() const override { return 0; }
+ operator bool() const override {
+ return !fail() && (!eof() || m_follow);
+ }
+
+ size_t lines() const override { return m_line_number; }
+
+ std::optional line() override
+ {
+ if (m_stream.fail())
+ // reached failed state
+ return {};
+
+ // TODO: may need to implement --line-buffered behaviour
+ std::string line;
+ if (std::getline(m_stream, line)) {
+ m_line_number ++;
+ // successfully read next line
+ spdlog::debug("{}:{}: {}", m_path.string(), m_line_number, line);
+ return Line(m_line_number, line);
+ }
+
+ spdlog::trace("{}:{}: follow={} fail={} eof={}", m_path.string(), m_line_number,
+ m_follow, m_stream.fail(), m_stream.eof());
+
+ if (m_stream.eof()) {
+ // reached the end of file
+ if (m_follow)
+ m_stream.clear();
+ spdlog::trace("{}:{}: reached end of file", m_path.string(), m_line_number);
+ return {};
+ }
+
+ if (!m_stream.fail()) {
+ // this will confuse the eof()/fail() logic -- should not happen
+ spdlog::error("{}: no output, fail={} eof={}",
+ m_path.string(), m_stream.fail(), m_stream.eof());
+ std::terminate();
+ }
+
+ // some reading failure
+ spdlog::warn("{}: failed reading", m_path.string());
+ return {};
+ }
+};
+
+// used by Input::POLL
+class FilePoller : public Reader {
+ fs::path m_path;
+ double m_interval;
+ bool m_fail = false;
+ std::chrono::steady_clock::time_point m_last_read{};
+ size_t m_line_number{};
+
+public:
+ FilePoller(const std::string &details, double interval)
+ : m_path(details), m_interval(interval)
+ {
+ if (m_interval <= 0.0) {
+ spdlog::error("invalid polling interval: {}", m_interval);
+ std::exit(1);
+ }
+ if (!fs::exists(m_path)) {
+ spdlog::error("{}: does not exist", m_path.string());
+ std::exit(1);
+ }
+ if (!fs::is_regular_file(m_path) && !fs::is_character_file(m_path) && !fs::is_other(m_path)) {
+ spdlog::error("{}: unsupported file type for polling", m_path.string());
+ std::exit(1);
+ }
+ }
+
+ ~FilePoller() override {}
+
+ bool fail() const override { return m_fail; }
+ bool eof() const override { return false; }
+ operator bool() const override { return !m_fail; }
+
+ double idle() const override {
+ if (m_fail) {
+ return 0.0;
+ }
+ auto now = std::chrono::steady_clock::now();
+ if (m_last_read == std::chrono::steady_clock::time_point{}) {
+ return 0.0;
+ }
+ double elapsed = std::chrono::duration(now - m_last_read).count();
+ if (elapsed >= m_interval) {
+ return 0.0;
+ } else {
+ return m_interval - elapsed;
+ }
+ }
+
+ size_t lines() const override { return m_line_number; }
+
+ std::optional line() override
+ {
+ if (m_fail) {
+ return {};
+ }
+
+ auto now = std::chrono::steady_clock::now();
+ if (m_last_read != std::chrono::steady_clock::time_point{} &&
+ std::chrono::duration(now - m_last_read).count() < m_interval) {
+ return {};
+ }
+
+ std::ifstream stream(m_path);
+ if (!stream.is_open()) {
+ spdlog::error("{}: failed to open", m_path.string());
+ m_fail = true;
+ return {};
+ }
+
+ std::string line;
+ if (std::getline(stream, line)) {
+ m_line_number ++;
+ spdlog::debug("{}: {}", m_path.string(), line);
+ m_last_read = now;
+ return Line(m_line_number, line);
+ } else {
+ if (stream.fail() && !stream.eof()) {
+ spdlog::warn("{}: failed reading", m_path.string());
+ m_fail = true;
+ } else {
+ spdlog::trace("{}: no data available", m_path.string());
+ }
+ m_last_read = now; // Update timestamp even on empty read to maintain polling interval
+ return {};
+ }
+ }
+};
+
+// used by Input::EXEC
+class ExecStreamReader : public Reader {
+ static constexpr const size_t g_reserve_space = 4096;
+ static constexpr const size_t g_read_chunk = 4096;
+
+ std::string m_command;
+ FILE* m_pipe = nullptr;
+ int m_fd = -1;
+ std::string m_buffer;
+ size_t m_line_number{};
+ bool m_eof = false;
+ bool m_fail = false;
+
+public:
+ ExecStreamReader(const std::string& command) : m_command(command) {
+ m_pipe = popen(m_command.c_str(), "r");
+ if (!m_pipe) {
+ spdlog::error("failed to execute command '{}': {}", m_command, std::strerror(errno));
+ std::exit(1);
+ }
+
+ m_fd = fileno(m_pipe);
+ int flags = fcntl(m_fd, F_GETFL, 0);
+ if (flags == -1 || fcntl(m_fd, F_SETFL, flags | O_NONBLOCK) == -1) {
+ spdlog::error("failed to set non-blocking mode for command '{}': {}", m_command, std::strerror(errno));
+ std::exit(1);
+ }
+
+ m_buffer.reserve(g_reserve_space);
+ }
+
+ ~ExecStreamReader() override {
+ if (m_pipe) {
+ pclose(m_pipe);
+ }
+ }
+
+ bool fail() const override { return m_fail; }
+ bool eof() const override { return m_eof; }
+ double idle() const override { return 0; }
+ operator bool() const override { return !m_fail && !m_eof; }
+
+ size_t lines() const override { return m_line_number; }
+
+ std::optional line() override {
+ if (m_fail || m_eof) {
+ return {};
+ }
+
+ // Extract a complete line from the buffer if available
+ while (true) {
+ size_t pos = m_buffer.find('\n');
+ if (pos != std::string::npos) {
+ m_line_number ++;
+ std::string l = m_buffer.substr(0, pos);
+ m_buffer.erase(0, pos + 1);
+ spdlog::debug("{}: {}", m_command, l);
+ return Line(m_line_number, l);
+ }
+
+ // Read more data non-blockingly
+ char buf[g_read_chunk];
+ ssize_t bytes_read = read(m_fd, buf, sizeof(buf));
+ if (bytes_read > 0) {
+ m_buffer.append(buf, bytes_read);
+ continue; // Check for a line again
+ } else if (bytes_read == 0) {
+ // EOF: subprocess exited
+ m_eof = true;
+ spdlog::trace("command '{}' reached EOF", m_command);
+ if (!m_buffer.empty()) {
+ // Return any remaining data as the last (unterminated) line
+ m_line_number ++;
+ std::string l = std::move(m_buffer);
+ m_buffer.clear();
+ spdlog::debug("{}: {}", m_command, l);
+ return Line(m_line_number, l);
+ }
+ return {};
+ } else {
+ // Read error
+ if (errno == EAGAIN || errno == EWOULDBLOCK) {
+ // No data available yet (async/non-blocking behavior)
+ return {};
+ } else {
+ spdlog::warn("read error for command '{}': {}", m_command, std::strerror(errno));
+ m_fail = true;
+ return {};
+ }
+ }
+ }
+ }
+};
+
+// used by Input::WATCH
+class ExecWatcher : public Reader {
+ std::string m_command;
+ double m_interval;
+ bool m_fail = false;
+ size_t m_line_number{};
+ std::chrono::steady_clock::time_point m_last_exec{};
+
+public:
+ ExecWatcher(const std::string &command, double interval)
+ : m_command(command), m_interval(interval)
+ {
+ if (m_interval <= 0.0) {
+ spdlog::error("invalid watching interval: {}", m_interval);
+ std::exit(1);
+ }
+ }
+
+ ~ExecWatcher() override {}
+
+ bool fail() const override { return m_fail; }
+ bool eof() const override { return false; }
+ operator bool() const override { return !m_fail; }
+
+ double idle() const override {
+ if (m_fail) {
+ return 0.0;
+ }
+ auto now = std::chrono::steady_clock::now();
+ if (m_last_exec == std::chrono::steady_clock::time_point{}) {
+ return 0.0;
+ }
+ double elapsed = std::chrono::duration(now - m_last_exec).count();
+ if (elapsed >= m_interval) {
+ return 0.0;
+ } else {
+ return m_interval - elapsed;
+ }
+ }
+
+ size_t lines() const override { return m_line_number; }
+
+ std::optional line() override
+ {
+ if (m_fail) {
+ return {};
+ }
+
+ auto now = std::chrono::steady_clock::now();
+ if (m_last_exec != std::chrono::steady_clock::time_point{} &&
+ std::chrono::duration(now - m_last_exec).count() < m_interval) {
+ return {};
+ }
+
+ FILE* pipe = popen(m_command.c_str(), "r");
+ if (!pipe) {
+ spdlog::error("failed to execute command '{}': {}", m_command, std::strerror(errno));
+ m_fail = true;
+ return {};
+ }
+
+ char buf[4096];
+ if (fgets(buf, sizeof(buf), pipe) == nullptr) {
+ if (ferror(pipe)) {
+ spdlog::warn("error reading from command '{}'", m_command);
+ m_fail = true;
+ } else {
+ spdlog::trace("no output from command '{}'", m_command);
+ }
+ pclose(pipe);
+ m_last_exec = now;
+ return {};
+ }
+
+ std::string line(buf);
+ size_t pos = line.find_last_not_of("\r\n");
+ if (pos != std::string::npos) {
+ line.erase(pos + 1);
+ } else {
+ line.clear();
+ }
+
+ spdlog::debug("{}: {}", m_command, line);
+
+ int status = pclose(pipe);
+ if (status != 0) {
+ spdlog::warn("command '{}' exited with status {}", m_command, status);
+ // Note: Not setting fail here, as we still got a line
+ }
+
+ m_line_number ++;
+ m_last_exec = now;
+ return Line(m_line_number, line);
+ }
+};
+
+std::unique_ptr Reader::from(const Input &input)
+{
+ switch (input.source()) {
+ case Input::READ:
+ return std::make_unique(input.details());
+
+ case Input::FOLLOW:
+ return std::make_unique(input.details(), true);
+
+ case Input::POLL:
+ return std::make_unique(input.details(), input.interval());
+
+ case Input::EXEC:
+ return std::make_unique(input.details());
+
+ case Input::WATCH:
+ return std::make_unique(input.details(), input.interval());
+
+ default:
+ spdlog::error("invalid input source value {}", (int)input.source());
+ std::terminate();
+ }
+}
diff --git a/cli/reader.hpp b/cli/reader.hpp
new file mode 100644
index 0000000..8bc001e
--- /dev/null
+++ b/cli/reader.hpp
@@ -0,0 +1,29 @@
+#pragma once
+
+#include "config.hpp"
+
+#include
+#include
+#include
+
+struct Line final {
+ size_t number;
+ std::string text;
+
+ explicit Line(size_t n, std::string t) : number(n), text(t) {}
+};
+
+class Reader {
+public:
+ virtual ~Reader() {}
+
+ virtual bool fail() const = 0;
+ virtual bool eof() const = 0;
+ virtual operator bool() const = 0;
+ virtual double idle() const = 0;
+ virtual size_t lines() const = 0;
+ virtual std::optional line() = 0;
+
+ static std::unique_ptr from(const Input &input);
+};
+
diff --git a/images/blot-bar-read.png b/images/blot-bar-read.png
new file mode 100644
index 0000000..e192e81
Binary files /dev/null and b/images/blot-bar-read.png differ
diff --git a/images/blot-line-follow.png b/images/blot-line-follow.png
new file mode 100644
index 0000000..9529b45
Binary files /dev/null and b/images/blot-line-follow.png differ
diff --git a/images/blot-line-poll.png b/images/blot-line-poll.png
new file mode 100644
index 0000000..5e91749
Binary files /dev/null and b/images/blot-line-poll.png differ
diff --git a/images/blot-scatter-exec.png b/images/blot-scatter-exec.png
new file mode 100644
index 0000000..a4d25a1
Binary files /dev/null and b/images/blot-scatter-exec.png differ
diff --git a/images/plot-line-watch.png b/images/plot-line-watch.png
new file mode 100644
index 0000000..4fccf7c
Binary files /dev/null and b/images/plot-line-watch.png differ
diff --git a/include/blot.h b/include/blot.h
index ff29ea7..c5c35e4 100644
--- a/include/blot.h
+++ b/include/blot.h
@@ -6,6 +6,7 @@
#include "blot_error.h"
#include "blot_figure.h"
#include "blot_layer.h"
+#include "blot_names.h"
#include "blot_screen.h"
#include "blot_terminal.h"
#include "blot_time.h"
diff --git a/include/blot.hpp b/include/blot.hpp
index 54f8889..7215c66 100644
--- a/include/blot.hpp
+++ b/include/blot.hpp
@@ -1,16 +1,7 @@
/* blot C++ wrapper */
/* vim: set noet sw=8 ts=8 tw=120: */
#pragma once
-#include "blot_canvas.h"
-#include "blot_color.h"
-#include "blot_error.h"
-#include "blot_figure.h"
-#include "blot_layer.h"
-#include "blot_screen.h"
-#include "blot_terminal.h"
-#include "blot_time.h"
-#include "blot_types.h"
-#include "blot_utils.h"
+#include "blot.h"
#include
#include
@@ -22,8 +13,8 @@
namespace Blot {
/* the C code already allocated a "GError", this class just
- * carries it throught the exception mechanism, then cleans up. */
-class Exception final {
+ * carries it through the exception mechanism, then cleans up. */
+class Exception final : public std::exception {
protected:
GError *m_error;
public:
@@ -57,7 +48,10 @@ class Exception final {
operator bool() const { return m_error != nullptr; }
int code() const { return m_error ? m_error->code : 0; }
std::string str() const { if (m_error) return m_error->message; return {}; }
- const char * c_str() const { return m_error ? m_error->message : nullptr; }
+
+ const char * what() const _GLIBCXX_TXN_SAFE_DYN _GLIBCXX_NOTHROW override {
+ return m_error ? m_error->message : nullptr;
+ }
};
@@ -66,11 +60,11 @@ class Exception final {
GError *error = g_error_new(G_UNIX_ERROR, code, "%s:%u:%s: " format, \
basename(__FILE__), __LINE__, __func__, ##args); \
std::cerr << error->message << std::endl; \
- throw Exception(error); \
+ throw Blot::Exception(error); \
})
#else
#define BLOT_THROW(code,format,args...) \
- throw Exception(g_error_new(G_UNIX_ERROR, code, "%s:%u:%s: " format, \
+ throw Blot::Exception(g_error_new(G_UNIX_ERROR, code, "%s:%u:%s: " format, \
basename(__FILE__), __LINE__, __func__, ##args))
#endif
diff --git a/include/blot_names.h b/include/blot_names.h
new file mode 100644
index 0000000..9346cd1
--- /dev/null
+++ b/include/blot_names.h
@@ -0,0 +1,16 @@
+/* vim: set noet sw=8 ts=8 tw=120: */
+#pragma once
+
+#include
+
+#include "blot_types.h"
+
+static inline const char * blot_plot_type_to_string(blot_plot_type plot_type) {
+ switch (plot_type) {
+ case BLOT_SCATTER: return "scatter";
+ case BLOT_LINE: return "line";
+ case BLOT_BAR: return "bar";
+ default: return NULL;
+ }
+}
+
diff --git a/include/blot_types.h b/include/blot_types.h
index 9422dea..e34eb1b 100644
--- a/include/blot_types.h
+++ b/include/blot_types.h
@@ -74,6 +74,7 @@ typedef enum blot_render_flags {
BLOT_RENDER_LEGEND_BELOW = 0x00000040,
BLOT_RENDER_NO_X_AXIS = 0x00000080,
BLOT_RENDER_NO_Y_AXIS = 0x00000100,
+ BLOT_RENDER_LEGEND_DETAILS = 0x00000200,
} blot_render_flags;
DEFINE_ENUM_OPERATORS_FOR(blot_render_flags)
diff --git a/include/blot_utils.h b/include/blot_utils.h
index d1ec72f..50c0a76 100644
--- a/include/blot_utils.h
+++ b/include/blot_utils.h
@@ -84,3 +84,5 @@
#if !__has_builtin(__builtin_constant_p)
#define __builtin_constant_p(x) (0)
#endif
+
+BLOT_EXTERN unsigned blot_env_to_uint(const char *name, unsigned dflt);
diff --git a/lib/CMakeLists.txt b/lib/CMakeLists.txt
index 7e712be..2378e5f 100644
--- a/lib/CMakeLists.txt
+++ b/lib/CMakeLists.txt
@@ -1,15 +1,20 @@
SET(LIBBLOT_SRCS
- blot_axis.c
- blot_braille.c
- blot_canvas.c
- blot_color.c
- blot_figure.c
- blot_layer.c
- blot_screen.c
- blot_utils.c
- blot_terminal.c
+ blot_axis.c
+ blot_braille.c
+ blot_canvas.c
+ blot_color.c
+ blot_figure.c
+ blot_layer.c
+ blot_screen.c
+ blot_utils.c
+ blot_terminal.c
)
+# we need glib
+
+FIND_PACKAGE(PkgConfig)
+PKG_CHECK_MODULES(GLIB2 REQUIRED glib-2.0>=2.44)
+
# build a libblot.so and a libblot.a
ADD_LIBRARY(blot_a STATIC ${LIBBLOT_SRCS})
@@ -19,33 +24,29 @@ ADD_LIBRARY(blot_so SHARED ${LIBBLOT_SRCS})
foreach(blot blot_a blot_so)
- SET_TARGET_PROPERTIES(${blot} PROPERTIES OUTPUT_NAME blot)
+ SET_TARGET_PROPERTIES(${blot} PROPERTIES OUTPUT_NAME blot)
- SET_PROPERTY(TARGET ${blot} PROPERTY POSITION_INDEPENDENT_CODE ON)
+ SET_PROPERTY(TARGET ${blot} PROPERTY POSITION_INDEPENDENT_CODE ON)
- #TARGET_COMPILE_DEFINITIONS(${blot}
- # PUBLIC var="val"
- #)
+ TARGET_INCLUDE_DIRECTORIES(${blot} INTERFACE
+ ${CMAKE_SOURCE_DIR}/blot/include
+ )
- TARGET_INCLUDE_DIRECTORIES(${blot} INTERFACE
- ${CMAKE_SOURCE_DIR}/blot/include
- )
+ TARGET_INCLUDE_DIRECTORIES(${blot} PUBLIC
+ ${GLIB2_INCLUDE_DIRS}
+ )
- TARGET_INCLUDE_DIRECTORIES(${blot} PUBLIC
- ${GLIB2_INCLUDE_DIRS}
- )
+ TARGET_INCLUDE_DIRECTORIES(${blot} PRIVATE
+ ${CMAKE_SOURCE_DIR}/blot/include
+ ${CMAKE_CURRENT_SOURCE_DIR}/include
+ )
- TARGET_INCLUDE_DIRECTORIES(${blot} PRIVATE
- ${CMAKE_SOURCE_DIR}/blot/include
- ${CMAKE_CURRENT_SOURCE_DIR}/include
- ${GIO_INCLUDE_DIRS}
- ${GIOUNIX_INCLUDE_DIRS}
- )
+ TARGET_LINK_DIRECTORIES(${blot} PUBLIC
+ ${GLIB2_LIBRARY_DIRS}
+ )
- TARGET_LINK_LIBRARIES(${blot}
- ${GLIB2_LIBRARIES}
- ${GIO_LIBRARIES}
- ${GIOUNIX_LIBRARIES}
- )
+ TARGET_LINK_LIBRARIES(${blot}
+ ${GLIB2_LIBRARIES}
+ )
endforeach(blot)
diff --git a/lib/blot_figure.c b/lib/blot_figure.c
index 4513077..f8e4ac6 100644
--- a/lib/blot_figure.c
+++ b/lib/blot_figure.c
@@ -272,20 +272,25 @@ static blot_margins blot_figure_finalize_margins(const blot_figure *fig,
/* add 3 digits of precision */
mrg.left += 4;
-
- // Ensure margins don't exceed dimensions
- mrg.left = min_t(unsigned, mrg.left, dim->cols / 2);
- mrg.bottom = min_t(unsigned, mrg.bottom, dim->rows / 2);
- mrg.right = min_t(unsigned, mrg.right, dim->cols / 2);
- mrg.top = min_t(unsigned, mrg.top, dim->rows / 2);
}
+ if (flags & BLOT_RENDER_LEGEND_ABOVE)
+ mrg.top += fig->layer_count;
+ if (flags & BLOT_RENDER_LEGEND_BELOW)
+ mrg.top += fig->layer_count;
+
#if 0
- /* for testing only */
+ /* don't use the top or right edge */
mrg.right += 1;
mrg.top += 1;
#endif
+ // Ensure margins don't exceed dimensions
+ mrg.left = min_t(unsigned, mrg.left, dim->cols / 2);
+ mrg.bottom = min_t(unsigned, mrg.bottom, dim->rows / 2);
+ mrg.right = min_t(unsigned, mrg.right, dim->cols / 2);
+ mrg.top = min_t(unsigned, mrg.top, dim->rows / 2);
+
return mrg;
}
diff --git a/lib/blot_layer.c b/lib/blot_layer.c
index 2cc33df..a2eb9a0 100644
--- a/lib/blot_layer.c
+++ b/lib/blot_layer.c
@@ -216,12 +216,15 @@ static bool blot_layer_scatter_int64(const blot_layer *lay, const blot_xy_limits
static bool blot_layer_line(const blot_layer *lay, const blot_xy_limits *lim,
blot_canvas *can, GError **error)
{
- double x_range = lim->x_max - lim->x_min + 1;
- double y_range = lim->y_max - lim->y_min + 1;
+ double x_range = lim->x_max - lim->x_min;
+ double y_range = lim->y_max - lim->y_min;
RETURN_ERRORx(x_range <= 0, false, error, ERANGE, "invalid column limits %f..%f", lim->x_min, lim->x_max);
RETURN_ERRORx(x_range <= 0, false, error, ERANGE, "invalid column limits %f..%f", lim->x_min, lim->x_max);
+ double per_col = (double)(can->dim.cols-1) / x_range;
+ double per_row = (double)(can->dim.rows-1) / y_range;
+
bool visible = false;
double px=0, py=0;
@@ -234,8 +237,8 @@ static bool blot_layer_line(const blot_layer *lay, const blot_xy_limits *lim,
return false;
// compute location
- double dx = (double)(rx - lim->x_min) * can->dim.cols / x_range;
- double dy = (double)(ry - lim->y_min) * can->dim.rows / y_range;
+ double dx = (double)(rx - lim->x_min) * per_col;
+ double dy = (double)(ry - lim->y_min) * per_row;
// plot it
if (likely (visible)) {
diff --git a/lib/blot_screen.c b/lib/blot_screen.c
index d52e5c2..ca84f89 100644
--- a/lib/blot_screen.c
+++ b/lib/blot_screen.c
@@ -67,9 +67,18 @@ static bool blot_screen_can_legend(blot_screen *scr, unsigned count,
//wchar_t star = 0x2605; // does not show up in Terminus font
wchar_t symbol = 0x25D8; // inverse bullet ◘
- int len = swprintf(p, end-p, L"%s%lc %s %s\n",
+ int len;
+
+ if (scr->flags & BLOT_RENDER_LEGEND_DETAILS) {
+ len = swprintf(p, end-p, L"%s%lc %s %s \tcount=%u\n",
+ colstr, symbol, COL_RESET,
+ lay->label,
+ lay->count);
+ } else {
+ len = swprintf(p, end-p, L"%s%lc %s %s\n",
colstr, symbol, COL_RESET,
lay->label);
+ }
RETURN_ERROR(len<0, false, error, "swprintf");
p += len;
}
@@ -88,7 +97,8 @@ static bool blot_screen_plot_cans(blot_screen *scr,
blot_canvas *const*cans,
GError **error)
{
- bool reset_after = false;
+ const int PREV_COLOR_UNUSED = -1;
+ int prev_color = PREV_COLOR_UNUSED;
wchar_t *p = scr->data + scr->data_used,
*end = scr->data + scr->data_size;
int len;
@@ -135,11 +145,13 @@ static bool blot_screen_plot_cans(blot_screen *scr,
/* apply Y-axis color */
- const char *colstr = fg(y_axs->color);
- len = swprintf(p, end-p, L"%s", colstr);
- RETURN_ERROR(len<0, false, error, "swprintf");
- p += len;
- reset_after = true;
+ if (!(scr->flags & BLOT_RENDER_NO_COLOR) && prev_color != y_axs->color) {
+ const char *colstr = fg(y_axs->color);
+ len = swprintf(p, end-p, L"%s", colstr);
+ RETURN_ERROR(len<0, false, error, "swprintf");
+ p += len;
+ prev_color = y_axs->color;
+ }
/* the next dsp_lft characters are the Y-axis label + line */
@@ -147,7 +159,7 @@ static bool blot_screen_plot_cans(blot_screen *scr,
ytick = blot_axis_get_tick_at(y_axs, c_y, error);
const char *ytick_label = ytick ? ytick->label : "";
- char axis_char = ytick ? '*' : '-';
+ char axis_char = ytick ? '*' : '|';
len = swprintf(p, end-p, L"%*s %c",
dsp_lft-2, ytick_label, axis_char);
@@ -182,12 +194,12 @@ static bool blot_screen_plot_cans(blot_screen *scr,
}
if (top_cell) {
- if (!(scr->flags & BLOT_RENDER_NO_COLOR)) {
+ if (!(scr->flags & BLOT_RENDER_NO_COLOR) && prev_color != top_col) {
const char *colstr = fg(top_col);
len = swprintf(p, end-p, L"%s", colstr);
RETURN_ERROR(len<0, false, error, "swprintf");
p += len;
- reset_after = true;
+ prev_color = top_col;
}
wch = top_cell;
}
@@ -197,11 +209,11 @@ static bool blot_screen_plot_cans(blot_screen *scr,
g_assert_cmpuint((uintptr_t)p, <, (uintptr_t)end);
}
- if (reset_after) {
+ if (prev_color != PREV_COLOR_UNUSED) {
len = swprintf(p, end-p, L"%s", COL_RESET);
RETURN_ERROR(len<0, false, error, "swprintf");
p += len;
- reset_after = false;
+ prev_color = PREV_COLOR_UNUSED;
}
*(p++) = L'\n';
@@ -213,11 +225,13 @@ static bool blot_screen_plot_cans(blot_screen *scr,
if (!draw_x_axis)
goto done_bot_line;
- const char *colstr = fg(x_axs->color);
- len = swprintf(p, end-p, L"%s", colstr);
- RETURN_ERROR(len<0, false, error, "swprintf");
- p += len;
- reset_after = true;
+ if (!(scr->flags & BLOT_RENDER_NO_COLOR) && prev_color != x_axs->color) {
+ const char *colstr = fg(x_axs->color);
+ len = swprintf(p, end-p, L"%s", colstr);
+ RETURN_ERROR(len<0, false, error, "swprintf");
+ p += len;
+ prev_color = x_axs->color;
+ }
if (s_y == dsp_bot) {
/* this line is the X-axis line */
@@ -266,11 +280,11 @@ static bool blot_screen_plot_cans(blot_screen *scr,
}
done_bot_line:
- if (reset_after) {
+ if (prev_color != PREV_COLOR_UNUSED) {
len = swprintf(p, end-p, L"%s", COL_RESET);
RETURN_ERROR(len<0, false, error, "swprintf");
p += len;
- reset_after = false;
+ prev_color = PREV_COLOR_UNUSED;
}
*(p++) = L'\n';
diff --git a/lib/blot_terminal.c b/lib/blot_terminal.c
index 7226c00..e01ec06 100644
--- a/lib/blot_terminal.c
+++ b/lib/blot_terminal.c
@@ -23,6 +23,7 @@ bool blot_terminal_set_size(blot_dimensions dims, GError **error)
return true;
}
+
// caller should call blot_terminal_set_size() if the terminal size cannot be determine
bool blot_terminal_get_size(blot_dimensions *dims, GError **error)
{
@@ -33,6 +34,14 @@ bool blot_terminal_get_size(blot_dimensions *dims, GError **error)
return true;
}
+ dims->cols = blot_env_to_uint("COLUMNS", 0);
+ dims->rows = blot_env_to_uint("LINES", 0);
+
+ if (dims->cols && dims->rows) {
+ blot_fixed_dims = *dims;
+ return true;
+ }
+
struct winsize w = {};
int rc = ioctl(STDOUT_FILENO, TIOCGWINSZ, &w);
RETURN_ERROR(rc<0,false,error,"failed to get terminal size");
diff --git a/lib/blot_utils.c b/lib/blot_utils.c
index 213a4fb..9d6c101 100644
--- a/lib/blot_utils.c
+++ b/lib/blot_utils.c
@@ -1,2 +1,17 @@
/* blot: various utility macros */
/* vim: set noet sw=8 ts=8 tw=120: */
+
+#include "blot_utils.h"
+
+unsigned blot_env_to_uint(const char *name, unsigned dflt)
+{
+ const char *txt = getenv(name);
+ if (!txt || !*txt)
+ return dflt;
+
+ unsigned long num = strtoul(txt, NULL, 0);
+ if (num == ULONG_MAX)
+ return dflt;
+
+ return num;
+}