[ermine] launch a module to ask for input

Use a flutter module to ask for a package name and launch it.

Testing: manual

Change-Id: I0884251f2d6fc1102b1b92634fd17305ffc6b20a
diff --git a/bin/session_shell/ermine_session_shell/BUILD.gn b/bin/session_shell/ermine_session_shell/BUILD.gn
index a667e43..58e3688 100644
--- a/bin/session_shell/ermine_session_shell/BUILD.gn
+++ b/bin/session_shell/ermine_session_shell/BUILD.gn
@@ -30,6 +30,7 @@
     "//third_party/rust-crates/rustc_deps:itertools",
     "//third_party/rust-crates/rustc_deps:lazy_static",
     "//third_party/rust-crates/rustc_deps:parking_lot",
+    "//topaz/bin/ui/text_input_mod/public/fidl:fuchsia.textinputmod-rustc",
   ]
 }
 
diff --git a/bin/session_shell/ermine_session_shell/README.md b/bin/session_shell/ermine_session_shell/README.md
index 4adf269..cb5b0a2 100644
--- a/bin/session_shell/ermine_session_shell/README.md
+++ b/bin/session_shell/ermine_session_shell/README.md
@@ -1,6 +1,11 @@
 Ermine is a Fuchsia Session Shell intended to provide developers with a
 lovable environment for testing mods on Fuchsia.
 
+Pressing command-space (circle key in caps lock position on Eve, not sure
+what key on a Windows-style keyboard) will bring up a text entry box. Enter
+the name of a package (i.e. noodles) and it will be launched into a new
+story.
+
 This initial version implements the same approach for view hosting,
 layout and control as ``//garnet/bin/developer/[tiles|tiles_ctl]``. For
 simplicity it re-uses
diff --git a/bin/session_shell/ermine_session_shell/meta/ermine.cmx b/bin/session_shell/ermine_session_shell/meta/ermine.cmx
index 641f8c4..8e49025 100644
--- a/bin/session_shell/ermine_session_shell/meta/ermine.cmx
+++ b/bin/session_shell/ermine_session_shell/meta/ermine.cmx
@@ -5,6 +5,8 @@
     "sandbox": {
         "services": [
             "fuchsia.modular.SessionShellContext",
+            "fuchsia.modular.PuppetMaster",
+            "fuchsia.sys.Launcher",
             "fuchsia.ui.scenic.Scenic",
             "fuchsia.ui.viewsv1.ViewManager"
         ]
diff --git a/bin/session_shell/ermine_session_shell/src/ask_box.rs b/bin/session_shell/ermine_session_shell/src/ask_box.rs
new file mode 100644
index 0000000..77b7036
--- /dev/null
+++ b/bin/session_shell/ermine_session_shell/src/ask_box.rs
@@ -0,0 +1,138 @@
+use crate::APP;
+use failure::Error;
+use fidl::encoding::OutOfLine;
+use fidl::endpoints::create_endpoints;
+use fidl_fuchsia_math::{InsetF, SizeF};
+use fidl_fuchsia_textinputmod::{TextInputModMarker, TextInputModProxy, TextInputModReceiverMarker,
+                                TextInputModReceiverRequest};
+use fidl_fuchsia_ui_viewsv1::{CustomFocusBehavior, ViewLayout, ViewProperties, ViewProviderMarker};
+use fidl_fuchsia_ui_viewsv1token::ViewOwnerMarker;
+use fuchsia_app::client::{App, Launcher};
+use fuchsia_async as fasync;
+use fuchsia_scenic::{EntityNode, ImportNode, SessionPtr};
+use futures::{TryFutureExt, TryStreamExt};
+
+pub struct AskBox {
+    _app: App,
+    key: u32,
+    pub host_node: EntityNode,
+    pub text_input_mod: TextInputModProxy,
+}
+
+impl AskBox {
+    fn setup_view(
+        app: &App, key: u32, session: &SessionPtr,
+        view_container: &fidl_fuchsia_ui_viewsv1::ViewContainerProxy, import_node: &ImportNode,
+    ) -> Result<EntityNode, Error> {
+        let view_provider = app.connect_to_service(ViewProviderMarker)?;
+        let (view_owner_client, view_owner_server) = create_endpoints::<ViewOwnerMarker>()?;
+        view_provider.create_view(view_owner_server, None)?;
+        let host_node = EntityNode::new(session.clone());
+        let host_import_token = host_node.export_as_request();
+
+        view_container.add_child(key, view_owner_client, host_import_token)?;
+        //view_container.request_focus(key)?;
+        import_node.add_child(&host_node);
+        Ok(host_node)
+    }
+
+    fn setup_text_mod_receiver(app: &App) -> Result<TextInputModProxy, Error> {
+        let text_input_mod = app.connect_to_service(TextInputModMarker)?;
+
+        let (text_input_receiver, text_input_receiver_request) =
+            create_endpoints::<TextInputModReceiverMarker>()?;
+
+        fasync::spawn(
+            text_input_receiver_request
+                .into_stream()
+                .unwrap()
+                .map_ok(move |request| match request {
+                    TextInputModReceiverRequest::UserEnteredText { text, responder } => {
+                        APP.lock()
+                            .handle_suggestion(Some(&text))
+                            .unwrap_or_else(|e| eprintln!("handle_suggestion error: {:?}", e));
+                        responder
+                            .send()
+                            .unwrap_or_else(|e| eprintln!("UserEnteredText send failed: {:?}", e));
+                    }
+                    TextInputModReceiverRequest::UserCanceled { responder } => {
+                        APP.lock()
+                            .handle_suggestion(None)
+                            .unwrap_or_else(|e| eprintln!("handle_suggestion error: {:?}", e));
+                        responder
+                            .send()
+                            .unwrap_or_else(|e| eprintln!("UserCanceled send failed: {:?}", e));
+                    }
+                })
+                .try_collect::<()>()
+                .unwrap_or_else(|e| eprintln!("text input receiver error: {:?}", e)),
+        );
+
+        let f = text_input_mod.listen_for_text_input(text_input_receiver);
+        fasync::spawn(f.unwrap_or_else(|e| eprintln!("listen_for_text_input error: {:?}", e)));
+
+        Ok(text_input_mod)
+    }
+
+    pub fn focus(
+        &mut self, view_container: &fidl_fuchsia_ui_viewsv1::ViewContainerProxy,
+    ) -> Result<(), Error> {
+        println!("Want to focus {}", self.key);
+        view_container.request_focus(self.key)?;
+        Ok(())
+    }
+
+    pub fn remove(
+        &mut self, view_container: &fidl_fuchsia_ui_viewsv1::ViewContainerProxy,
+    ) -> Result<(), Error> {
+        view_container.remove_child(self.key, None)?;
+        Ok(())
+    }
+
+    pub fn new(
+        key: u32, session: &SessionPtr,
+        view_container: &fidl_fuchsia_ui_viewsv1::ViewContainerProxy, import_node: &ImportNode,
+    ) -> Result<AskBox, Error> {
+        let app = Launcher::new()?.launch(
+            "fuchsia-pkg://fuchsia.com/text_input_mod#meta/text_input_mod.cmx".to_string(),
+            None,
+        )?;
+
+        let host_node = Self::setup_view(&app, key, session, view_container, import_node)?;
+        let text_input_mod = Self::setup_text_mod_receiver(&app)?;
+
+        Ok(AskBox {
+            _app: app,
+            key,
+            host_node,
+            text_input_mod,
+        })
+    }
+
+    pub fn layout(
+        &self, view_container: &fidl_fuchsia_ui_viewsv1::ViewContainerProxy, width: f32,
+        height: f32,
+    ) {
+        let x_inset = width * 0.1;
+        let y_inset = height * 0.4;
+        let mut view_properties = ViewProperties {
+            custom_focus_behavior: Some(Box::new(CustomFocusBehavior { allow_focus: true })),
+            view_layout: Some(Box::new(ViewLayout {
+                inset: InsetF {
+                    bottom: 0.0,
+                    left: 0.0,
+                    right: 0.0,
+                    top: 0.0,
+                },
+                size: SizeF {
+                    width: width - 2.0 * x_inset,
+                    height: height - 2.0 * y_inset,
+                },
+            })),
+        };
+        view_container
+            .set_child_properties(self.key, Some(OutOfLine(&mut view_properties)))
+            .unwrap();
+        self.host_node.set_translation(x_inset, y_inset, 10.0);
+    }
+}
diff --git a/bin/session_shell/ermine_session_shell/src/main.rs b/bin/session_shell/ermine_session_shell/src/main.rs
index 41e6c08..fd3a515 100644
--- a/bin/session_shell/ermine_session_shell/src/main.rs
+++ b/bin/session_shell/ermine_session_shell/src/main.rs
@@ -7,11 +7,11 @@
                       ServiceMarker};
 use fidl_fuchsia_developer_tiles as tiles;
 use fidl_fuchsia_math::SizeF;
-use fidl_fuchsia_modular::{StoryProviderProxy, StoryProviderWatcherMarker,
-                           StoryProviderWatcherRequest, StoryState, SessionShellContextMarker,
-                           SessionShellContextProxy};
-use fidl_fuchsia_ui_input::{KeyboardEvent, KeyboardEventPhase, MODIFIER_LEFT_CONTROL,
-                            MODIFIER_RIGHT_CONTROL};
+use fidl_fuchsia_modular::{SessionShellContextMarker, SessionShellContextProxy,
+                           StoryProviderProxy, StoryProviderWatcherMarker,
+                           StoryProviderWatcherRequest, StoryState};
+use fidl_fuchsia_ui_input::{KeyboardEvent, KeyboardEventPhase, MODIFIER_LEFT_SUPER,
+                            MODIFIER_RIGHT_SUPER};
 use fidl_fuchsia_ui_policy::{KeyboardCaptureListenerHackMarker,
                              KeyboardCaptureListenerHackRequest, PresentationProxy};
 use fidl_fuchsia_ui_viewsv1::{ViewManagerMarker, ViewManagerProxy, ViewProviderMarker,
@@ -25,6 +25,7 @@
 use parking_lot::Mutex;
 use std::sync::Arc;
 
+mod ask_box;
 mod view;
 
 use crate::view::{ErmineView, ErmineViewPtr};
@@ -99,20 +100,14 @@
         Ok(())
     }
 
-    pub fn setup_keyboard_hack(&mut self) -> Result<(), Error> {
-        let (presentation_proxy, presentation_request) = create_proxy()?;
-        self.session_shell_context
-            .clone()
-            .get_presentation(presentation_request)?;
-        self.presentation_proxy = Some(presentation_proxy);
-
+    pub fn watch_for_key_event(&mut self, code_point: u32, modifiers: u32) -> Result<(), Error> {
         let mut hotkey_event = KeyboardEvent {
             event_time: 0,
             device_id: 0,
             phase: KeyboardEventPhase::Released,
             hid_usage: 0,
-            code_point: 0x67,
-            modifiers: MODIFIER_LEFT_CONTROL | MODIFIER_RIGHT_CONTROL,
+            code_point: code_point,
+            modifiers: modifiers,
         };
         let (event_listener_client, event_listener_server) = zx::Channel::create()?;
         let event_listener = ClientEnd::new(event_listener_client);
@@ -129,8 +124,8 @@
                 .into_stream()
                 .unwrap()
                 .try_for_each(move |event| match event {
-                    KeyboardCaptureListenerHackRequest::OnEvent { .. } => {
-                        println!("ermine: hotkey support goes here");
+                    KeyboardCaptureListenerHackRequest::OnEvent { event, .. } => {
+                        APP.lock().handle_hot_key(&event).expect("handle hot key");
                         futures::future::ready(Ok(()))
                     }
                 })
@@ -140,6 +135,19 @@
         Ok(())
     }
 
+    pub fn setup_keyboard_hack(&mut self) -> Result<(), Error> {
+        let (presentation_proxy, presentation_request) = create_proxy()?;
+        self.session_shell_context
+            .clone()
+            .get_presentation(presentation_request)?;
+        self.presentation_proxy = Some(presentation_proxy);
+
+        self.watch_for_key_event(0x20, MODIFIER_LEFT_SUPER)?;
+        self.watch_for_key_event(0x20, MODIFIER_RIGHT_SUPER)?;
+
+        Ok(())
+    }
+
     fn next_story_key(&mut self) -> u32 {
         let next_key = self.next_key;
         self.next_key += 1;
@@ -263,6 +271,21 @@
         );
         Ok(())
     }
+
+    pub fn handle_hot_key(&mut self, event: &KeyboardEvent) -> Result<(), Error> {
+        let key_to_use = self.next_story_key();
+        self.views[0].lock().handle_hot_key(event, key_to_use)
+    }
+
+    pub fn handle_suggestion(&mut self, text: Option<&str>) -> Result<(), Error> {
+        let mut view = self.views[0].lock();
+        if let Some(text) = text {
+            view.handle_suggestion(text)
+                .unwrap_or_else(|e| eprintln!("handle_suggestion error: {:?}", e));
+        }
+        view.remove_ask_box();
+        Ok(())
+    }
 }
 
 fn main() -> Result<(), Error> {
diff --git a/bin/session_shell/ermine_session_shell/src/view.rs b/bin/session_shell/ermine_session_shell/src/view.rs
index 64a556f..61b2fd5 100644
--- a/bin/session_shell/ermine_session_shell/src/view.rs
+++ b/bin/session_shell/ermine_session_shell/src/view.rs
@@ -2,18 +2,22 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-use failure::Error;
+use crate::ask_box::AskBox;
+use failure::{Error, ResultExt};
 use fidl::encoding::OutOfLine;
 use fidl::endpoints::{create_proxy, ClientEnd, ServerEnd, ServiceMarker};
 use fidl_fuchsia_math::{InsetF, RectF, SizeF};
-use fidl_fuchsia_modular::{Intent, StoryProviderProxy};
+use fidl_fuchsia_modular::{AddMod, Intent, PuppetMasterMarker, PuppetMasterProxy, StoryCommand,
+                           StoryProviderProxy, StoryPuppetMasterProxy, SurfaceArrangement,
+                           SurfaceDependency, SurfaceRelation};
 use fidl_fuchsia_ui_gfx::{self as gfx, ColorRgba};
 use fidl_fuchsia_ui_input::{InputConnectionMarker, InputConnectionProxy, InputListenerMarker,
-                            InputListenerRequest};
+                            InputListenerRequest, KeyboardEvent};
 use fidl_fuchsia_ui_scenic::{SessionListenerMarker, SessionListenerRequest};
 use fidl_fuchsia_ui_viewsv1::{CustomFocusBehavior, ViewContainerListenerMarker,
                               ViewContainerListenerRequest, ViewLayout, ViewListenerMarker,
                               ViewListenerRequest, ViewProperties};
+use fuchsia_app::client::connect_to_service;
 use fuchsia_async as fasync;
 use fuchsia_scenic::{EntityNode, ImportNode, Material, Rectangle, Session, SessionPtr, ShapeNode};
 use fuchsia_zircon::{self as zx, Channel};
@@ -22,6 +26,32 @@
 use parking_lot::Mutex;
 use std::collections::BTreeMap;
 use std::sync::Arc;
+use std::time::SystemTime;
+
+fn random_story_name() -> String {
+    let secs = match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) {
+        Ok(n) => n.as_secs(),
+        Err(_) => panic!("SystemTime before UNIX EPOCH!"),
+    };
+    format!("ermine-story-{}", secs)
+}
+
+fn random_mod_name() -> String {
+    let secs = match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) {
+        Ok(n) => n.as_secs(),
+        Err(_) => panic!("SystemTime before UNIX EPOCH!"),
+    };
+    format!("ermine-mod-{}", secs)
+}
+
+fn inset(rect: &mut RectF, border: f32) {
+    let inset = border.min(rect.width / 0.3).min(rect.height / 0.3);
+    rect.x += inset;
+    rect.y += inset;
+    let inset_width = inset * 2.0;
+    rect.width = rect.width - inset_width;
+    rect.height = rect.height - inset_width;
+}
 
 struct ViewData {
     key: u32,
@@ -50,6 +80,8 @@
 pub struct ErmineView {
     // Must keep the view proxy alive or the view goes away.
     _view: fidl_fuchsia_ui_viewsv1::ViewProxy,
+    puppet_master: PuppetMasterProxy,
+    story_puppet_masters: BTreeMap<String, StoryPuppetMasterProxy>,
     view_container: fidl_fuchsia_ui_viewsv1::ViewContainerProxy,
     input_connection_proxy: InputConnectionProxy,
     session: SessionPtr,
@@ -59,6 +91,7 @@
     views: BTreeMap<u32, ViewData>,
     width: f32,
     height: f32,
+    ask_box: Option<AskBox>,
 }
 
 pub type ErmineViewPtr = Arc<Mutex<ErmineView>>;
@@ -90,8 +123,12 @@
             input_connection_request.into_channel(),
         )?;
 
+        let puppet_master = connect_to_service::<PuppetMasterMarker>()?;
+
         let view_controller = ErmineView {
             _view: view,
+            puppet_master,
+            story_puppet_masters: BTreeMap::new(),
             view_container: view_container_proxy,
             input_connection_proxy: input_connection_proxy,
             session: session.clone(),
@@ -101,6 +138,7 @@
             views: BTreeMap::new(),
             width: 0.0,
             height: 0.0,
+            ask_box: None,
         };
 
         let view_controller = Arc::new(Mutex::new(view_controller));
@@ -386,66 +424,125 @@
         (keys, urls, sizes, fs)
     }
 
-    fn inset(rect: &mut RectF, border: f32) {
-        let inset = border.min(rect.width / 0.3).min(rect.height / 0.3);
-        rect.x += inset;
-        rect.y += inset;
-        let inset_width = inset * 2.0;
-        rect.width = rect.width - inset_width;
-        rect.height = rect.height - inset_width;
+    pub fn handle_hot_key(&mut self, event: &KeyboardEvent, key_to_use: u32) -> Result<(), Error> {
+        if event.code_point == 0x20 {
+            if let Some(ask_box) = self.ask_box.as_mut() {
+                ask_box.focus(&self.view_container)?;
+            } else {
+                self.ask_box = Some(AskBox::new(
+                    key_to_use,
+                    &self.session,
+                    &self.view_container,
+                    &self.import_node,
+                )?);
+                self.update();
+                self.layout();
+            }
+        }
+        Ok(())
+    }
+
+    pub fn remove_ask_box(&mut self) {
+        if let Some(mut ask_box) = self.ask_box.take() {
+            ask_box
+                .remove(&self.view_container)
+                .unwrap_or_else(|e| eprintln!("ask_box.remove error: {:?}", e));
+        }
+    }
+
+    pub fn handle_suggestion(&mut self, text: &str) -> Result<(), Error> {
+        let story_name = random_story_name();
+        let package = format!("fuchsia-pkg://fuchsia.com/{}#meta/{}.cmx", text, text);
+        let (story_puppet_master, story_puppet_master_end) =
+            create_proxy().context("handle_suggestion control_story")?;
+        self.puppet_master
+            .control_story(&story_name, story_puppet_master_end)?;
+        let mut commands = [StoryCommand::AddMod(AddMod {
+            mod_name: vec![random_mod_name()],
+            intent: Intent {
+                action: None,
+                handler: Some(package),
+                parameters: None,
+            },
+            surface_parent_mod_name: None,
+            surface_relation: SurfaceRelation {
+                arrangement: SurfaceArrangement::None,
+                dependency: SurfaceDependency::None,
+                emphasis: 1.0,
+            },
+        })];
+        story_puppet_master
+            .enqueue(&mut commands.iter_mut())
+            .context("handle_suggestion story_puppet_master.enqueue")?;
+        let f = story_puppet_master.execute();
+        fasync::spawn(
+            f.map_ok(move |_| {})
+                .unwrap_or_else(|e| eprintln!("puppetmaster error: {:?}", e)),
+        );
+        self.story_puppet_masters
+            .insert(story_name, story_puppet_master);
+
+        Ok(())
     }
 
     pub fn layout(&mut self) {
-        if self.views.is_empty() {
-            return;
+        if !self.views.is_empty() {
+            let num_tiles = self.views.len();
+
+            let columns = (num_tiles as f32).sqrt().ceil() as usize;
+            let rows = (columns + num_tiles - 1) / columns;
+            let tile_height = (self.height / rows as f32).floor();
+
+            for (row_index, view_chunk) in self
+                .views
+                .iter_mut()
+                .chunks(columns)
+                .into_iter()
+                .enumerate()
+            {
+                let tiles_in_row = if row_index == rows - 1 && (num_tiles % columns) != 0 {
+                    num_tiles % columns
+                } else {
+                    columns
+                };
+                let tile_width = (self.width / tiles_in_row as f32).floor();
+                for (column_index, (_key, view)) in view_chunk.enumerate() {
+                    let mut tile_bounds = RectF {
+                        height: tile_height,
+                        width: tile_width,
+                        x: column_index as f32 * tile_width,
+                        y: row_index as f32 * tile_height,
+                    };
+                    inset(&mut tile_bounds, 10.0);
+                    let mut view_properties = ViewProperties {
+                        custom_focus_behavior: Some(Box::new(CustomFocusBehavior {
+                            allow_focus: view.allow_focus,
+                        })),
+                        view_layout: Some(Box::new(ViewLayout {
+                            inset: InsetF {
+                                bottom: 0.0,
+                                left: 0.0,
+                                right: 0.0,
+                                top: 0.0,
+                            },
+                            size: SizeF {
+                                width: tile_bounds.width,
+                                height: tile_bounds.height,
+                            },
+                        })),
+                    };
+                    self.view_container
+                        .set_child_properties(view.key, Some(OutOfLine(&mut view_properties)))
+                        .unwrap();
+                    view.host_node
+                        .set_translation(tile_bounds.x, tile_bounds.y, 0.0);
+                    view.bounds = Some(tile_bounds);
+                }
+            }
         }
 
-        let num_tiles = self.views.len();
-
-        let columns = (num_tiles as f32).sqrt().ceil() as usize;
-        let rows = (columns + num_tiles - 1) / columns;
-        let tile_height = (self.height / rows as f32).floor();
-
-        for (row_index, view_chunk) in itertools::enumerate(&self.views.iter_mut().chunks(columns))
-        {
-            let tiles_in_row = if row_index == rows - 1 && (num_tiles % columns) != 0 {
-                num_tiles % columns
-            } else {
-                columns
-            };
-            let tile_width = (self.width / tiles_in_row as f32).floor();
-            for (column_index, (_key, view)) in view_chunk.enumerate() {
-                let mut tile_bounds = RectF {
-                    height: tile_height,
-                    width: tile_width,
-                    x: column_index as f32 * tile_width,
-                    y: row_index as f32 * tile_height,
-                };
-                Self::inset(&mut tile_bounds, 10.0);
-                let mut view_properties = ViewProperties {
-                    custom_focus_behavior: Some(Box::new(CustomFocusBehavior {
-                        allow_focus: view.allow_focus,
-                    })),
-                    view_layout: Some(Box::new(ViewLayout {
-                        inset: InsetF {
-                            bottom: 0.0,
-                            left: 0.0,
-                            right: 0.0,
-                            top: 0.0,
-                        },
-                        size: SizeF {
-                            width: tile_bounds.width,
-                            height: tile_bounds.height,
-                        },
-                    })),
-                };
-                self.view_container
-                    .set_child_properties(view.key, Some(OutOfLine(&mut view_properties)))
-                    .unwrap();
-                view.host_node
-                    .set_translation(tile_bounds.x, tile_bounds.y, 0.0);
-                view.bounds = Some(tile_bounds);
-            }
+        if let Some(ask_box) = self.ask_box.as_ref() {
+            ask_box.layout(&self.view_container, self.width, self.height);
         }
     }
 }
diff --git a/bin/ui/text_input_mod/BUILD.gn b/bin/ui/text_input_mod/BUILD.gn
new file mode 100644
index 0000000..d7b2dbc
--- /dev/null
+++ b/bin/ui/text_input_mod/BUILD.gn
@@ -0,0 +1,38 @@
+# Copyright 2018 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("//topaz/runtime/flutter_runner/flutter_app.gni")
+
+flutter_app("text_input_mod") {
+  main_dart = "lib/main.dart"
+  package_name = "text_input_mod"
+
+  fuchsia_package_name = "text_input_mod"
+
+  sources = []
+  deps = [
+    "//third_party/dart-pkg/git/flutter/packages/flutter",
+    "//topaz/public/dart/widgets:lib.widgets",
+    "//topaz/examples/modular/models",
+    "//topaz/public/dart/fuchsia_logger",
+    "//topaz/public/dart/fuchsia_modular",
+    "//topaz/public/dart/fidl",
+    "//topaz/public/dart/widgets:lib.widgets",
+    "//topaz/public/lib/app/dart",
+    "//topaz/public/lib/app_driver/dart",
+    "//topaz/public/lib/module/dart",
+    "//topaz/public/lib/module_resolver/dart",
+    "//topaz/public/lib/ui/flutter",
+    "//topaz/public/lib/widgets/dart",
+    "//topaz/bin/ui/text_input_mod/public/fidl:fuchsia.textinputmod",
+  ]
+
+  meta = [
+    {
+      path = rebase_path("meta/text_input_mod.cmx")
+      dest = "text_input_mod.cmx"
+    },
+  ]
+}
+
diff --git a/bin/ui/text_input_mod/README.md b/bin/ui/text_input_mod/README.md
new file mode 100644
index 0000000..2c2d604
--- /dev/null
+++ b/bin/ui/text_input_mod/README.md
@@ -0,0 +1 @@
+A simple module which shows a text input
\ No newline at end of file
diff --git a/bin/ui/text_input_mod/analysis_options.yaml b/bin/ui/text_input_mod/analysis_options.yaml
new file mode 100644
index 0000000..f0cea8a
--- /dev/null
+++ b/bin/ui/text_input_mod/analysis_options.yaml
@@ -0,0 +1,5 @@
+# Copyright 2018 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+include: ../../../tools/analysis_options.yaml
diff --git a/bin/ui/text_input_mod/lib/main.dart b/bin/ui/text_input_mod/lib/main.dart
new file mode 100644
index 0000000..d620434
--- /dev/null
+++ b/bin/ui/text_input_mod/lib/main.dart
@@ -0,0 +1,130 @@
+// Copyright 2018 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+import 'dart:async';
+
+import 'package:fidl/fidl.dart';
+import 'package:flutter/material.dart';
+import 'package:fidl_fuchsia_textinputmod/fidl_async.dart';
+import 'package:lib.app.dart/app_async.dart';
+
+class ErmineTextInputMod implements TextInputMod {
+  TextInputModReceiverProxy textInputModReceiverProxy;
+  Completer completer;
+  void sendData(String text) {
+    if (completer != null) {
+      textInputModReceiverProxy.userEnteredText(text).then((_) {
+        completer.complete();
+      });
+    }
+  }
+
+  void cancel() {
+    if (completer != null) {
+      textInputModReceiverProxy.userCanceled().then((_) {
+        completer.complete();
+      });
+    }
+  }
+
+  @override
+  Future<void> listenForTextInput(
+      InterfaceHandle<TextInputModReceiver> receiver) {
+    textInputModReceiverProxy = TextInputModReceiverProxy();
+
+    textInputModReceiverProxy.ctrl.bind(receiver);
+    completer = Completer<void>();
+
+    return completer.future;
+  }
+}
+
+void main() {
+  TextInputMod textInputMod = ErmineTextInputMod();
+
+  StartupContext startupContext = StartupContext.fromStartupInfo();
+
+  startupContext.outgoingServices.addServiceForName(
+    (InterfaceRequest<TextInputMod> request) {
+      TextInputModBinding().bind(textInputMod, request);
+    },
+    TextInputMod.$serviceName,
+  );
+
+  ValueNotifier<String> text = ValueNotifier('null');
+
+  final FocusNode focusNode = FocusNode();
+
+  runApp(MyApp(focusNode: focusNode, textInputMod: textInputMod, text: text));
+}
+
+class MyApp extends StatelessWidget {
+  final FocusNode focusNode;
+  final ErmineTextInputMod textInputMod;
+  final ValueNotifier<String> text;
+
+  /// Constructor
+  const MyApp({
+    @required this.focusNode,
+    @required this.textInputMod,
+    @required this.text,
+    Key key,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    FocusScope.of(context).requestFocus(
+      focusNode,
+    );
+    return MaterialApp(
+      title: 'text input',
+      theme: ThemeData(
+        primarySwatch: Colors.blue,
+      ),
+      home: Scaffold(
+        backgroundColor: Colors.blueGrey,
+        body: Container(
+          child: Center(
+            child: Row(
+              mainAxisSize: MainAxisSize.min,
+              children: [
+                Container(
+                  padding: EdgeInsets.all(5),
+                  child: IconButton(
+                    icon: Icon(Icons.cancel),
+                    iconSize: 30,
+                    color: Colors.grey[100],
+                    disabledColor: Colors.grey[500],
+                    onPressed: textInputMod.cancel,
+                  ),
+                ),
+                Flexible(
+                  child: TextField(
+                    focusNode: focusNode,
+                    onChanged: (v) {
+                      text.value = v;
+                    },
+                    onSubmitted: textInputMod.sendData,
+                    decoration: InputDecoration(hintText: 'Enter Mod URL'),
+                  ),
+                ),
+                Container(
+                  padding: EdgeInsets.all(5),
+                  child: IconButton(
+                    icon: Icon(Icons.send),
+                    iconSize: 30,
+                    color: Colors.grey[100],
+                    disabledColor: Colors.grey[500],
+                    onPressed: () {
+                      textInputMod.sendData(text.value);
+                    },
+                  ),
+                ),
+              ],
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+}
diff --git a/bin/ui/text_input_mod/manifest.json b/bin/ui/text_input_mod/manifest.json
new file mode 100644
index 0000000..06ea4de
--- /dev/null
+++ b/bin/ui/text_input_mod/manifest.json
@@ -0,0 +1,6 @@
+{
+    "@version": 2,
+    "binary": "text_input_mod",
+    "suggestion_headline": "Show a text input",
+    "intent_filters": []
+}
\ No newline at end of file
diff --git a/bin/ui/text_input_mod/meta/text_input_mod.cmx b/bin/ui/text_input_mod/meta/text_input_mod.cmx
new file mode 100644
index 0000000..2aec1c3
--- /dev/null
+++ b/bin/ui/text_input_mod/meta/text_input_mod.cmx
@@ -0,0 +1,22 @@
+{
+    "program": {
+        "data": "data/text_input_mod"
+    },
+    "sandbox": {
+        "services": [
+            "fuchsia.fonts.Provider",
+            "fuchsia.sys.Environment",
+            "fuchsia.logger.LogSink",
+            "fuchsia.cobalt.LoggerFactory",
+            "fuchsia.modular.ContextWriter",
+            "fuchsia.ui.viewsv1.ViewManager",
+            "fuchsia.ui.scenic.Scenic",
+            "fuchsia.ui.input.ImeService",
+            "fuchsia.ui.policy.Presenter",
+            "fuchsia.modular.Clipboard",
+            "fuchsia.modular.ComponentContext",
+            "fuchsia.modular.ModuleContext",
+            "fuchsia.textinputmod.TextInputModReceiver"
+        ]
+    }
+}
diff --git a/bin/ui/text_input_mod/public/fidl/BUILD.gn b/bin/ui/text_input_mod/public/fidl/BUILD.gn
new file mode 100644
index 0000000..f4c5987
--- /dev/null
+++ b/bin/ui/text_input_mod/public/fidl/BUILD.gn
@@ -0,0 +1,11 @@
+# Copyright 2018 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("//build/fidl/fidl.gni")
+
+fidl("fuchsia.textinputmod") {
+  sources = [
+    "textinputmod.fidl",
+  ]
+}
diff --git a/bin/ui/text_input_mod/public/fidl/textinputmod.fidl b/bin/ui/text_input_mod/public/fidl/textinputmod.fidl
new file mode 100644
index 0000000..f8cf2dc
--- /dev/null
+++ b/bin/ui/text_input_mod/public/fidl/textinputmod.fidl
@@ -0,0 +1,16 @@
+// Copyright 2018 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+library fuchsia.textinputmod;
+
+[Discoverable]
+interface TextInputModReceiver {
+   1: UserEnteredText(string text) -> ();
+   2: UserCanceled() -> ();
+};
+
+[Discoverable]
+interface TextInputMod {
+   1: ListenForTextInput(TextInputModReceiver receiver) -> ();
+};
diff --git a/bin/ui/text_input_mod/pubspec.yaml b/bin/ui/text_input_mod/pubspec.yaml
new file mode 100644
index 0000000..de784fc
--- /dev/null
+++ b/bin/ui/text_input_mod/pubspec.yaml
@@ -0,0 +1,3 @@
+# Copyright 2018 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
diff --git a/examples/ui/text_flutter/BUILD.gn b/examples/ui/text_flutter/BUILD.gn
index 203e272..4a974f8 100644
--- a/examples/ui/text_flutter/BUILD.gn
+++ b/examples/ui/text_flutter/BUILD.gn
@@ -15,6 +15,7 @@
     "//garnet/public/fidl/fuchsia.ui.viewsv1",
     "//third_party/dart-pkg/git/flutter/packages/flutter",
     "//topaz/public/dart/widgets:lib.widgets",
+    "//topaz/public/lib/app_driver/dart",
     "//topaz/public/lib/ui/flutter",
   ]
 
diff --git a/packages/products/ermine b/packages/products/ermine
index d828258..9644279 100644
--- a/packages/products/ermine
+++ b/packages/products/ermine
@@ -13,5 +13,9 @@
         "topaz/packages/prod/sysui",
         "topaz/packages/prod/term",
         "topaz/packages/prod/web_runner_prototype"
+    ],
+
+    "packages": [
+        "//topaz/bin/ui/text_input_mod"
     ]
 }