chiark / gitweb /
Start of a full-screen editor for composing toots.
authorSimon Tatham <anakin@pobox.com>
Mon, 1 Jan 2024 16:13:15 +0000 (16:13 +0000)
committerSimon Tatham <anakin@pobox.com>
Tue, 2 Jan 2024 17:30:18 +0000 (17:30 +0000)
You can't actually post anything. And none of the keystrokes special
to the composing editor is implemented yet (ok, except [^O][Q] to quit
the editor). But you can at least start typing text into the buffer,
and it gets wrapped, colourised, and checked for being too long.

src/activity_stack.rs
src/editor.rs
src/menu.rs
src/tui.rs

index 77bc55128e37a02b5317d75a530bce85460bd951..d3178f44699fd2036ed40b35108a91141e1391f2 100644 (file)
@@ -6,6 +6,7 @@ pub enum NonUtilityActivity {
     LocalTimelineFile,
     SinglePost(String),
     HashtagTimeline(String),
+    ComposeToplevel,
 }
 
 #[derive(PartialEq, Eq, Debug, Clone)]
index 2b1154c4726b834834f15cc3592b3485ab2c5dd0..bef31c66a96091768298b9a1df396af2bdefc4e9 100644 (file)
@@ -1,12 +1,16 @@
+use std::cmp::{min, max};
 use unicode_width::UnicodeWidthChar;
 
 use super::activity_stack::{NonUtilityActivity, UtilityActivity};
-use super::client::Client;
+use super::client::{Client, ClientError};
 use super::coloured_string::ColouredString;
+use super::text::*;
 use super::tui::{
     ActivityState, CursorPosition, LogicalAction,
     OurKey, OurKey::*,
 };
+use super::types::InstanceStatusConfig;
+use super::scan_re::Scan;
 
 struct EditorCore {
     text: String,
@@ -635,3 +639,543 @@ pub fn get_hashtag_to_read() -> Box<dyn ActivityState> {
         })
     ))
 }
+
+#[derive(Debug, PartialEq, Eq, Clone)]
+struct ComposeBufferRegion {
+    start: usize,
+    end: usize,
+    colour: char,
+}
+
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+struct ComposeLayoutCell {
+    pos: usize,
+    x: usize,
+    y: usize,
+}
+
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+enum ComposerKeyState {
+    Start,
+    CtrlO,
+}
+
+struct Composer {
+    core: EditorCore,
+    regions: Vec<ComposeBufferRegion>,
+    layout: Vec<ComposeLayoutCell>,
+    scanner: &'static Scan,
+    conf: InstanceStatusConfig,
+    last_size: Option<(usize, usize)>,
+    keystate: ComposerKeyState,
+    header: FileHeader,
+    headersep: EditorHeaderSeparator,
+}
+
+impl Composer {
+    fn new(conf: InstanceStatusConfig, header: FileHeader,
+           text: &str) -> Self
+    {
+        Composer {
+            core: EditorCore {
+                text: text.to_owned(),
+                point: text.len(),
+                paste_buffer: "".to_owned(),
+            },
+            regions: Vec::new(),
+            layout: Vec::new(),
+            scanner: Scan::get(),
+            conf,
+            last_size: None,
+            keystate: ComposerKeyState::Start,
+            header,
+            headersep: EditorHeaderSeparator::new(),
+        }
+    }
+
+    #[cfg(test)]
+    fn test_new(conf: InstanceStatusConfig, text: &str) -> Self {
+        Self::new(conf, FileHeader::new(ColouredString::plain("dummy")), text)
+    }
+
+    fn is_line_boundary(c: char) -> bool {
+        // FIXME: for supporting multi-toot threads, perhaps introduce
+        // a second thing that functions as a toot-break, say \0, and
+        // regard it as a line boundary too for wrapping purposes
+        c == '\n'
+    }
+
+    fn make_regions(&self, core: &EditorCore) -> Vec<ComposeBufferRegion> {
+        let scanners = &[
+            ('#', &self.scanner.hashtag),
+            ('@', &self.scanner.mention),
+            ('u', &self.scanner.url),
+        ];
+
+        let mut regions: Vec<ComposeBufferRegion> = Vec::new();
+        let mut pos = 0;
+        let mut nchars = 0;
+
+        // Internal helper that we call whenever we decide what colour
+        // a region of the buffer ought to be, _after_ checking the
+        // character limit. Appends to 'regions', unless the region
+        // can be merged with the previous one.
+        let mut add_region_inner = |start: usize, end: usize, colour: char| {
+            // Special case: if the function below generates a
+            // zero-length region, don't bother to add it at all.
+            if end == start {
+                return
+            }
+
+            // Check if there's a previous region with the same colour
+            if let Some(ref mut prev) = regions.last_mut() {
+                if prev.colour == colour {
+                    assert_eq!(prev.end, start, "Regions should abut!");
+                    prev.end = end;
+                    return;
+                }
+            }
+
+            // Otherwise, just push a new region
+            regions.push(ComposeBufferRegion { start, end, colour });
+        };
+
+        // Internal helper that we call whenever we decide what colour
+        // a region of the buffer ought to be, _irrespective_ of the
+        // character limit. Inside here, we check the character limit
+        // and may use it to adjust the colours we give to
+        // add_region_inner.
+        let mut add_region = |start: usize, end: usize, colour: char| {
+            // Determine the total cost of the current region.
+            let cost = match colour {
+                'u' => self.conf.characters_reserved_per_url,
+                '@' => match self.core.text[start+1..end].find('@') {
+                    Some(pos) => pos + 1, // just the part before the @domain
+                    None => end - start, // otherwise the whole thing counts
+                }
+                _ => end - start,
+            };
+
+            // Maybe recolour the region as 'out of bounds', or partly so.
+            if nchars + cost <= self.conf.max_characters {
+                // Whole region is in bounds
+                add_region_inner(start, end, colour);
+            } else if nchars >= self.conf.max_characters {
+                // Whole region is out of bounds
+                add_region_inner(start, end, '!');
+            } else {
+                // Break the region into an ok and a bad subsection
+                let nbad_chars = nchars + cost - self.conf.max_characters;
+                let nok_chars = end.saturating_sub(start + nbad_chars);
+                let midpoint = start + nok_chars;
+                add_region_inner(start, midpoint, colour);
+                add_region_inner(midpoint, end, '!');
+            }
+
+            nchars += cost;
+        };
+
+        while pos < core.text.len() {
+            // Try all three regex matchers and see which one picks up
+            // something starting soonest (out of those that pick up
+            // anything at all).
+            let next_match = scanners.iter().filter_map(|(colour, scanner)| {
+                scanner.get_span(&core.text[pos..])
+                    .map(|(start, end)| (pos + start, pos + end, colour))
+            }).min();
+
+            match next_match {
+                Some((start, end, colour)) => {
+                    if pos < start {
+                        add_region(pos, start, ' ');
+                    }
+                    if start < end {
+                        add_region(start, end, *colour);
+                    }
+                    pos = end;
+                }
+                None => {
+                    add_region(pos, core.text.len(), ' ');
+                    break;
+                }
+            }
+        }
+
+        regions
+    }
+
+    fn layout(&self, width: usize) -> Vec<ComposeLayoutCell> {
+        let mut cells = Vec::new();
+        let mut pos = 0;
+        let mut x = 0;
+        let mut y = 0;
+        let mut soft_wrap_pos = None;
+        let mut hard_wrap_pos = None;
+
+        loop {
+            cells.push(ComposeLayoutCell{pos, x, y});
+            match self.core.char_width_and_bytes(pos) {
+                None => break, // we're done
+                Some((w, b)) => {
+                    let mut chars_iter = self.core.text[pos..].chars();
+                    let c = chars_iter.next()
+                        .expect("we just found out we're not at end of string");
+                    if Self::is_line_boundary(c) {
+                        // End of paragraph.
+                        y += 1;
+                        x = 0;
+                        soft_wrap_pos = None;
+                        hard_wrap_pos = None;
+                        pos += b;
+                    } else {
+                        x += w;
+                        if x <= width {
+                            pos += b;
+                            hard_wrap_pos = Some((pos, cells.len()));
+                            if c == ' ' && chars_iter.next() != Some(' ') {
+                                soft_wrap_pos = hard_wrap_pos;
+                            }
+                        } else {
+                            let (wrap_pos, wrap_cell) = match soft_wrap_pos {
+                                Some(p) => p,
+                                None => hard_wrap_pos.expect(
+                                    "We can't break the line _anywhere_?!"),
+                            };
+
+                            // Now rewind to the place we just broke
+                            // the line, and keep going
+                            pos = wrap_pos;
+                            cells.truncate(wrap_cell);
+                            y += 1;
+                            x = 0;
+                            soft_wrap_pos = None;
+                            hard_wrap_pos = None;
+                        }
+                    }
+                }
+            }
+        }
+
+        cells
+    }
+
+    fn get_coloured_line(&self, y: usize) -> Option<ColouredString> {
+        // Use self.layout to find the bounds of this line within the
+        // buffer text.
+        let start_cell_index = self.layout
+            .partition_point(|cell| cell.y < y);
+        if dbg!(start_cell_index) == self.layout.len() {
+            return None;    // y is after the end of the buffer
+        }
+        let start_pos = self.layout[start_cell_index].pos;
+
+        let end_cell_index = self.layout
+            .partition_point(|cell| cell.y <= y);
+        let end_pos = if end_cell_index == self.layout.len() {
+            self.core.text.len()
+        } else {
+            self.layout[end_cell_index].pos
+        };
+
+        // Trim the line-end character from the bounds if there is
+        // one: we don't want to _draw_ it, under any circumstances.
+        let last_char = if end_cell_index > start_cell_index {
+            let last_cell = &self.layout[end_cell_index - 1];
+            self.core.text[last_cell.pos..].chars().next()
+        } else {
+            None
+        };
+        let end_pos = if last_char.map_or(false, Self::is_line_boundary) {
+            end_pos - 1
+        } else {
+            end_pos
+        };
+
+        // Now look up in self.regions to decide what colour to make
+        // everything.
+        let start_region_index = self.regions
+            .partition_point(|region| region.end <= start_pos);
+
+        let mut cs = ColouredString::plain("");
+
+        for region in self.regions[start_region_index..].iter() {
+            if end_pos <= region.start {
+                break;          // finished this line
+            }
+            let start = max(start_pos, region.start);
+            let end = min(end_pos, region.end);
+            cs.push_str(&ColouredString::uniform(
+                &self.core.text[start..end], region.colour).slice());
+        }
+
+        Some(cs)
+    }
+
+    fn post_update(&mut self) {
+        self.regions = self.make_regions(&self.core);
+        if let Some((w, _h)) = self.last_size {
+            self.layout = self.layout(w);
+        }
+    }
+}
+
+#[test]
+fn test_regions() {
+    let standard_conf = InstanceStatusConfig {
+        max_characters: 500,
+        max_media_attachments: 4,
+        characters_reserved_per_url: 23,
+    };
+    let main_sample_text = "test a #hashtag and a @mention@domain.thingy and a https://url.url.url.example.com/whatnot.";
+
+    // Scan the sample text and ensure we're spotting the hashtag,
+    // mention and URL.
+    let composer = Composer::test_new(standard_conf.clone(), main_sample_text);
+    assert_eq!(composer.make_regions(&composer.core), vec! {
+        ComposeBufferRegion { start: 0, end: 7, colour: ' ' },
+        ComposeBufferRegion { start: 7, end: 15, colour: '#' },
+        ComposeBufferRegion { start: 15, end: 22, colour: ' ' },
+        ComposeBufferRegion { start: 22, end: 44, colour: '@' },
+        ComposeBufferRegion { start: 44, end: 51, colour: ' ' },
+        ComposeBufferRegion { start: 51, end: 90, colour: 'u' },
+        ComposeBufferRegion { start: 90, end: 91, colour: ' ' },
+    });
+
+    // Scan a shorter piece of text in which a hashtag and a mention
+    // directly abut. FIXME: actually, surely we _shouldn't_ match
+    // this?
+    let composer = Composer::test_new(
+        standard_conf.clone(), "#hashtag@mention");
+    assert_eq!(composer.make_regions(&composer.core), vec! {
+        ComposeBufferRegion { start: 0, end: 8, colour: '#' },
+        ComposeBufferRegion { start: 8, end: 16, colour: '@' },
+    });
+
+    // The total cost of main_sample_text is 61 (counting the mention
+    // and the URL for less than their full lengths). So setting
+    // max=60 highlights the final character as overflow.
+    let composer = Composer::test_new(InstanceStatusConfig {
+        max_characters: 60,
+        max_media_attachments: 4,
+        characters_reserved_per_url: 23,
+    }, main_sample_text);
+    assert_eq!(composer.make_regions(&composer.core), vec! {
+        ComposeBufferRegion { start: 0, end: 7, colour: ' ' },
+        ComposeBufferRegion { start: 7, end: 15, colour: '#' },
+        ComposeBufferRegion { start: 15, end: 22, colour: ' ' },
+        ComposeBufferRegion { start: 22, end: 44, colour: '@' },
+        ComposeBufferRegion { start: 44, end: 51, colour: ' ' },
+        ComposeBufferRegion { start: 51, end: 90, colour: 'u' },
+        ComposeBufferRegion { start: 90, end: 91, colour: '!' },
+    });
+
+    // Dropping the limit by another 1 highlights the last character
+    // of the URL.
+    // them.)
+    let composer = Composer::test_new(InstanceStatusConfig {
+        max_characters: 59,
+        max_media_attachments: 4,
+        characters_reserved_per_url: 23,
+    }, main_sample_text);
+    assert_eq!(composer.make_regions(&composer.core), vec! {
+        ComposeBufferRegion { start: 0, end: 7, colour: ' ' },
+        ComposeBufferRegion { start: 7, end: 15, colour: '#' },
+        ComposeBufferRegion { start: 15, end: 22, colour: ' ' },
+        ComposeBufferRegion { start: 22, end: 44, colour: '@' },
+        ComposeBufferRegion { start: 44, end: 51, colour: ' ' },
+        ComposeBufferRegion { start: 51, end: 90-1, colour: 'u' },
+        ComposeBufferRegion { start: 90-1, end: 91, colour: '!' },
+    });
+
+    // and dropping it by another 21 highlights the last 22 characters
+    // of the URL ...
+    let composer = Composer::test_new(InstanceStatusConfig {
+        max_characters: 38,
+        max_media_attachments: 4,
+        characters_reserved_per_url: 23,
+    }, main_sample_text);
+    assert_eq!(composer.make_regions(&composer.core), vec! {
+        ComposeBufferRegion { start: 0, end: 7, colour: ' ' },
+        ComposeBufferRegion { start: 7, end: 15, colour: '#' },
+        ComposeBufferRegion { start: 15, end: 22, colour: ' ' },
+        ComposeBufferRegion { start: 22, end: 44, colour: '@' },
+        ComposeBufferRegion { start: 44, end: 51, colour: ' ' },
+        ComposeBufferRegion { start: 51, end: 90-22, colour: 'u' },
+        ComposeBufferRegion { start: 90-22, end: 91, colour: '!' },
+    });
+
+    // but dropping it by _another_ one means that the entire URL
+    // (since it costs 23 chars no matter what its length) is beyond
+    // the limit, so now it all gets highlighted.
+    let composer = Composer::test_new(InstanceStatusConfig {
+        max_characters: 37,
+        max_media_attachments: 4,
+        characters_reserved_per_url: 23,
+    }, main_sample_text);
+    assert_eq!(composer.make_regions(&composer.core), vec! {
+        ComposeBufferRegion { start: 0, end: 7, colour: ' ' },
+        ComposeBufferRegion { start: 7, end: 15, colour: '#' },
+        ComposeBufferRegion { start: 15, end: 22, colour: ' ' },
+        ComposeBufferRegion { start: 22, end: 44, colour: '@' },
+        ComposeBufferRegion { start: 44, end: 51, colour: ' ' },
+        ComposeBufferRegion { start: 51, end: 91, colour: '!' },
+    });
+
+    // And just for good measure, drop the limit by one _more_, and show the ordinary character just before the URL being highlighted as well.
+    let composer = Composer::test_new(InstanceStatusConfig {
+        max_characters: 36,
+        max_media_attachments: 4,
+        characters_reserved_per_url: 23,
+    }, main_sample_text);
+    assert_eq!(composer.make_regions(&composer.core), vec! {
+        ComposeBufferRegion { start: 0, end: 7, colour: ' ' },
+        ComposeBufferRegion { start: 7, end: 15, colour: '#' },
+        ComposeBufferRegion { start: 15, end: 22, colour: ' ' },
+        ComposeBufferRegion { start: 22, end: 44, colour: '@' },
+        ComposeBufferRegion { start: 44, end: 51-1, colour: ' ' },
+        ComposeBufferRegion { start: 51-1, end: 91, colour: '!' },
+    });
+}
+
+#[test]
+fn test_layout() {
+    let conf = InstanceStatusConfig {
+        max_characters: 500,
+        max_media_attachments: 4,
+        characters_reserved_per_url: 23,
+    };
+
+    // The empty string, because it would be embarrassing if that didn't work
+    let composer = Composer::test_new(conf.clone(), "");
+    assert_eq!(composer.layout(10), vec!{
+        ComposeLayoutCell { pos: 0, x: 0, y: 0 }});
+
+    // One line, just to check that we get a position assigned to
+    // every character boundary
+    let composer = Composer::test_new(conf.clone(), "abc");
+    assert_eq!(composer.layout(10),
+               (0..=3).map(|i| ComposeLayoutCell { pos: i, x: i, y: 0 })
+               .collect::<Vec<_>>());
+    assert_eq!(composer.layout(3),
+               (0..=3).map(|i| ComposeLayoutCell { pos: i, x: i, y: 0 })
+               .collect::<Vec<_>>());
+
+    // Two lines, which wrap so that 'g' is first on the new line
+    let composer = Composer::test_new(conf.clone(), "abc def ghi jkl");
+    assert_eq!(
+        composer.layout(10),
+        (0..=7).map(|i| ComposeLayoutCell { pos: i, x: i, y: 0 }).chain(
+            (0..=7).map(|i| ComposeLayoutCell { pos: i+8, x: i, y: 1 }))
+            .collect::<Vec<_>>());
+
+    // An overlong line, which has to wrap via the fallback
+    // hard_wrap_pos system, so we get the full 10 characters on the
+    // first line
+    let composer = Composer::test_new(conf.clone(), "abcxdefxghixjkl");
+    assert_eq!(
+        composer.layout(10),
+        (0..=9).map(|i| ComposeLayoutCell { pos: i, x: i, y: 0 }).chain(
+            (0..=5).map(|i| ComposeLayoutCell { pos: i+10, x: i, y: 1 }))
+            .collect::<Vec<_>>());
+
+    // The most trivial case with a newline in: _just_ the newline
+    let composer = Composer::test_new(conf.clone(), "\n");
+    assert_eq!(composer.layout(10), vec!{
+        ComposeLayoutCell { pos: 0, x: 0, y: 0 },
+        ComposeLayoutCell { pos: 1, x: 0, y: 1 }});
+
+    // Watch what happens just as we type text across a wrap boundary.
+    // At 8 characters, this should be fine as it is, since the wrap
+    // width is 1 less than the physical screen width and the cursor
+    // is permitted to be in the final column if it's at end-of-buffer
+    // rather than on a character.
+    let composer = Composer::test_new(conf.clone(), "abc def ");
+    assert_eq!(
+        composer.layout(8),
+        (0..=8).map(|i| ComposeLayoutCell { pos: i, x: i, y: 0 })
+            .collect::<Vec<_>>());
+    // Now we type the next character, and it should wrap on to the
+    // next line.
+    let composer = Composer::test_new(conf.clone(), "abc def g");
+    assert_eq!(
+        composer.layout(8),
+        (0..=7).map(|i| ComposeLayoutCell { pos: i, x: i, y: 0 }).chain(
+            (0..=1).map(|i| ComposeLayoutCell { pos: i+8, x: i, y: 1 }))
+            .collect::<Vec<_>>());
+}
+
+impl ActivityState for Composer {
+    fn resize(&mut self, w: usize, h: usize) {
+        if self.last_size != Some((w, h)) {
+            self.last_size = Some((w, h));
+            self.post_update();
+        }
+    }
+
+    fn draw(&self, w: usize, h: usize) -> (Vec<ColouredString>, CursorPosition)
+    {
+        let mut lines = Vec::new();
+        lines.extend_from_slice(&self.header.render(w));
+        lines.extend_from_slice(&self.headersep.render(w));
+
+        let ytop = 0;           // FIXME: vary this to keep cursor in view
+        let ystart = lines.len();
+
+        while lines.len() < h {
+            let y = ytop + (lines.len() - ystart);
+            match self.get_coloured_line(y) {
+                Some(line) => lines.push(line),
+                None => break,  // ran out of lines in the buffer
+            }
+        }
+
+        let mut cursor_pos = CursorPosition::None;
+        let cursor_cell_index = self.layout
+            .partition_point(|cell| cell.pos < self.core.point);
+        if let Some(cell) = self.layout.get(cursor_cell_index) {
+            if cell.pos == self.core.point {
+                if let Some(y) = cell.y.checked_sub(ytop) {
+                    let y = y + ystart;
+                    if y < h {
+                        cursor_pos = CursorPosition::At(cell.x, y);
+                    }
+                }
+            }
+        }
+
+        (lines, cursor_pos)
+    }
+
+    fn handle_keypress(&mut self, key: OurKey, _client: &mut Client) ->
+        LogicalAction
+    {
+        use ComposerKeyState::*;
+
+        match (self.keystate, key) {
+            (Start, Ctrl('O')) => {
+                self.keystate = CtrlO;
+            }
+            (CtrlO, Pr('q')) | (CtrlO, Pr('Q')) => {
+                return LogicalAction::Pop;
+            }
+            (CtrlO, _) => {
+                self.keystate = Start;
+                return LogicalAction::Beep;
+            }
+            (Start, _) => {
+                self.core.handle_keypress(key);
+                self.post_update();
+            }
+        }
+        LogicalAction::Nothing
+    }
+}
+
+pub fn compose_toplevel_post(client: &mut Client) ->
+    Result<Box<dyn ActivityState>, ClientError>
+{
+    let inst = client.instance()?;
+    let header = FileHeader::new(ColouredString::uniform(
+        "Compose a post", 'H'));
+    Ok(Box::new(Composer::new(inst.configuration.statuses, header, "")))
+}
index ba256d202186c88a57616728daeaec26cd60ac9e..f9b57eab1ea023eca74568323d73b1d588ee6259 100644 (file)
@@ -73,12 +73,6 @@ impl Menu {
 
     fn add_action_coloured(&mut self, key: OurKey, desc: ColouredString,
                            action: LogicalAction) {
-        let desc = if action == LogicalAction::NYI {
-            desc + &ColouredString::plain(" ") +
-                &ColouredString::uniform("NYI", '!')
-        } else {
-            desc
-        };
         self.lines.push(MenuLine::Key(MenuKeypressLine::new(key, desc)));
 
         if action != LogicalAction::Nothing {
@@ -201,8 +195,8 @@ pub fn main_menu() -> Box<dyn ActivityState> {
     menu.add_action(Pr('I'), "View a post by its ID", LogicalAction::Goto(
         OverlayActivity::GetPostIdToRead.into()));
     menu.add_blank_line();
-    menu.add_action(Pr('C'), "Compose a post",
-                    LogicalAction::NYI);
+    menu.add_action(Pr('C'), "Compose a post", LogicalAction::Goto(
+        NonUtilityActivity::ComposeToplevel.into()));
     menu.add_blank_line();
 
     // We don't need to provide a LogicalAction for this keystroke,
index 9a9b787fc025c36c48da4c0c60f106f074dc38a5..70c55cc1d3d6930d035a7c0870c13d97ff1edb48 100644 (file)
@@ -397,7 +397,6 @@ pub enum LogicalAction {
     Exit,
     Nothing,
     Error(ClientError), // throw UI into the Error Log
-    NYI, // FIXME: get rid of this once everything is implemented
 }
 
 pub trait ActivityState {
@@ -453,6 +452,8 @@ fn new_activity_state(activity: Activity, client: &mut Client) ->
             examine_user(client, name),
         Activity::NonUtil(NonUtilityActivity::SinglePost(ref id)) =>
             view_single_post(client, id),
+        Activity::NonUtil(NonUtilityActivity::ComposeToplevel) =>
+            compose_toplevel_post(client),
         _ => todo!(),
     };
 
@@ -557,7 +558,6 @@ impl TuiLogicalState {
             LogicalAction::Beep => PhysicalAction::Beep,
             LogicalAction::Exit => PhysicalAction::Exit,
             LogicalAction::Nothing => PhysicalAction::Nothing,
-            LogicalAction::NYI => PhysicalAction::Beep,
             LogicalAction::Goto(activity) => {
                 self.activity_stack.goto(activity);
                 self.changed_activity(client);