Skip to content

Commit c0e74b1

Browse files
committed
feat: notifications display via a polled file (FEATURE-REQUESTS D1)
The minimal file-poll version of in-TUI notifications: a new `notify_file` global names a file whose contents are surfaced as a pop-up banner each time a menu screen is entered. - check_notify() reads the file and shows a one-line banner; a per-(mtime,size) signature kept on $ctx->{state} means each distinct write is shown once -- a fresh write (new mtime) or an append (new size) re-notifies, emptying the file arms the next write. The file is not consumed, so the producer owns clearing it. A leading ~ in the path is expanded; empty disables. - do_menu calls check_notify() on entering each menu (no change to the hot loop and zero cost when unconfigured). A socket/inotify listener can layer on top later. - ccfe.conf(5) documents the notify_file global; t/42 shows the banner on the first menu and a fresh banner (after a rewrite) on entering a child menu. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ec8a404 commit c0e74b1

4 files changed

Lines changed: 196 additions & 35 deletions

File tree

src/ccfe.pl

Lines changed: 90 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -127,41 +127,42 @@
127127
$NO_THEMES_MSG, $KEYMAP_PICK_TITLE, $KEYMAP_SET_MSG,
128128
$KEYMAP_TITLE, $NO_KEYMAPS_MSG, $KEY_SEARCH_LABEL,
129129
$SEARCH_OBJ_TITLE, $SEARCH_OBJ_PROMPT, $SEARCH_RESULTS_TITLE,
130-
$RESTRICTED_MSG, $RESTRICTED_TITLE, $RS_BOTTOM_ROWS,
131-
$RS_HEADER_ROWS, $RS_INFO_ID, $RS_STDERR_ID,
132-
$RS_STDOUT_ID, $RS_TOP_ROWS, $SAVE_DETAILED,
133-
$SAVE_DETAILED_DESCR, $SAVE_ERROR_MSG, $SAVE_ERROR_TITLE,
134-
$SAVE_FIELDVAL_MSG, $SAVE_FIELDVAL_TITLE, $SAVE_FNAME_PROMPT,
135-
$SAVE_FNAME_TITLE, $SAVE_SCRIPT, $SAVE_SCRIPT_DESCR,
136-
$SAVE_SIMPLE, $SAVE_SIMPLE_DESCR, $SAVE_TYPE_TITLE,
137-
$SEARCH_PTRN_PROMPT, $SEARCH_PTRN_TITLE, $SEPARATOR,
138-
$SEP_LINE, $SEP_LINE_DOUBLE, $SEP_TEXT,
139-
$SEP_TEXT_CENTER, $SHOW_ACTION_TITLE, $SIMPLE,
140-
$SR_BUFF_SIZE, $SYNTH_KEY_BASE, $STRING,
141-
$THEMEDIR, $KEYMAPDIR, $TRUE,
142-
$UCSTRING, $USERNAME, $USR_CFG,
143-
$USR_OBJ, $VERSION, $VERSION_DATE,
144-
$VERSION_YEAR, $WAIT_MSG_MSG, $WRKDIR,
145-
$YES, $attrk, $attrv,
146-
$called_form, $ch, $choice,
147-
$cpid, $descr, $es,
148-
$exec_hh, $exec_mm, $exec_ss,
149-
$i, $id, $lflags_size,
150-
$mlmargin, $mwin, $mwinr,
151-
$opt, $out, $ovl_mode,
152-
$p, $pad_lines, $path,
153-
$pid, $prev_wdir, $res,
154-
$rflags_size, $s, $scan,
155-
$search_string, $shcut_type, $text,
156-
$tmpfh, $twin, @CONFIRM_ITEMS,
157-
@ERR_LITTLE_SCREEN, @ERR_WRONG_FPATH, @FORM_TOP_MSG,
158-
@FSKeys, @LW_DISPLAY_TOP_MSG, @LW_MULTIVAL_TOP_MSG,
159-
@LW_SINGLEVAL_TOP_MSG, @MENU_TOP_MSG, @MSKeys,
160-
@RSKeys, @cnf_path, @cnf_path_base,
161-
@es_str, @flist, @fn_key_functions,
162-
@lines, @mf_path, @mf_path_base,
163-
%FN_LABEL, %bool_vals, %layout_vals,
164-
%options, %sep_type_vals, %type_vals,
130+
$NOTIFY_TITLE, $RESTRICTED_MSG, $RESTRICTED_TITLE,
131+
$RS_BOTTOM_ROWS, $RS_HEADER_ROWS, $RS_INFO_ID,
132+
$RS_STDERR_ID, $RS_STDOUT_ID, $RS_TOP_ROWS,
133+
$SAVE_DETAILED, $SAVE_DETAILED_DESCR, $SAVE_ERROR_MSG,
134+
$SAVE_ERROR_TITLE, $SAVE_FIELDVAL_MSG, $SAVE_FIELDVAL_TITLE,
135+
$SAVE_FNAME_PROMPT, $SAVE_FNAME_TITLE, $SAVE_SCRIPT,
136+
$SAVE_SCRIPT_DESCR, $SAVE_SIMPLE, $SAVE_SIMPLE_DESCR,
137+
$SAVE_TYPE_TITLE, $SEARCH_PTRN_PROMPT, $SEARCH_PTRN_TITLE,
138+
$SEPARATOR, $SEP_LINE, $SEP_LINE_DOUBLE,
139+
$SEP_TEXT, $SEP_TEXT_CENTER, $SHOW_ACTION_TITLE,
140+
$SIMPLE, $SR_BUFF_SIZE, $SYNTH_KEY_BASE,
141+
$STRING, $THEMEDIR, $KEYMAPDIR,
142+
$TRUE, $UCSTRING, $USERNAME,
143+
$USR_CFG, $USR_OBJ, $VERSION,
144+
$VERSION_DATE, $VERSION_YEAR, $WAIT_MSG_MSG,
145+
$WRKDIR, $YES, $attrk,
146+
$attrv, $called_form, $ch,
147+
$choice, $cpid, $descr,
148+
$es, $exec_hh, $exec_mm,
149+
$exec_ss, $i, $id,
150+
$lflags_size, $mlmargin, $mwin,
151+
$mwinr, $opt, $out,
152+
$ovl_mode, $p, $pad_lines,
153+
$path, $pid, $prev_wdir,
154+
$res, $rflags_size, $s,
155+
$scan, $search_string, $shcut_type,
156+
$text, $tmpfh, $twin,
157+
@CONFIRM_ITEMS, @ERR_LITTLE_SCREEN, @ERR_WRONG_FPATH,
158+
@FORM_TOP_MSG, @FSKeys, @LW_DISPLAY_TOP_MSG,
159+
@LW_MULTIVAL_TOP_MSG, @LW_SINGLEVAL_TOP_MSG, @MENU_TOP_MSG,
160+
@MSKeys, @RSKeys, @cnf_path,
161+
@cnf_path_base, @es_str, @flist,
162+
@fn_key_functions, @lines, @mf_path,
163+
@mf_path_base, %FN_LABEL, %bool_vals,
164+
%layout_vals, %options, %sep_type_vals,
165+
%type_vals,
165166
);
166167
## END-OUR
167168

@@ -719,6 +720,7 @@ sub load_msgs {
719720
SEARCH_OBJ_TITLE
720721
SEARCH_OBJ_PROMPT
721722
SEARCH_RESULTS_TITLE
723+
NOTIFY_TITLE
722724
NULL_FACTION_MSG
723725
NULL_FACTION_TITLE
724726
EXEC_NOTFOUND_MSG
@@ -1878,6 +1880,18 @@ sub load_config {
18781880
$ctx->{cfg}{PATH} = $attrv;
18791881
last ASWITCH;
18801882
}
1883+
elsif (/^NOTIFY_FILE$/) {
1884+
1885+
# File polled for notifications to surface
1886+
# in the TUI (FEATURE-REQUESTS D1). A
1887+
# leading ~ is expanded; empty disables.
1888+
my $nf = $attrv;
1889+
$nf =~ s/^\s+//;
1890+
$nf =~ s/\s+$//;
1891+
$nf =~ s{^~(/|$)}{$ENV{HOME}$1};
1892+
$ctx->{cfg}{NOTIFY_FILE} = $nf;
1893+
last ASWITCH;
1894+
}
18811895
elsif (/^THEME$/) {
18821896

18831897
# Pull in a named colour/style theme from
@@ -2465,6 +2479,7 @@ sub set_cfg_defaults {
24652479
$ctx->{cfg}{THEME} = '';
24662480
$ctx->{cfg}{KEYMAP} = '';
24672481
$ctx->{cfg}{event2code} = {};
2482+
$ctx->{cfg}{NOTIFY_FILE} = '';
24682483

24692484
# Menu/screen theme attributes. Defaults preserve the historical
24702485
# monochrome look (overall screen normal, selected item reversed, bold
@@ -3036,6 +3051,43 @@ ($win)
30363051
return;
30373052
}
30383053

3054+
# Notifications display (FEATURE-REQUESTS D1, minimal file-poll version). If a
3055+
# `notify_file` is configured and is non-empty, surface its contents as a pop-up
3056+
# banner -- checked each time a menu screen is entered. A per-(mtime,size)
3057+
# signature means each notification is shown once: a fresh write (new mtime)
3058+
# re-notifies; emptying the file arms the next write. Non-destructive (the file
3059+
# is not consumed), so the producer owns clearing it. A richer socket/inotify
3060+
# listener can layer on later.
3061+
sub check_notify ($win) {
3062+
my $nf = $ctx->{cfg}{NOTIFY_FILE};
3063+
return unless defined $nf and $nf ne '';
3064+
3065+
my @st = stat $nf;
3066+
if ( !@st or $st[7] == 0 ) {
3067+
delete $ctx->{state}{notify_sig}; # gone/empty: re-arm for next write
3068+
return;
3069+
}
3070+
my $sig = "$st[9]:$st[7]"; # mtime:size
3071+
return if ( $ctx->{state}{notify_sig} // '' ) eq $sig;
3072+
$ctx->{state}{notify_sig} = $sig;
3073+
3074+
my $body = '';
3075+
if ( open( my $fh, '<', $nf ) ) {
3076+
local $/;
3077+
$body = <$fh> // '';
3078+
close($fh);
3079+
}
3080+
3081+
# disp_msg shows one centred line; collapse the file to a single line (it
3082+
# truncates to the screen width itself).
3083+
$body =~ s/\s+/ /g;
3084+
$body =~ s/^\s+//;
3085+
$body =~ s/\s+$//;
3086+
return if $body eq '';
3087+
disp_msg( $win, $body, $NOTIFY_TITLE );
3088+
return;
3089+
}
3090+
30393091
sub do_menu {
30403092
my ( $menuname, $title ) = @_;
30413093

@@ -3160,6 +3212,9 @@ sub do_menu {
31603212
};
31613213
$draw_menu->();
31623214

3215+
# Surface any pending notification on entering this menu (D1).
3216+
check_notify($win);
3217+
31633218
$es = 0;
31643219
while ( !defined( $ctx->{state}{exec_args} ) ) {
31653220
disp_page( $win, item_index( current_item($cmenu) ) + 1,

src/man/ccfe.conf.5

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,24 @@ Values:
158158
.br
159159
Required: No
160160

161+
.SS notify_file
162+
.PP
163+
Path of a file polled for notifications (FEATURE-REQUESTS D1). Whenever a menu
164+
screen is entered, if the file exists and is non-empty its contents are shown as
165+
a pop-up banner. Each distinct write is shown once \(em the file is identified
166+
by its modification time and size, so rewriting it (or appending) re-notifies,
167+
and emptying it arms the next write. The file is
168+
.B not
169+
consumed, so the producer owns clearing it. A leading
170+
.B ~
171+
is expanded to the home directory; an empty value disables the feature. This is
172+
the minimal file-poll version; a socket/inotify listener may be added later.
173+
.PP
174+
Values:
175+
.I PATH
176+
.br
177+
Required: No
178+
161179
.SS keymap
162180
.PP
163181
Pull in a named keymap preset \(em a complete set of key bindings shipped (or

src/msg/C/ccfe

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ SEARCH_OBJ_TITLE = "SEARCH MENUS & FORMS"
5151
SEARCH_OBJ_PROMPT = "Search for:"
5252
SEARCH_RESULTS_TITLE = "Search results -- pick one to open"
5353

54+
# Title of the pop-up shown for a pending notification (the notify_file poll):
55+
NOTIFY_TITLE = "NOTIFICATION"
56+
5457
# Title and message string for pop-up window displayed when
5558
# a form is without action{} block or with wrong select-item{} block:
5659
NULL_FACTION_TITLE = "ERROR"

src/t/42-notify.t

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/usr/bin/perl
2+
#
3+
# Notifications display (FEATURE-REQUESTS D1, minimal file-poll version).
4+
#
5+
# When `notify_file` names a non-empty file, its contents are shown as a pop-up
6+
# banner each time a menu screen is entered, and each distinct write is shown
7+
# once. This drives both: the banner appears on the first menu, and after the
8+
# file is rewritten a fresh banner appears on entering a child menu.
9+
#
10+
use strict;
11+
use warnings;
12+
use FindBin qw($Bin);
13+
use lib "$Bin/lib";
14+
use File::Temp qw(tempdir);
15+
use Test::More;
16+
17+
my $src = "$Bin/..";
18+
19+
eval { require CCFE::Test::Pty; 1 } or plan skip_all => "pty helper: $@";
20+
plan skip_all => 'no Linux pseudo-terminal' unless CCFE::Test::Pty->available;
21+
plan skip_all => 'Curses not installed' unless eval { require Curses; 1 };
22+
plan skip_all => 'no installer' unless -f "$src/install.sh";
23+
24+
my $prefix = tempdir( CLEANUP => 1 );
25+
my $log = `cd "$src" && sh install.sh -b -p "$prefix" 2>&1`;
26+
plan skip_all => "install failed: $log" unless $? == 0 && -x "$prefix/bin/ccfe";
27+
28+
my $nfile = "$prefix/notify.txt";
29+
open( my $nf, '>', $nfile ) or plan skip_all => "notify: $!";
30+
print {$nf} "NOTIFYBANNERMARK";
31+
close($nf);
32+
33+
my $conf = "$prefix/etc/ccfe.conf";
34+
open( my $cf, '>>', $conf ) or plan skip_all => "conf: $!";
35+
print {$cf} "\nglobal {\n notify_file = $nfile\n}\n";
36+
close($cf);
37+
38+
my $objs = "$prefix/share/ccfe/objects/ccfe";
39+
open( my $m1, '>', "$objs/notifytest.menu" ) or plan skip_all => "write: $!";
40+
print {$m1} <<'MENU';
41+
title { Notify test }
42+
item {
43+
id = GO
44+
descr = Go to child
45+
action = menu:child
46+
}
47+
MENU
48+
close($m1);
49+
50+
open( my $m2, '>', "$objs/child.menu" ) or plan skip_all => "write: $!";
51+
print {$m2} <<'MENU';
52+
title { Child menu }
53+
item {
54+
id = X
55+
descr = Nothing
56+
action = run:true
57+
}
58+
MENU
59+
close($m2);
60+
61+
plan tests => 2;
62+
63+
my $p = CCFE::Test::Pty->spawn( 80, 24, "$prefix/bin/ccfe", 'notifytest' );
64+
$p->pump(1.3);
65+
like( $p->screen, qr/NOTIFYBANNERMARK/,
66+
'a pending notification is shown on entering the menu' );
67+
$p->send(" "); # dismiss the banner
68+
$p->pump(0.5);
69+
70+
# A fresh write (different size) is a new notification; entering the child menu
71+
# surfaces it.
72+
open( my $nf2, '>', $nfile ) or die "notify rewrite: $!";
73+
print {$nf2} "NEWNOTIFYMARK";
74+
close($nf2);
75+
76+
$p->send("\r"); # GO -> child menu -> check on entry
77+
$p->pump(1.0);
78+
like( $p->screen, qr/NEWNOTIFYMARK/,
79+
'a fresh write is surfaced on the next menu entry' );
80+
$p->send(" "); # dismiss
81+
$p->pump(0.3);
82+
$p->send("\e");
83+
$p->pump(0.2);
84+
$p->send("\e");
85+
$p->pump(0.2);

0 commit comments

Comments
 (0)