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. +![blot bar --read](images/blot-bar-read.png) + +### 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. +![blot line --follow](images/blot-line-follow.png) + +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 +``` +![blot line --poll](images/blot-line-poll.png) + +### 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 +``` +![blot scatter --exec](images/blot-scatter-exec.png) + +### 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 +``` +![blot scatter --watch](images/plot-line-watch.png) + +## 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 ![trig example](images/trig.png) + + ## 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; +}